Add project files.
This commit is contained in:
+495
@@ -0,0 +1,495 @@
|
|||||||
|
# Shortcuts & Tech Debt
|
||||||
|
|
||||||
|
A running list of pragmatic shortcuts, hacks, and not-yet-proper-design decisions taken to keep the
|
||||||
|
MVP moving. These are deliberate and known — revisit before this is anything more than a prototype.
|
||||||
|
Newest entries near the top of each section. Date format: YYYY-MM-DD.
|
||||||
|
|
||||||
|
## Groups v2 build (in progress, 2026-06-16)
|
||||||
|
|
||||||
|
Executing the docs/GROUPS.md plan A→H. Done so far (each gated by byte-exact libsignal vectors):
|
||||||
|
- **Phase A** — Sender Key persistence: `SenderKeyRecord.Serialize/Deserialize` + `SqliteSenderKeyStore`
|
||||||
|
(DPAPI). Test: `SenderKeyPersistenceTests` (chain + skipped-key cache survive restart).
|
||||||
|
- **Phase B** — Ristretto255 hand-port (BC 2.5.1 has no Ristretto): `Wingnal.Protocol/ZkGroup/Curve/`
|
||||||
|
`Fe` (field over BC `X25519Field`; `PowP58` = ref10 pow22523 chain, since BC `PowPm5d8` is internal),
|
||||||
|
`Ristretto255` (RFC 9496), `Scalar25519` (mod ℓ, BigInteger). SQRT_M1 is COMPUTED (p≡5 mod8 ⇒
|
||||||
|
2·(2^((p-5)/8))²), not transcribed. Tests: `Ristretto255VectorTests` (RFC 9496 App-A) + `FeArithmeticTests`
|
||||||
|
(fuzz vs BigInteger). SHORTCUT: scalar/point ops are NOT constant-time yet (BigInteger scalar,
|
||||||
|
data-dependent double-and-add) — fine for client-side proofs, harden later.
|
||||||
|
- **Phase C** — poksho: `Wingnal.Protocol/ZkGroup/Poksho/` `ShoHmacSha256` (stateful HMAC sponge) +
|
||||||
|
`Statement` (Schnorr proof for linear relations). Tests reproduce poksho's own SHO + complex-statement
|
||||||
|
proof vectors byte-for-byte. Reference fetched via `gh api .../contents/... | base64 -d` (WebFetch
|
||||||
|
summarizes — unsuitable for verbatim crypto source).
|
||||||
|
- **Phase D1** — `Wingnal.Protocol/ZkGroup/GroupSecretParams`: master-key → group_id + blob_key SHO
|
||||||
|
derivation + AES-256-GCM-SIV blob encrypt/decrypt-with-padding (BC `GcmSivBlockCipher`). Gated by
|
||||||
|
zkgroup's `test_encrypt_with_padding` vectors. Unblocks group-id routing (G1) + title/avatar decryption.
|
||||||
|
- **Phase D2 PART 1 DONE — the member-hiding ciphertext layer** (byte-exact vs libsignal vectors):
|
||||||
|
- `ZkGroup/Curve/Lizard.cs` — Lizard 16-byte↔Ristretto encode/decode (curve25519-dalek-**signal** fork,
|
||||||
|
NOT RFC 9496). Encode reuses the existing single-Elligator `ElligatorRistrettoFlavor` (renamed from
|
||||||
|
`MapToPoint`, now also exposed as `Ristretto255.FromSingleElligatorBytes` = `get_point_single_elligator`).
|
||||||
|
Decode = full Elligator inverse via the Jacobi quartic (`Ristretto255.ElligatorInverse` +
|
||||||
|
`ToJacobiQuarticRistretto`). Lizard constants COMPUTED from `Fe` (SQRT_ID/DP1_OVER_DM1/MDOUBLE_/MIDOUBLE_/
|
||||||
|
MINVSQRT_ONE_PLUS_D per the dalek lizard_constants test). Gate: `LizardTests` — 3 fork encode vectors +
|
||||||
|
independent decode of the pinned points + 50× round-trip.
|
||||||
|
- `ZkGroup/ZkCredential/AttributeEncryption.cs` — zkcredential `attributes` (Chase-Perrin-Zaverucha §4.1):
|
||||||
|
`AttributeKeyPair` (a1,a2,A), `AttributeCiphertext` (E_A1=a1·M1, E_A2=a2·E_A1+M2; 64-byte ser),
|
||||||
|
`DecryptToSecondPoint`. Added `Ristretto255.Negate`.
|
||||||
|
- `ZkGroup/UidEncryption.cs` (`UidStruct`/SystemParams/`ServiceId`) + `ProfileKeyEncryption.cs`
|
||||||
|
(`ProfileKeyStruct`/SystemParams; decrypt = `decode_253_bits` × 8 sign-bit variants). SystemParams are
|
||||||
|
GENERATED via SHO (no transcribed point constants) and gated against the hardcoded 64-byte blobs.
|
||||||
|
`Ciphertexts.cs` = `UuidCiphertext`/`ProfileKeyCiphertext` (reserved 0x00 ‖ 64 = 65B).
|
||||||
|
- `GroupSecretParams.cs` extended: derives `UidKeyPair` + `ProfileKeyKeyPair` from the same SHO chain
|
||||||
|
(group_id→blob_key→uid_kp→profile_kp), `EncryptServiceId`/`DecryptServiceId`/Encrypt/DecryptProfileKey,
|
||||||
|
`PublicParamsSerialized` (97B). **This unblocks decrypting group rosters (Phase E read).**
|
||||||
|
- Gates: `LizardTests`, `AttributeEncryptionTests` (uid + profile ciphertext vectors byte-for-byte,
|
||||||
|
SystemParams==hardcoded, ACI/PNI + profile-key round-trips), `GroupSecretParamsTests` (member enc round-trip).
|
||||||
|
163 offline tests green; WinUI app builds (x64).
|
||||||
|
- **Phase D2 PART 2 DONE — the AuthCredentialWithPni credential/presentation system** (byte-exact components):
|
||||||
|
- `ZkGroup/Poksho/ShoSha256.cs` — the "innerpad" SHA-256 SHO (sibling of ShoHmacSha256). Gate: poksho's own
|
||||||
|
ShoSha256 vector. Added `GetScalar`/`GetPoint` to ShoHmacSha256.
|
||||||
|
- `ZkGroup/ZkCredential/CredentialSystem.cs` — the algebraic-MAC credential (Chase-Perrin-Zaverucha §3.1):
|
||||||
|
`CredentialSystem.SystemParams` (13 generators via ShoSha256, **gated vs the 416-byte hardcoded hex** —
|
||||||
|
this also pins ShoSha256.GetPoint), `Credential` (t,U,V), `CredentialPrivateKey` (w,wprime,W,x0,x1,y[7] +
|
||||||
|
`CredentialCore` MAC), `CredentialPublicKey` (C_W, I[6]; `IFor(numAttrs)=I[numAttrs-2]`), `CredentialKeyPair`.
|
||||||
|
- `ZkGroup/ZkCredential/CredentialProofs.cs` (`IssuanceProofBuilder` issue/verify) + `PresentationProof.cs`
|
||||||
|
(`PresentationProofBuilder.Present` / `PresentationProofVerifier.Verify`) — both build a poksho `Statement`
|
||||||
|
and reuse the byte-exact Schnorr engine. PresentationProof bincode ser = Cx0‖Cx1‖Cv‖u64le(n)‖Cy‖u64le(len)‖proof.
|
||||||
|
- `ZkGroup/AuthCredentialWithPni.cs` — label `20240222_Signal_AuthCredentialZkc`; `Issue`/`Receive`/`Present`/
|
||||||
|
`VerifyPresentation`. Presentation wire = version(3)‖PresentationProof‖aciCt(64)‖pniCt(64)‖u64le(redemption).
|
||||||
|
- Gate: `AuthCredentialTests` — ShoSha256 vector, credential SystemParams==hardcoded, full
|
||||||
|
issue→receive→present→verify round-trip (libsignal's zkc.rs test, fixed seeds) + ciphertext-consistency.
|
||||||
|
167 offline tests green; WinUI app builds (x64).
|
||||||
|
- RESIDUAL (live-only): the exact AUTH-presentation wire bytes aren't pinned to a libsignal hex vector (every
|
||||||
|
*component* is byte-pinned — poksho proof, ciphertexts, SystemParams, MAC, bincode layout matches the Rust
|
||||||
|
struct order — so the wire format should match; final proof is a 200 from storage.signal.org in Phase E).
|
||||||
|
`ServerPublicParams` parsing (to extract generic_credential_public_key from Signal's real params) is NOT
|
||||||
|
yet done — needed in Phase E to receive the real AuthCredential.
|
||||||
|
- **Phase G1 DONE (receive-side core) — receive-only group messages**:
|
||||||
|
- `Wingnal.Service/Messaging/GroupMessageProcessor.cs` — `ProcessDistribution` (install a peer's SKDM),
|
||||||
|
`DecryptGroupMessage` (SenderKeyMessage → padded Content), `GroupIdHex(masterKey)` (=GroupSecretParams
|
||||||
|
group-id, lowercase hex) for routing. No zkgroup credentials needed to RECEIVE.
|
||||||
|
- `MessageDecryptor` wiring (guarded by an optional `ISenderKeyStore` — 1:1 path unchanged when absent):
|
||||||
|
sealed-sender inner type 7 (SENDERKEY) → group decrypt → strip padding → Content; any decrypted Content
|
||||||
|
carrying `senderKeyDistributionMessage` → install it; `DecryptedMessage.GroupId` set from
|
||||||
|
`DataMessage.groupV2.masterKey`.
|
||||||
|
- Wired live: `ChatReceiver` takes an `ISenderKeyStore`; `ChatPage` passes a `SqliteSenderKeyStore`
|
||||||
|
(`_senderKeys`, %LOCALAPPDATA%\Wingnal\senderkeys.db).
|
||||||
|
- Gate: `GroupReceiveTests` (offline loopback: distribute → decrypt → group-id routing) + group-id
|
||||||
|
derivation match. 169 offline tests green; app builds (x64).
|
||||||
|
- REMAINING for full G1 visibility (Phase H territory): `ChatPage.OnMessage` does NOT yet route by
|
||||||
|
`GroupId` — a group message currently lands in the *sender's* 1:1 thread (visible but not grouped); no
|
||||||
|
group conversation/roster UI. Live-UNTESTED (headless): the real sealed-sender SENDERKEY socket path.
|
||||||
|
- **Phase E PARTIAL DONE — group storage-service read path + API client**:
|
||||||
|
- `Wingnal.Service/Protos/Groups.proto` (vendored from Signal-Android `lib/.../protowire/Groups.proto`,
|
||||||
|
`package signal`, `csharp_namespace = Wingnal.Service.Protos.Groups`): Group/Member/AccessControl/
|
||||||
|
GroupChange/GroupAttributeBlob/GroupResponse/GroupChanges etc.
|
||||||
|
- `Wingnal.Service/Groups/GroupStateCodec.cs` + `DecryptedGroup.cs` — decrypts a fetched `Group` into a
|
||||||
|
plaintext model: member service ids via `GroupSecretParams.DecryptServiceId` (UuidCiphertext), title/
|
||||||
|
description via the GCM-SIV attribute blob → `GroupAttributeBlob`. Gate: `GroupStateCodecTests` (full
|
||||||
|
round-trip — encrypt members+title with a derived GroupSecretParams, build a Group proto, decode, assert
|
||||||
|
roster/roles/title). Offline.
|
||||||
|
- `Wingnal.Service/Groups/GroupsApiClient.cs` — `storage.signal.org` (`SignalServiceConfig.StorageUrl`,
|
||||||
|
same `SignalTrust` pin). GET `/v2/groups/` → GroupResponse.Group; GET `/v2/groups/logs/{rev}` →
|
||||||
|
GroupChanges; PATCH `/v2/groups/` → GroupChangeResponse. Auth = `Basic base64(hex(GroupPublicParams):
|
||||||
|
hex(presentation))` (per Signal-Android `GroupsV2AuthorizationString`). Gate: `AuthHeader` format test.
|
||||||
|
LIVE-UNTESTED (headless): the actual storage round-trip.
|
||||||
|
- 171 offline tests green; app builds (x64).
|
||||||
|
- **Phase E REMAINING (live flow):** `ServerPublicParams` parsing (extract `generic_credential_public_key`
|
||||||
|
+ `sig_public_key`) — needs Signal's PUBLISHED production ServerPublicParams constant (data, not derivable;
|
||||||
|
fetch from Signal-Android/Desktop config like the CA pem) — and the AuthCredentialWithPni FETCH from the
|
||||||
|
chat server (GET the credential response, `AuthCredentialWithPni.Receive`). Then wire
|
||||||
|
`AuthCredentialWithPni.Present(group)` → `GroupsApiClient.GetGroupAsync` → `GroupStateCodec.Decode`.
|
||||||
|
- **Phase F PARTIAL DONE — membership model + GroupChange application** (offline core):
|
||||||
|
- `Wingnal.Service/Groups/GroupChangeApplier.cs` — applies a `GroupChange.Actions` delta to a
|
||||||
|
`DecryptedGroup` (decrypting member/title fields): add/delete/modify-role members, promote-pending,
|
||||||
|
modify title/description, revision bump. (Pending/requesting/banned/access-control/timer actions are
|
||||||
|
recognised + skipped for now.) Gate: `GroupChangeTests.Apply_*`.
|
||||||
|
- `Wingnal.Service/Groups/GroupStore.cs` (+`StoredGroup`) — SQLite (%LOCALAPPDATA%\Wingnal\groups.db),
|
||||||
|
keyed by hex group id; master key + title + roster JSON encrypted at rest via `LocalCipher`. Gate:
|
||||||
|
`GroupChangeTests.GroupStore_PersistsAndReloads`.
|
||||||
|
- `Wingnal.Protocol/ZkGroup/Poksho/PokshoSignature.cs` — poksho Schnorr signature (`public_key=private_key·G`,
|
||||||
|
reuses the Statement engine) + `Wingnal.Service/Groups/GroupSignatureVerifier.cs` (verify a GroupChange's
|
||||||
|
server signature over its actions bytes). Gate: `PokshoSignatureTests` (poksho signature vector byte-exact).
|
||||||
|
174 offline tests green; app builds (x64).
|
||||||
|
- **Phase F REMAINING (live):** the server's sig PUBLIC key (= `ServerPublicParams.sig_public_key`, the same
|
||||||
|
production-constant blocker as Phase E) to verify REAL changes; the incremental `GET /v2/groups/logs`
|
||||||
|
reconcile loop (apply changes from stored revision → latest); pending/requesting/banned member modelling.
|
||||||
|
- **Phase G2 DONE (send crypto core)**: `Wingnal.Service/Messaging/GroupSendBuilder.cs` — `CreateDistribution`
|
||||||
|
(our SKDM to send 1:1 to members) + `EncryptMessage(Content)` (the single group ciphertext, padded, that
|
||||||
|
every member device gets). Also extracted `Wingnal.Service/Messaging/MessagePadding.cs` (Add/Strip; replaced
|
||||||
|
the duplicated private padding in MessageSender + MessageDecryptor — still green). Gate:
|
||||||
|
`GroupSendReceiveLoopbackTests` (offline 3-member: encrypt-once → 2 receivers + a late-joiner decrypt +
|
||||||
|
group-id route). 175 offline tests green; app builds (x64).
|
||||||
|
- **Phase G2 REMAINING (live):** the fan-out/transport — for each member device, wrap the SenderKeyMessage as
|
||||||
|
sealed-sender (type 7) via the existing `EncryptWithCertificate` and send (reuse `MessageSender`); send the
|
||||||
|
SKDM (sealed 1:1) to members who lack our key; handle 409/410 device-set changes per member.
|
||||||
|
- **ServerPublicParams DONE** (the keystone that was blocking the live credential flow):
|
||||||
|
`Wingnal.Protocol/ZkGroup/ServerPublicParams.cs` embeds Signal's PRODUCTION `ZKGROUP_SERVER_PUBLIC_PARAMS`
|
||||||
|
base64 (from Signal-Android BuildConfig — the `AMhf5ywV…` prod value, NOT staging) and `Parse` extracts
|
||||||
|
`sig_public_key` (bytes[129..161]) + `generic_credential_public_key` (bytes[417..641], = C_W‖I[6]). Offsets
|
||||||
|
computed from the struct layout (1 + 6×64 + 32 sig + 224 generic + 32 endorsement = 673 = SERVER_PUBLIC_PARAMS_LEN).
|
||||||
|
Gate: `ServerPublicParamsTests` (decodes to 673; parses into valid canonical Ristretto points — a wrong
|
||||||
|
offset would fail Decode; also cross-validates `CredentialPublicKey.Deserialize` against REAL production data).
|
||||||
|
Also: `AuthCredentialWithPni.IssueResponse`/`ReceiveResponse` + `IssuanceProof.Serialize/Deserialize` (the
|
||||||
|
`version(3)‖IssuanceProof` wire form); `ReceiveResponse` defaults to `ServerPublicParams.Production`. Gate:
|
||||||
|
`AuthCredentialResponse_Serialization_RoundTrips`. 178 offline tests green; app builds.
|
||||||
|
- **OVERALL GroupsV2: ALL crypto/protocol layers DONE + gated** — D2 crypto, G1 receive, E decode + API
|
||||||
|
client, F apply + store + sig-verify, G2 encrypt-once, ServerPublicParams + credential-response wire.
|
||||||
|
**Remaining is live-network + UI only:** (1) the chat-server endpoints to FETCH the AuthCredentialWithPni
|
||||||
|
response (then `ReceiveResponse`) and to drive group GET/PATCH round-trips + sealed-sender group fan-out +
|
||||||
|
409/410 + `/logs` reconcile; (2) **Phase H** group UI (group conversation list + roster + new-group/add/leave
|
||||||
|
+ route ChatPage.OnMessage by `GroupId` so received group msgs land in a group thread, not the sender's 1:1).
|
||||||
|
`GroupSignatureVerifier` can now use `ServerPublicParams.Production.SigPublicKey` for real changes.
|
||||||
|
DONE this session: `SqliteSenderKeyStore.Clear()` + `GroupStore.Clear()` are now wired into the unlink wipe
|
||||||
|
(`SettingsPage.OnUnlinkClick`).
|
||||||
|
- NOTE: Lizard/Ristretto scalar+point ops remain non-constant-time (existing shortcut); decode uses
|
||||||
|
data-dependent branching — fine for client-side group decryption.
|
||||||
|
|
||||||
|
## Display / sync fixes (2026-06-16)
|
||||||
|
|
||||||
|
- ~~Thread showed only the OLDEST messages (capped ~1/8/26)~~ (FIXED). `MessageStore.Recent` was
|
||||||
|
`ORDER BY timestamp ASC LIMIT 200` → the oldest 200, so a long (imported) thread never showed recent
|
||||||
|
messages. Now selects the NEWEST N (`DESC LIMIT 500`) and reverses for chronological display. Test:
|
||||||
|
`MessageStoreTests.Recent_ReturnsNewestN_InChronologicalOrder`. (No infinite-scroll yet → messages
|
||||||
|
older than the newest 500 in a thread aren't loaded; a follow-up if needed.)
|
||||||
|
- **Contacts re-sync on unknown sender** (2026-06-16). A message from a peer we have no name for now
|
||||||
|
triggers a debounced (≤1/min) contacts re-sync (`ChatPage.MaybeResyncContacts` → `SendSyncRequests`
|
||||||
|
CONTACTS), so a newly-added phone contact resolves to a name (the response refreshes all titles via
|
||||||
|
OnSync→RefreshTitles). LIMIT: only resolves names for people saved in the phone's contacts; a pure ACI
|
||||||
|
with no saved contact still shows the short id (no CDSI/profile-name fetch).
|
||||||
|
- **Receipts (✓/✓✓) + typing indicators** (2026-06-16). `Receipts` builds the sideband `Content`
|
||||||
|
(DELIVERY/READ `ReceiptMessage`, START/STOP `TypingMessage`); `ChatReceiver` surfaces them via
|
||||||
|
`onReceipt`/`onTyping`. On receiving a 1:1 message we send DELIVERY (or READ if the thread is open);
|
||||||
|
opening a thread with unread messages sends READ; a peer's receipt advances our bubble to
|
||||||
|
Delivered/Read (monotonic via `MessageItem.Rank`, so a late DELIVERED never downgrades a READ).
|
||||||
|
Outbound typing is throttled to once/5s and STOPs on send; inbound shows a "<name> is typing…" pill
|
||||||
|
that auto-clears after 6s. Offline-tested: the wire builders (`ReceiptsTests`). LIMITS: per-message
|
||||||
|
Delivered/Read state is NOT persisted — it shows only while the thread stays open (a reopen resets
|
||||||
|
bubbles to plain time); receipts are matched only for the currently-open peer; no "viewed" (view-once)
|
||||||
|
receipts; no per-message read marker in the conversation list; the user's read-receipt privacy toggle
|
||||||
|
isn't honored (we always send). LIVE-UNTESTED (headless): the round-trip over the socket.
|
||||||
|
- **Inline media: inbound attachments download + render** (2026-06-16). `MessageDecryptor` surfaces the
|
||||||
|
first `AttachmentPointer` on `DecryptedMessage.Attachment`; `ChatPage.OnMessage` downloads+decrypts it
|
||||||
|
to `%LOCALAPPDATA%\Wingnal\media\` via `AttachmentService.SaveAsync` (reuses `AttachmentDownloader` +
|
||||||
|
the tested `AttachmentCipher`), persists the local path (`MessageStore.media` column, encrypted; idempotent
|
||||||
|
ALTER migration), and the bubble shows the image inline (`MessageItem.ImageSource`) or the
|
||||||
|
"📷/🎥/🎙/📎" placeholder for non-image/failed types. Offline-tested: pointer surfacing + file save
|
||||||
|
(`AttachmentServiceTests`). LIVE-UNTESTED (headless): the CDN GET + image render. REMAINING: SENDING
|
||||||
|
media (needs picker + CDN upload), reactions attached to their target bubble (still a separate line),
|
||||||
|
history-imported media (`BackupImporter` imports text only), tap-to-open for non-image files.
|
||||||
|
- **Groups + remaining features still missing** (known, large). No group chats (needs Groups v2 — zkgroup
|
||||||
|
+ storage service + membership + sealed-sender fan-out; only the Sender Key crypto primitive exists,
|
||||||
|
docs/GROUPS.md). Now done: inline media download/render, delivery/read receipts, typing indicators
|
||||||
|
(see entries above). Still not implemented: SENDING media (picker + CDN upload), profile name/avatar
|
||||||
|
fetch for non-contacts, calls, stories, edit/delete-for-everyone, view-once. Each is its own feature.
|
||||||
|
|
||||||
|
## Minimal-code audit (ponytail pass, 2026-06-16)
|
||||||
|
|
||||||
|
Collapsed custom code into BCL/shared helpers (all covered by existing byte-exact tests → still 113 green):
|
||||||
|
- ~6 hand-rolled UUID↔RFC-4122 byte reorders → .NET 8 `Guid.ToByteArray(bigEndian:true)` /
|
||||||
|
`new Guid(span, bigEndian:true)` (SafetyNumber, BackupKey, SenderKey wire).
|
||||||
|
- 4 copies of "service-id binary → uuid string" → one `Wingnal.Service.ServiceIds.StringFromBinary`
|
||||||
|
(MessageDecryptor, SyncProcessor, SealedSenderDecryptor, BackupImporter).
|
||||||
|
- 2 manual hex parsers → `Convert.FromHexString` (Ed25519Ct, TestHex).
|
||||||
|
- `DeviceNameCipher`'s private AES-CTR (+counter, ~30 lines) → the shared `CryptoPrimitives.AesCtr`.
|
||||||
|
|
||||||
|
DELIBERATE exceptions (NOT ponytail violations — keep as custom): the pure-.NET Signal protocol port is
|
||||||
|
the whole point of the project (no signal-cli / Rust FFI) and is security-vector-tested — the hand-written
|
||||||
|
protobuf codec in `Wingnal.Protocol` (intentionally avoids a protobuf-compiler dep there), the BigInteger
|
||||||
|
Ed25519 reference (kept for verify + as the constant-time cross-check oracle), and the ML-KEM/SPQR/ratchet
|
||||||
|
ports all stay. They're "custom" by design, not by oversight.
|
||||||
|
|
||||||
|
## Correctness / protocol risks (highest priority)
|
||||||
|
|
||||||
|
- ~~No identity-change detection (silent MITM)~~ (RESOLVED 2026-06-16). `IIdentityKeyStore.IsTrustedIdentity`
|
||||||
|
added (trust-on-first-use: a *different* key for a known address is untrusted). `SessionBuilder` (both
|
||||||
|
initiator bundle + responder prekey paths) throws `UntrustedIdentityException` before establishing, so a
|
||||||
|
changed identity no longer silently proceeds. `SafetyNumber` (Wingnal.Protocol/Identity) computes the
|
||||||
|
Signal-exact numeric fingerprint (version 2, 5200× SHA-512, 16-byte ACI stable id) — **validated
|
||||||
|
byte-exact against libsignal's own vector** so it matches the official app. `ChatPage` surfaces a
|
||||||
|
"safety number changed" dialog (warning + the number + Verify & approve) on both send and receive; approve
|
||||||
|
= `SqliteSignalProtocolStore.ResetPeer(name)` (forget old identity + dead sessions → re-trust on next
|
||||||
|
establish). Tests: SafetyNumberTests (vector/symmetry/diff), IdentityTrustTests.
|
||||||
|
Proactive "view safety number" is also available (shield button in the thread header → read-only dialog).
|
||||||
|
|
||||||
|
## Security hardening (the "nervous-making" list)
|
||||||
|
|
||||||
|
- ~~Messages/contacts plaintext at rest~~ (RESOLVED 2026-06-16). `LocalCipher` (AES-256-GCM under a
|
||||||
|
random per-install key, DPAPI-wrapped at `%LOCALAPPDATA%\Wingnal\local.key`) encrypts message **bodies**
|
||||||
|
(MessageStore) and contact **names/numbers** (ContactsStore) at rest; peer ACIs + timestamps stay
|
||||||
|
plaintext so the list/threads stay queryable/sortable. Legacy plaintext rows decrypt-through unchanged
|
||||||
|
(no migration). Tests: LocalCipherTests (round-trip, non-determinism, legacy passthrough, on-disk
|
||||||
|
ciphertext check). RESIDUAL: peer ACIs + message timing are still visible at rest (content + the
|
||||||
|
ACI→name mapping are not) — full-DB encryption would need SQLCipher (native dep).
|
||||||
|
- ~~Sealed-sender certs not validated~~ (RESOLVED 2026-06-16). `SenderCertificateValidator` checks the
|
||||||
|
trust root → server-cert → sender-cert signature chain + expiry against Signal's production trust roots;
|
||||||
|
`SealedSenderDecryptor.Decrypt` rejects an invalid/expired/forged cert. Tests: round-trip-with-chain,
|
||||||
|
reject-untrusted-root, reject-expired, reject-tamper.
|
||||||
|
- ~~EC/XEdDSA core is not constant-time~~ (RESOLVED 2026-06-16). XEdDSA **signing** now runs through
|
||||||
|
`Ed25519Ct` — a constant-time fixed-base scalar multiply + ref10 constant-time scalar arithmetic mod L
|
||||||
|
(`ScReduce`/`ScMulAdd`), built on BouncyCastle's vetted constant-time field `X25519Field`. Verify (no
|
||||||
|
secret) stays on the BigInteger reference. Gated by cross-check: `Ed25519CtTests` confirm the CT
|
||||||
|
primitives are byte-identical to the KAT-validated reference across 192 random inputs, and the XEdDSA
|
||||||
|
vector + all signing-dependent tests pass — so signing is unchanged in output, just constant-time.
|
||||||
|
GOTCHAs found during the port (documented so they don't recur): BC's `X25519Field.CMov` takes a FULL
|
||||||
|
word mask (0/0xFFFFFFFF), not 0/1; chained `Add`/`Sub` need `Carry` before `Mul`; `Mul`/`Sqr` are not
|
||||||
|
alias-safe (distinct output arrays); the `d` constant is computed from -121665/121666 (not hardcoded)
|
||||||
|
to avoid transcription error.
|
||||||
|
- ~~Sends are unsealed (metadata leak)~~ (RESOLVED 2026-06-16, opportunistic). `MessageSender.TrySealedAsync`
|
||||||
|
now sends sealed-sender when it can: it captures peers' profile keys from inbound DataMessages
|
||||||
|
(`ProfileKeyStore`, encrypted at rest), derives their unidentified-access key
|
||||||
|
(`UnidentifiedAccess.DeriveAccessKey`), fetches a delivery certificate (`GET /v1/certificate/delivery`,
|
||||||
|
cached), re-wraps the already-built per-device ciphertexts as sealed envelopes
|
||||||
|
(`SealedSenderDecryptor.EncryptWithCertificate`), and sends them WITHOUT auth + the UD-key header. The
|
||||||
|
inner ciphertext is reused (ratchet advances once), and ANY missing prerequisite or failure falls back
|
||||||
|
to the authenticated send — so it never regresses. Offline-tested: access-key derivation, encrypted
|
||||||
|
ProfileKeyStore, sealed encrypt/decrypt-with-cert. LIVE-UNTESTED (can't headless-verify): the cert
|
||||||
|
fetch + unauthenticated UD send. Metadata protection only kicks in for recipients whose profile key
|
||||||
|
we've captured (i.e., after they've messaged us); others still send authenticated.
|
||||||
|
|
||||||
|
- ~~XEdDSA verify forced the Edwards sign bit to 0~~ (RESOLVED 2026-06-16, Step 7). `XEd25519.VerifySignature`
|
||||||
|
forced A's sign bit to 0 and used the full `s`. Signal's curve25519 XEdDSA instead stashes A's natural
|
||||||
|
sign bit in the high bit of `s` (signature[63]); the verifier must read it back, reconstruct A with that
|
||||||
|
sign, and clear the bit before parsing `s`. The bug was latent (only the verify path on REAL peer
|
||||||
|
prekeys exercised it — our own signer always emits sign-bit-0 keys, the case it accepted). It blocked
|
||||||
|
outgoing sends (prekey-bundle signature verification). Fixed + KAT-tested vs libsignal's own vector
|
||||||
|
(XEd25519VectorTests).
|
||||||
|
|
||||||
|
- ~~Declaring the `spqr` capability without implementing it~~ (RESOLVED 2026-06-16). SPQR (Sparse
|
||||||
|
Post-Quantum Ratchet) is now fully implemented in `Wingnal.Protocol/Spqr/` (ML-KEM-768, SCKA state
|
||||||
|
machine, Chain, Authenticator, lib API) and integrated into the ratchet/session cipher. Validated:
|
||||||
|
a real captured phone PreKeySignalMessage now decrypts to plaintext ("Test") — bad MAC gone. Also
|
||||||
|
fixed the PQXDH HKDF label bug (`WhisperText_X25519_SHA-256_CRYSTALS-KYBER-1024`, 96-byte output)
|
||||||
|
that was the underlying cause of the bad MAC. See `docs/SPQR_PORT.md`.
|
||||||
|
- **SPQR state is in-memory only; no proto serialization yet** (2026-06-16). NOTE: the published
|
||||||
|
**ML-KEM Braid** spec (signal.org/docs/specifications/mlkembraid) confirms our SPQR/SCKA port — KDF
|
||||||
|
labels (PROTOCOL_INFO `Signal_PQCKA_V1_MLKEM768` + `:Authenticator Update` / `:SCKA Key` / `:ekheader`
|
||||||
|
/ `:ciphertext`), header = `ek_seed‖hek`, incremental ML-KEM-768, chunked erasure coding. The spec
|
||||||
|
leaves wire format/state serialization implementation-defined (custom or protobuf) and does not
|
||||||
|
specify DR integration, so our in-memory state + hand-rolled wire format are spec-compliant choices,
|
||||||
|
not gaps. Persistence remains the only real follow-up here. `SessionState.Spqr` holds a
|
||||||
|
live `SpqrRatchet` object (and `SckaStates`/`Chain`/`Authenticator` objects) rather than the
|
||||||
|
prost-serialized `PqRatchetState`. Fine while sessions are in-memory (they already are — see "Sessions
|
||||||
|
are in-memory only"), but durable session persistence needs `into_pb`/`from_pb` for the SPQR state
|
||||||
|
(proto/pq_ratchet.proto). *Proper:* port the serialize.rs layer when adding the SQLite session store.
|
||||||
|
- **ML-KEM-768 keygen uses the final-FIPS-203 rank byte; KAT-validated only on encaps/decaps**
|
||||||
|
(2026-06-16). `MlKem768.Generate` appends `k=3` in `G(d‖k)` (final FIPS-203, matches libcrux). The
|
||||||
|
available C2SP KAT vector is FIPS-203 IPD (no rank byte), so keygen is validated by self-consistency +
|
||||||
|
dk-structure while encaps/decaps (the interop-critical, IPD≡final ops) are KAT-validated byte-exact.
|
||||||
|
The rank byte only affects the local seed→keypair map (never on the wire), so this is safe.
|
||||||
|
- ~~Device-name encryption unverified~~ (RESOLVED 2026-06-16). Matched against Signal-Android
|
||||||
|
`DeviceNameCipher.kt`; the AES-CTR IV must be zero (not the syntheticIv). Round-trip tested.
|
||||||
|
- ~~QR scheme is a guess~~ (RESOLVED 2026-06-16). `sgnl://linkdevice?...` confirmed working — a real
|
||||||
|
phone linked successfully.
|
||||||
|
|
||||||
|
## Key management / persistence
|
||||||
|
|
||||||
|
- **Registered prekeys stashed inside the account blob** (2026-06-16). `SignalAccount.AciPreKeys` /
|
||||||
|
`PniPreKeys` (`RegisteredPreKeys`) carry the signed + last-resort kyber private material directly in
|
||||||
|
`account.bin` instead of a real key store. *Proper:* a persistent `SignalProtocolStore` (SQLite),
|
||||||
|
shared by linking and the session layer.
|
||||||
|
- ~~No persistent `SignalProtocolStore` for the app~~ (RESOLVED 2026-06-16). `SqliteSignalProtocolStore`
|
||||||
|
implements all five store interfaces, persisting sessions + learned identities to SQLite with each blob
|
||||||
|
DPAPI-protected at rest; identity/signed/kyber/one-time prekeys are still seeded from `account.bin`.
|
||||||
|
State serialization is a compact custom binary format covering the full DR `SessionState` (incl.
|
||||||
|
receiver chains + skipped-key seed cache) AND the SPQR ratchet (`SckaStates`/`Chain`/`Authenticator`/
|
||||||
|
polynomial encoders+decoders/version negotiation) — round-trip tested (SpqrRatchetTests,
|
||||||
|
SendPathTests). The app (`ChatPage`) now uses it. `AccountProtocolStore` (in-memory) remains for tests.
|
||||||
|
- ~~No one-time prekeys uploaded~~ (RESOLVED 2026-06-16; per PQXDH/X3DH spec recommendation). Linking now
|
||||||
|
generates 100 one-time EC prekeys, uploads them (`SignalRestClient.UploadPreKeysAsync` → `PUT
|
||||||
|
/v2/keys?identity=aci`, best-effort), and stores the privates in `account.bin`
|
||||||
|
(`SignalAccount.AciOneTimePreKeys`). `AccountProtocolStore` seeds them; `RemovePreKey` removes the
|
||||||
|
consumed key and re-persists via an `onChanged` callback (wired in `ChatPage`). Offline-tested
|
||||||
|
(SendPathTests.OneTimePreKey_IsUsedAndConsumed). REMAINING: activates on next **re-link** (live
|
||||||
|
`PUT /v2/keys` only runs then); no **PNI** one-time prekeys, no **kyber one-time** prekeys, and no
|
||||||
|
**replenishment** when the pool runs low (PUT more when the server reports few remaining).
|
||||||
|
- **Fixed prekey IDs** (2026-06-16). `LinkingManager` hardcodes `SignedPreKeyId = 1`, `KyberPreKeyId = 1`.
|
||||||
|
No rotation, no id allocation/tracking.
|
||||||
|
- **Single-file DPAPI account store with a constant entropy string** (2026-06-16). `AccountStore` writes
|
||||||
|
one `account.bin`; no schema/versioning/migration beyond the `"Wingnal.Account.v1"` entropy literal.
|
||||||
|
|
||||||
|
## Networking
|
||||||
|
|
||||||
|
- **`SignalRestClient` news up its own `HttpClient`** (2026-06-16). Not pooled / DI-injected; fine for
|
||||||
|
one-shot linking, not for app-wide use. Cert revocation check is disabled (`RevocationMode.NoCheck`)
|
||||||
|
in `SignalTrust`.
|
||||||
|
- **`SignalWebSocket` is minimal** (2026-06-16). No keepalive/ping handling, ignores RESPONSE frames,
|
||||||
|
single request/response correlation only — good enough for the provisioning handshake, not for the
|
||||||
|
authenticated chat socket.
|
||||||
|
- **Pinned CA has no rotation handling** (2026-06-16). Signal's root CA is bundled as an embedded PEM
|
||||||
|
(expires 2032-01-24). No fallback or update path if Signal rotates it.
|
||||||
|
|
||||||
|
## Sending (step 7/8)
|
||||||
|
|
||||||
|
- ~~**ChatPage send is Note-to-Self only**~~ (RESOLVED 2026-06-16, Step 8/Task 1). `ChatPage` now has a
|
||||||
|
two-pane UI: a conversation list keyed by peer + a recipient picker (`RecipientBox`/"New"). Sent and
|
||||||
|
received messages route to the selected peer's thread (`MessageStore.Conversations()` /
|
||||||
|
`Recent(peer)`). The compose box targets the selected peer (own ACI = "Note to Self").
|
||||||
|
- **Messaging others: search contacts by name** (2026-06-16). The sidebar search is an `AutoSuggestBox`
|
||||||
|
over synced contacts (`ContactsStore.Search`): type a name → pick a contact → the thread opens (we use
|
||||||
|
their synced ACI). A raw ACI UUID still works as a fallback. The conversation list itself shows ONLY
|
||||||
|
people you've actually chatted with (by design). Send path logs to `wingnal.log` (`send: -> … result …`);
|
||||||
|
the log confirms sends to other users return `ok=True … sent to N device(s)`.
|
||||||
|
- **Sent messages sync to your own other devices** (2026-06-16, FIXED). When you message someone else,
|
||||||
|
`MessageSender.SendTextAsync` now also sends a `SyncMessage.Sent` transcript (destinationServiceId +
|
||||||
|
timestamp + the DataMessage) to your OWN ACI, so your phone/other linked devices show the outgoing
|
||||||
|
message (`BuildSentTranscript`; best-effort, logged). Note-to-Self already reached them directly.
|
||||||
|
Offline-tested: the transcript decrypts as an *outgoing* message routed to the real peer's thread
|
||||||
|
(`SendPathTests.SentTranscript_DecryptsAsOutgoingToTheRealPeer`).
|
||||||
|
- ~~**Sealed-sender certs with `signer.id` rejected → "not seeing others"**~~ (FIXED 2026-06-17). LIVE BUG
|
||||||
|
found via the running app's log: **145** inbound sealed-sender messages from real people failed with
|
||||||
|
`InvalidMessageException: sender certificate has no server certificate`. Root cause: real Signal sender
|
||||||
|
certificates DON'T embed the ServerCertificate (oneof `signer` field 5) — they reference it by **id**
|
||||||
|
(field 8, production id=3) to save space, and libsignal resolves the id from a hardcoded
|
||||||
|
`KNOWN_SERVER_CERTIFICATES` map. Our `SenderCertificateValidator` only accepted the embedded form and threw
|
||||||
|
for every real message. FIX: embedded the known server certificates (id 2 staging, id 3 production) and
|
||||||
|
resolve `signer.id` → that cert before the trust-root chain check. Validated against the captured
|
||||||
|
failed-envelope-*.bin (the "no server certificate" errors are GONE; remaining are SSv2 + stale-prekey
|
||||||
|
captures). Regression: `KnownServerCertTests` (the embedded prod cert verifies against a production trust
|
||||||
|
root; an id-based sender cert resolves past the rejection). This was THE reason real 1:1 (and group)
|
||||||
|
messages from others weren't appearing. Classic "passes our own round-trip, fails the real wire" — like the
|
||||||
|
earlier SPQR field + XEdDSA-verify bugs.
|
||||||
|
- **STILL A GAP — Sealed Sender v2 (multi-recipient) receive not implemented** (16 inbound msgs throw
|
||||||
|
`sealed sender v2 not supported`). Modern Signal **group** sends use SSv2 multi-recipient, so this likely
|
||||||
|
blocks receiving many group messages even after the cert fix + the G1 SENDERKEY path. Next receive priority.
|
||||||
|
- **GroupsV2 receive routed in the UI** (2026-06-17). `MessageDecryptor` surfaces `DecryptedMessage.GroupId`
|
||||||
|
+ `GroupMasterKey` (from GroupContextV2, on both inbound + synced-sent); `ChatPage.OnMessage` routes group
|
||||||
|
messages to a `group:{id}` conversation (own thread, "Group <id8>" title until a live fetch names it),
|
||||||
|
persists the master key via `GroupStore.EnsureGroupKnown`, and skips 1:1 receipts/contact-resync for groups.
|
||||||
|
So a decrypted group message now shows as its own conversation (not in the sender's 1:1 thread).
|
||||||
|
- **Sealed-sender receive (v1) implemented** (2026-06-16). Incoming `UNIDENTIFIED_SENDER` envelopes (how
|
||||||
|
modern Signal clients message each other) are now decrypted: `SealedSenderDecryptor` (v1) parses the
|
||||||
|
outer `UnidentifiedSenderMessage`, derives ephemeral+static keys (HKDF salt `UnidentifiedDelivery`,
|
||||||
|
AES-256-CTR + HMAC-SHA256[:10]), recovers the sender from the certificate, and `MessageDecryptor` runs
|
||||||
|
the inner ciphertext through the normal session pipeline. Round-trip + tamper tested
|
||||||
|
(`SealedSenderTests`). GAPS: (1) **Sealed Sender v2** (multi-recipient, version 0x22/0x23) is NOT
|
||||||
|
handled — throws; (2) the sender **certificate's server signature is not validated** against Signal's
|
||||||
|
trust root (the inner Double-Ratchet MAC still authenticates content cryptographically, so this only
|
||||||
|
affects server-attested sender identity); (3) inbound group sender-key (type 7) is skipped; (4) we
|
||||||
|
don't send delivery receipts back.
|
||||||
|
- **Message list scrolls to newest** (2026-06-16). Opening a thread (and each new/sent message) scrolls
|
||||||
|
to the last (newest) message; `MessageList` is `SelectionMode=None`/`IsItemClickEnabled=False` so a
|
||||||
|
tap doesn't jump/scroll. Order is oldest→newest top-to-bottom (standard chat).
|
||||||
|
- **e164 recipients can't be resolved** (2026-06-16, Task 1). `RecipientResolver` accepts an ACI UUID
|
||||||
|
directly (normalized lowercase) and rejects a `+e164` with a clear message: Signal removed the
|
||||||
|
unauthenticated number→ACI lookup, so it needs the Contact Discovery Service (CDSI, an SGX enclave)
|
||||||
|
which isn't implemented. *Proper:* port the CDSI handshake (or surface it from Task 3 contact sync).
|
||||||
|
- ~~**Separate send store**~~ (RESOLVED 2026-06-16, Task 1). `ChatPage` now uses ONE durable
|
||||||
|
`SqliteSignalProtocolStore` (`protocol.db`) shared by send AND receive. Sessions are keyed by peer
|
||||||
|
address, so one store serves the whole conversation without initiator/responder clobber: send tries
|
||||||
|
`BuildFromExistingSessions` first (reusing a session the receive path established) and only fetches a
|
||||||
|
bundle on the first message or a 409/410. Note-to-Self stays clobber-free because our own device is
|
||||||
|
skipped on send. Proven by `SendPathTests.BidirectionalConversation_RoundTripsThroughOneSharedPerPeerSession`.
|
||||||
|
MIGRATION NOTE: existing installs' old `protocol-send.db`/`protocol-recv.db` are abandoned (not
|
||||||
|
migrated) — the next inbound prekey message / outbound bundle fetch re-establishes sessions in
|
||||||
|
`protocol.db`.
|
||||||
|
- **Active-session reuse on send** (2026-06-16, RESOLVED the per-send prekey fetch; Sesame §3). `MessageSender`
|
||||||
|
tries `BuildFromExistingSessions` first — if the recipient's devices already have sessions, it encrypts
|
||||||
|
with them and sends WITHOUT a `/v2/keys` fetch; it only fetches on the first message or a 409/410
|
||||||
|
device-set change. Now that sessions are durable (SQLite), reuse also works across restarts. (The
|
||||||
|
`dotnet test SendLiveTests` notify hack still uses a fresh in-memory store, so it fetches once per run;
|
||||||
|
a `wingnal-notify` CLI pointed at the durable store would avoid that.)
|
||||||
|
- ~~`MessageSender` has no 409/410 device-set recovery~~ (RESOLVED 2026-06-16 via Sesame spec §3.3).
|
||||||
|
On a 409 (mismatched) / 410 (stale) response, `SendTextAsync` now re-fetches the authoritative device
|
||||||
|
list (`GET /v2/keys/{id}/*`, which reconciles both added and removed/rotated devices) and retries,
|
||||||
|
bounded to `MaxSendAttempts=3` per Sesame's anti-loop guidance. (Per-device session insert/archive
|
||||||
|
semantics from Sesame §3 are not yet modeled — we use one in-memory store and re-establish on retry.)
|
||||||
|
- **No `wingnal-notify` CLI** (2026-06-16). The "message me when done" flow shells out to the gated
|
||||||
|
`SendLiveTests` via `dotnet test` (slow). A tiny console sender would be cleaner + hook-able. See
|
||||||
|
memory feedback_notify_when_done.
|
||||||
|
|
||||||
|
## App architecture / UI
|
||||||
|
|
||||||
|
- **No real MVVM / DI yet** (2026-06-16). `MainWindow` directly `new`s `AccountStore` and routes by
|
||||||
|
`AccountStore.Exists`; `LinkDevicePage` constructs the linking stack inline. *Proper:* view models +
|
||||||
|
a service/DI layer.
|
||||||
|
- **`ChatPage` is a placeholder** (2026-06-16). Shows account info only; messaging UI is steps 6–8.
|
||||||
|
- **"Unlink & re-link" doesn't call a server unlink** (2026-06-16). `ChatPage.OnUnlinkClick` now wipes
|
||||||
|
ALL local state (account.bin + messages.db + contacts.db + protocol.db sessions/identities, via each
|
||||||
|
store's `Clear()`) and clears the in-memory UI — so a re-link starts clean and never reuses sessions
|
||||||
|
tied to the old identity keys. It still does NOT call a server unlink (`DELETE /v1/devices/...`), so
|
||||||
|
the device stays registered on the account until removed from the phone.
|
||||||
|
- **Device name hardcoded to "Wingnal"** (2026-06-16). `LinkingManager` default; no UI to set it.
|
||||||
|
|
||||||
|
## Messaging / receive (step 6)
|
||||||
|
|
||||||
|
- ~~Sessions are in-memory only~~ (RESOLVED 2026-06-16). The app uses `SqliteSignalProtocolStore`;
|
||||||
|
sessions + learned identities survive restarts (SessionRecord/SessionState/SPQR serialization added).
|
||||||
|
~~Send and receive use SEPARATE DB files~~ → UNIFIED 2026-06-16 (Task 1) to ONE `protocol.db` shared by
|
||||||
|
send + receive (see "Separate send store" under Sending).
|
||||||
|
- **Ack before decrypt** (2026-06-16). `ChatReceiver` replies 200 to every `/api/v1/message` before
|
||||||
|
attempting decryption, so a message we can't decrypt is dropped from the server queue (avoids a
|
||||||
|
redelivery loop). *Proper:* ack only after successful persist; handle decrypt failures explicitly.
|
||||||
|
- **No outgoing keepalive** (2026-06-16). We answer inbound keepalives but don't send our own, so the
|
||||||
|
socket may idle-timeout (~60s). Fine for draining the queue on connect; not for staying connected.
|
||||||
|
- ~~Chat socket auth via query params~~ (RESOLVED 2026-06-16). Query-param auth connected but landed
|
||||||
|
UNAUTHENTICATED (upgrade OK, zero frames). Switched to `Authorization: Basic {aci.deviceId:password}`
|
||||||
|
header — server then delivered a queued message + `/api/v1/queue/empty`. Header auth is correct.
|
||||||
|
- **ack-after-success only** (2026-06-16). ChatReceiver now acks a /api/v1/message frame only after a
|
||||||
|
successful decrypt; a message we can't decrypt is NOT acked and the server redelivers it every
|
||||||
|
reconnect. Good for debugging, but a permanently-undecryptable message blocks the queue. Revisit once
|
||||||
|
decryption is solid (then ack-and-log-failures instead).
|
||||||
|
- **Receive only handles ACI sessions + DataMessage/SyncMessage.Sent text** (2026-06-16). PNI sessions,
|
||||||
|
sealed sender, receipts, typing, and non-text content are ignored. No one-time prekeys means every
|
||||||
|
inbound session uses the last-resort kyber prekey.
|
||||||
|
|
||||||
|
## Groups (Task 2 — Sender Key crypto core)
|
||||||
|
|
||||||
|
- **Sender Key primitive is crypto-core only; not wired into the app** (2026-06-16). `Wingnal.Protocol/
|
||||||
|
Groups/` (SenderKeyMessage/SKDM, state/record, GroupSessionBuilder/Cipher, in-memory store) is
|
||||||
|
byte-exact with libsignal v0.96.1 and offline-tested (one→many, OOO/skip, tamper, wire round-trip). It
|
||||||
|
does NOT join or message real Signal groups — that needs Groups v2 (zkgroup credentials, group master
|
||||||
|
key, groups storage service, membership, sealed sender). Full plan in `docs/GROUPS.md`.
|
||||||
|
- **Sender-key state is in-memory only** (2026-06-16). No `storage.proto` SenderKeyRecordStructure
|
||||||
|
serialization yet; `InMemorySenderKeyStore` only. *Proper:* serialize + a SQLite `ISenderKeyStore`
|
||||||
|
before any app use (Step A in docs/GROUPS.md).
|
||||||
|
- **31-bit chain id via `RandomNumberGenerator`** (2026-06-16). `GroupSessionBuilder.Create` uses
|
||||||
|
`RandomUInt32() >> 1` (matches libsignal's Java-compat 31-bit id). Fine.
|
||||||
|
|
||||||
|
## Sync & history (Task 3)
|
||||||
|
|
||||||
|
- ~~link'n'sync history backfill is scaffolded, not built~~ (BUILT 2026-06-16). Full engine in
|
||||||
|
`Wingnal.Service/Sync/`: `BackupKey` (HKDF chain, validated byte-exact vs libsignal vector),
|
||||||
|
`BackupReader` (HMAC+AES-CBC+unpad+gzip+varint frames; parser validated vs libsignal canonical
|
||||||
|
backup), `BackupImporter` (Recipient/Chat/ChatItem → contacts+messages), vendored `Protos/Backup.proto`,
|
||||||
|
`MessageHistoryImporter` orchestrator. QR advertises `backup5`; `LinkingManager` captures
|
||||||
|
`ephemeralBackupKey`; `ChatPage` runs the backfill once on a fresh link+sync connect. 91 offline tests.
|
||||||
|
LIVE-ONLY caveats (untested without a re-link): the `transfer_archive` descriptor shape + CDN object
|
||||||
|
path (`/attachments/{key}`), CDN cert pinning (may be a public CA, not the bundled Signal CA), and
|
||||||
|
whether the archive is gzip'd vs raw. Old messages require a RE-LINK (history transfers only at link
|
||||||
|
time; Signal stores none server-side). LIVE-CONFIRMED working: a re-link imported 3473 messages + 53
|
||||||
|
contacts. See `docs/SYNC.md`.
|
||||||
|
- **link'n'sync re-link reliability hardened** (2026-06-16). After a live re-link that didn't restore:
|
||||||
|
(1) the one-time `ephemeralBackupKey` is cleared ONLY on a definitive outcome (imported, or primary
|
||||||
|
reports no archive); on a transient failure (poll timeout / network / download error)
|
||||||
|
`MessageHistoryImporter.Result.ShouldRetry=true` and `ChatPage` KEEPS the key so the next launch retries
|
||||||
|
(previously cleared regardless → lost the single chance if the phone was slow to upload). (2) A
|
||||||
|
`_historyImportStarted` guard stops the observed double-import (archive written twice). (3)
|
||||||
|
`BackupImporter` dedups via `MessageStore.ExistingKeys()` (peer|ts|outgoing|body) so retries/re-imports
|
||||||
|
never duplicate. NOTE: history only arrives if the phone offers link+sync — the user must tap "transfer
|
||||||
|
messages" on the phone during each re-link.
|
||||||
|
- **Conversation list showed a wrong "latest message" date after import** (2026-06-16, FIXED). Symptom:
|
||||||
|
the newest message showed as months old (e.g. 1/8/2026) even though the data was correct (timestamps
|
||||||
|
spanned Dec 2025 → today). Cause: `MessageStore.Conversations()` picked each peer's row by `MAX(id)`
|
||||||
|
(last *inserted*) — but a bulk import inserts in archive order, not time order. Now uses `MAX(timestamp)`
|
||||||
|
(SQLite fills the bare columns from the max-timestamp row) so the list shows + sorts by the truly newest
|
||||||
|
message. Thread view (`Recent`) was already `ORDER BY timestamp ASC` (correct).
|
||||||
|
- **Duplicate messages from the earlier double-import** (2026-06-16, FIXED). `MessageStore.Deduplicate()`
|
||||||
|
(DELETE keeping MIN(id) per peer+timestamp+outgoing+body) runs once on `ChatPage` load to clean exact
|
||||||
|
duplicates; the import dedup prevents new ones. (Send-vs-import near-duplicates with slightly different
|
||||||
|
ms timestamps aren't caught — minor.)
|
||||||
|
- **CDN cert pinning unverified** (2026-06-16). `AttachmentDownloader` validates via `SignalTrust` (the
|
||||||
|
bundled Signal CA), but Signal's CDNs (cdn.signal.org / cdn2 / cdn3) may chain to a different (public)
|
||||||
|
CA — the download path is untested live (only `AttachmentCipher.Decrypt` is offline-tested). Revisit
|
||||||
|
when wiring real attachment/contacts-blob downloads; may need a CDN-specific CA or OS trust for CDN.
|
||||||
|
- **SyncMessage.Read surfaced but not persisted** (2026-06-16). `SyncProcessor.ReadReceiptReceived`
|
||||||
|
fires per read receipt, but there's no read column in `messages.db` yet, so read state isn't stored.
|
||||||
|
- **Contacts sync imports ACI + name/number only** (2026-06-16). `SyncProcessor.ImportContacts` skips
|
||||||
|
e164-only (ACI-less) contacts and ignores avatars/expireTimer/inbox beyond name. GROUPS sync omitted
|
||||||
|
(removed from the sync protocol; groups via storage service — docs/GROUPS.md).
|
||||||
|
- **Sync requests sent every connect** (2026-06-16). `ChatPage` fires CONTACTS/BLOCKED/CONFIGURATION
|
||||||
|
requests on each chat connect; no throttle/once-per-link guard. Fine (cheap), but could be gated.
|
||||||
|
|
||||||
|
## Resolved
|
||||||
|
|
||||||
|
- ~~KEM ciphertext prefix for messaging~~ (RESOLVED 2026-06-16). `SessionBuilder` now serializes the
|
||||||
|
kyber ciphertext with the `0x08` prefix on the wire and strips it on decapsulate.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Crypto;
|
||||||
|
|
||||||
|
/// <summary>Thin wrappers over the .NET BCL for the AEAD/KDF/MAC primitives Signal uses.</summary>
|
||||||
|
public static class CryptoPrimitives
|
||||||
|
{
|
||||||
|
/// <summary>HKDF-SHA256 (extract + expand). Signal's standard KDF.</summary>
|
||||||
|
public static byte[] Hkdf(byte[] inputKeyMaterial, byte[]? salt, byte[]? info, int outputLength)
|
||||||
|
{
|
||||||
|
return HKDF.DeriveKey(HashAlgorithmName.SHA256, inputKeyMaterial, outputLength, salt, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>HMAC-SHA256.</summary>
|
||||||
|
public static byte[] HmacSha256(byte[] key, ReadOnlySpan<byte> data)
|
||||||
|
{
|
||||||
|
using var hmac = new HMACSHA256(key);
|
||||||
|
return hmac.ComputeHash(data.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>AES-256-CBC encrypt with PKCS7 padding.</summary>
|
||||||
|
public static byte[] AesCbcEncrypt(byte[] key, byte[] iv, byte[] plaintext)
|
||||||
|
{
|
||||||
|
using var aes = Aes.Create();
|
||||||
|
aes.Key = key;
|
||||||
|
aes.IV = iv;
|
||||||
|
aes.Mode = CipherMode.CBC;
|
||||||
|
aes.Padding = PaddingMode.PKCS7;
|
||||||
|
using var enc = aes.CreateEncryptor();
|
||||||
|
return enc.TransformFinalBlock(plaintext, 0, plaintext.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>AES-256-CBC decrypt with PKCS7 padding.</summary>
|
||||||
|
public static byte[] AesCbcDecrypt(byte[] key, byte[] iv, byte[] ciphertext)
|
||||||
|
{
|
||||||
|
using var aes = Aes.Create();
|
||||||
|
aes.Key = key;
|
||||||
|
aes.IV = iv;
|
||||||
|
aes.Mode = CipherMode.CBC;
|
||||||
|
aes.Padding = PaddingMode.PKCS7;
|
||||||
|
using var dec = aes.CreateDecryptor();
|
||||||
|
return dec.TransformFinalBlock(ciphertext, 0, ciphertext.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>AES-CTR with a 128-bit big-endian counter starting at <paramref name="iv"/>. Symmetric
|
||||||
|
/// (same call encrypts and decrypts). Used by sealed sender (zero IV) and DeviceNameCipher.</summary>
|
||||||
|
public static byte[] AesCtr(byte[] key, byte[] iv, byte[] input)
|
||||||
|
{
|
||||||
|
using var aes = Aes.Create();
|
||||||
|
aes.Key = key;
|
||||||
|
aes.Mode = CipherMode.ECB;
|
||||||
|
aes.Padding = PaddingMode.None;
|
||||||
|
using ICryptoTransform ecb = aes.CreateEncryptor();
|
||||||
|
|
||||||
|
var counter = (byte[])iv.Clone();
|
||||||
|
var output = new byte[input.Length];
|
||||||
|
var keystream = new byte[16];
|
||||||
|
for (int offset = 0; offset < input.Length; offset += 16)
|
||||||
|
{
|
||||||
|
ecb.TransformBlock(counter, 0, 16, keystream, 0);
|
||||||
|
int block = Math.Min(16, input.Length - offset);
|
||||||
|
for (int i = 0; i < block; i++)
|
||||||
|
output[offset + i] = (byte)(input[offset + i] ^ keystream[i]);
|
||||||
|
for (int i = counter.Length - 1; i >= 0; i--)
|
||||||
|
if (++counter[i] != 0) break;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>AES-256-GCM encrypt. Returns ciphertext || 16-byte tag.</summary>
|
||||||
|
public static byte[] AesGcmEncrypt(byte[] key, byte[] nonce, byte[] plaintext, byte[]? associatedData = null)
|
||||||
|
{
|
||||||
|
var ciphertext = new byte[plaintext.Length];
|
||||||
|
var tag = new byte[16];
|
||||||
|
using var gcm = new AesGcm(key, 16);
|
||||||
|
gcm.Encrypt(nonce, plaintext, ciphertext, tag, associatedData);
|
||||||
|
var result = new byte[ciphertext.Length + tag.Length];
|
||||||
|
Array.Copy(ciphertext, 0, result, 0, ciphertext.Length);
|
||||||
|
Array.Copy(tag, 0, result, ciphertext.Length, tag.Length);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>AES-256-GCM decrypt. Input is ciphertext || 16-byte tag.</summary>
|
||||||
|
public static byte[] AesGcmDecrypt(byte[] key, byte[] nonce, byte[] ciphertextAndTag, byte[]? associatedData = null)
|
||||||
|
{
|
||||||
|
int ctLen = ciphertextAndTag.Length - 16;
|
||||||
|
if (ctLen < 0) throw new ArgumentException("ciphertext too short", nameof(ciphertextAndTag));
|
||||||
|
var ciphertext = new byte[ctLen];
|
||||||
|
var tag = new byte[16];
|
||||||
|
Array.Copy(ciphertextAndTag, 0, ciphertext, 0, ctLen);
|
||||||
|
Array.Copy(ciphertextAndTag, ctLen, tag, 0, 16);
|
||||||
|
var plaintext = new byte[ctLen];
|
||||||
|
using var gcm = new AesGcm(key, 16);
|
||||||
|
gcm.Decrypt(nonce, ciphertext, tag, plaintext, associatedData);
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Org.BouncyCastle.Crypto.Agreement;
|
||||||
|
using Org.BouncyCastle.Crypto.Parameters;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Curve;
|
||||||
|
|
||||||
|
/// <summary>A Curve25519 key pair. Private/public keys are raw 32-byte values.</summary>
|
||||||
|
public sealed class ECKeyPair
|
||||||
|
{
|
||||||
|
/// <summary>32-byte clamped X25519 private scalar (little-endian).</summary>
|
||||||
|
public byte[] PrivateKey { get; }
|
||||||
|
|
||||||
|
/// <summary>32-byte Montgomery u-coordinate public key.</summary>
|
||||||
|
public byte[] PublicKey { get; }
|
||||||
|
|
||||||
|
public ECKeyPair(byte[] privateKey, byte[] publicKey)
|
||||||
|
{
|
||||||
|
PrivateKey = privateKey;
|
||||||
|
PublicKey = publicKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// X25519 ECDH plus Signal's DjbECPublicKey (0x05-prefixed, 33-byte) serialization.
|
||||||
|
/// Private keys are clamped at generation so the same scalar is used consistently for both ECDH
|
||||||
|
/// and XEdDSA signing (clamping is idempotent, so BouncyCastle re-clamping during agreement is a no-op).
|
||||||
|
/// </summary>
|
||||||
|
public static class Curve25519
|
||||||
|
{
|
||||||
|
/// <summary>Signal's type byte for Curve25519 (DJB) public keys.</summary>
|
||||||
|
public const byte DjbType = 0x05;
|
||||||
|
|
||||||
|
public static ECKeyPair GenerateKeyPair()
|
||||||
|
{
|
||||||
|
byte[] priv = RandomNumberGenerator.GetBytes(32);
|
||||||
|
Clamp(priv);
|
||||||
|
byte[] pub = DerivePublicKey(priv);
|
||||||
|
return new ECKeyPair(priv, pub);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Derives the 32-byte Montgomery public key from a 32-byte private scalar.</summary>
|
||||||
|
public static byte[] DerivePublicKey(byte[] privateKey)
|
||||||
|
{
|
||||||
|
var sk = new X25519PrivateKeyParameters(privateKey, 0);
|
||||||
|
return sk.GeneratePublicKey().GetEncoded();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>X25519 ECDH. Returns the 32-byte shared secret.</summary>
|
||||||
|
public static byte[] CalculateAgreement(byte[] theirPublicKey, byte[] ourPrivateKey)
|
||||||
|
{
|
||||||
|
var agreement = new X25519Agreement();
|
||||||
|
agreement.Init(new X25519PrivateKeyParameters(ourPrivateKey, 0));
|
||||||
|
var secret = new byte[agreement.AgreementSize];
|
||||||
|
agreement.CalculateAgreement(new X25519PublicKeyParameters(theirPublicKey, 0), secret, 0);
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serializes a raw 32-byte public key to a 33-byte DjbECPublicKey (0x05 || u).</summary>
|
||||||
|
public static byte[] EncodePoint(byte[] publicKey)
|
||||||
|
{
|
||||||
|
if (publicKey.Length != 32) throw new ArgumentException("public key must be 32 bytes", nameof(publicKey));
|
||||||
|
var encoded = new byte[33];
|
||||||
|
encoded[0] = DjbType;
|
||||||
|
Array.Copy(publicKey, 0, encoded, 1, 32);
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parses a serialized public key (33-byte 0x05-prefixed, or raw 32-byte) to raw 32 bytes.</summary>
|
||||||
|
public static byte[] DecodePoint(ReadOnlySpan<byte> serialized)
|
||||||
|
{
|
||||||
|
if (serialized.Length == 33)
|
||||||
|
{
|
||||||
|
if (serialized[0] != DjbType) throw new ArgumentException($"unsupported key type {serialized[0]}");
|
||||||
|
return serialized.Slice(1, 32).ToArray();
|
||||||
|
}
|
||||||
|
if (serialized.Length == 32) return serialized.ToArray();
|
||||||
|
throw new ArgumentException($"bad public key length {serialized.Length}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Clamp(byte[] scalar)
|
||||||
|
{
|
||||||
|
scalar[0] &= 248;
|
||||||
|
scalar[31] &= 127;
|
||||||
|
scalar[31] |= 64;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
using Org.BouncyCastle.Math.EC.Rfc7748;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Curve;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constant-time Ed25519 primitives for the SIGNING path (the only place a long-term secret is used),
|
||||||
|
/// built on BouncyCastle's vetted constant-time field <see cref="X25519Field"/>. Provides:
|
||||||
|
/// a constant-time fixed-base scalar multiply (no data-dependent branches/table indexing) and the
|
||||||
|
/// ref10 constant-time scalar arithmetic mod L (<see cref="ScReduce"/>, <see cref="ScMulAdd"/>).
|
||||||
|
///
|
||||||
|
/// Correctness is gated by a cross-check test: signatures produced via these must be byte-identical to
|
||||||
|
/// the existing KAT-validated reference (<c>Ed25519Math</c>) for the same inputs, so a bug here can't
|
||||||
|
/// silently change/break signatures.
|
||||||
|
/// </summary>
|
||||||
|
internal static class Ed25519Ct
|
||||||
|
{
|
||||||
|
// Base point B (x,y), little-endian 32-byte encodings (standard Ed25519 generator).
|
||||||
|
private static readonly byte[] BxBytes = Convert.FromHexString("1ad5258f602d56c9b2a7259560c72c695cdcd6fd31e2a4c0fe536ecdd3366921");
|
||||||
|
private static readonly byte[] ByBytes = Convert.FromHexString("5866666666666666666666666666666666666666666666666666666666666666");
|
||||||
|
|
||||||
|
private static readonly int[] D2 = BuildD2();
|
||||||
|
private static readonly Pt Base = BuildBase();
|
||||||
|
|
||||||
|
private sealed class Pt
|
||||||
|
{
|
||||||
|
public readonly int[] X = X25519Field.Create();
|
||||||
|
public readonly int[] Y = X25519Field.Create();
|
||||||
|
public readonly int[] Z = X25519Field.Create();
|
||||||
|
public readonly int[] T = X25519Field.Create();
|
||||||
|
}
|
||||||
|
|
||||||
|
// d = -121665/121666 (mod p); compute it from the definition to avoid transcription error.
|
||||||
|
private static int[] BuildD2()
|
||||||
|
{
|
||||||
|
byte[] numBytes = new byte[32]; numBytes[0] = 0x41; numBytes[1] = 0xDB; numBytes[2] = 0x01; // 121665 = 0x1DB41
|
||||||
|
byte[] denBytes = new byte[32]; denBytes[0] = 0x42; denBytes[1] = 0xDB; denBytes[2] = 0x01; // 121666 = 0x1DB42
|
||||||
|
|
||||||
|
int[] num = X25519Field.Create(); X25519Field.Decode(numBytes, 0, num);
|
||||||
|
int[] den = X25519Field.Create(); X25519Field.Decode(denBytes, 0, den);
|
||||||
|
int[] dinv = X25519Field.Create(); X25519Field.Inv(den, dinv);
|
||||||
|
int[] d = X25519Field.Create(); X25519Field.Mul(num, dinv, d);
|
||||||
|
X25519Field.CNegate(1, d); X25519Field.Carry(d); // d = -num/den
|
||||||
|
int[] d2 = X25519Field.Create(); X25519Field.Add(d, d, d2); X25519Field.Carry(d2);
|
||||||
|
return d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Pt BuildBase()
|
||||||
|
{
|
||||||
|
var b = new Pt();
|
||||||
|
X25519Field.Decode(BxBytes, 0, b.X);
|
||||||
|
X25519Field.Decode(ByBytes, 0, b.Y);
|
||||||
|
X25519Field.One(b.Z);
|
||||||
|
X25519Field.Mul(b.X, b.Y, b.T);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── constant-time fixed-base scalar multiply ──
|
||||||
|
|
||||||
|
/// <summary>Returns the 32-byte encoding of <c>scalar·B</c>, constant-time in the scalar bits.</summary>
|
||||||
|
public static byte[] ScalarMultBaseEncode(byte[] scalar)
|
||||||
|
{
|
||||||
|
var r = new Pt(); // identity (0, 1, 1, 0)
|
||||||
|
X25519Field.Zero(r.X); X25519Field.One(r.Y); X25519Field.One(r.Z); X25519Field.Zero(r.T);
|
||||||
|
var added = new Pt();
|
||||||
|
|
||||||
|
for (int i = 255; i >= 0; i--)
|
||||||
|
{
|
||||||
|
Double(r, r);
|
||||||
|
Add(r, Base, added);
|
||||||
|
int bit = (scalar[i >> 3] >> (i & 7)) & 1;
|
||||||
|
CMov(bit, added, r);
|
||||||
|
}
|
||||||
|
return Encode(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Add(Pt p, Pt q, Pt outp)
|
||||||
|
{
|
||||||
|
// No Mul/Sqr writes into one of its own inputs (BC's field Mul is not alias-safe).
|
||||||
|
int[] A = X25519Field.Create(), B = X25519Field.Create(), C = X25519Field.Create();
|
||||||
|
int[] D = X25519Field.Create(), E = X25519Field.Create(), F = X25519Field.Create();
|
||||||
|
int[] G = X25519Field.Create(), H = X25519Field.Create();
|
||||||
|
int[] t1 = X25519Field.Create(), t2 = X25519Field.Create();
|
||||||
|
|
||||||
|
X25519Field.Sub(p.Y, p.X, t1); X25519Field.Sub(q.Y, q.X, t2); X25519Field.Mul(t1, t2, A); // A=(Y1-X1)(Y2-X2)
|
||||||
|
X25519Field.Add(p.Y, p.X, t1); X25519Field.Add(q.Y, q.X, t2); X25519Field.Mul(t1, t2, B); // B=(Y1+X1)(Y2+X2)
|
||||||
|
X25519Field.Mul(p.T, q.T, t1); X25519Field.Mul(t1, D2, C); // C=2d*T1*T2
|
||||||
|
X25519Field.Mul(p.Z, q.Z, t2); X25519Field.Add(t2, t2, D); // D=2*Z1*Z2
|
||||||
|
X25519Field.Sub(B, A, E); X25519Field.Carry(E);
|
||||||
|
X25519Field.Sub(D, C, F); X25519Field.Carry(F);
|
||||||
|
X25519Field.Add(D, C, G); X25519Field.Carry(G);
|
||||||
|
X25519Field.Add(B, A, H); X25519Field.Carry(H);
|
||||||
|
X25519Field.Mul(E, F, outp.X);
|
||||||
|
X25519Field.Mul(G, H, outp.Y);
|
||||||
|
X25519Field.Mul(E, H, outp.T);
|
||||||
|
X25519Field.Mul(F, G, outp.Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedicated doubling for twisted Edwards with a = -1 (dbl-2008-hwcd, specialized):
|
||||||
|
// A=X², B=Y², C=2Z², E=(X+Y)²-A-B, G=B-A, F=G-C, H=-(A+B).
|
||||||
|
private static void Double(Pt p, Pt outp)
|
||||||
|
{
|
||||||
|
int[] A = X25519Field.Create(), B = X25519Field.Create(), C = X25519Field.Create();
|
||||||
|
int[] E = X25519Field.Create(), F = X25519Field.Create(), G = X25519Field.Create();
|
||||||
|
int[] H = X25519Field.Create(), t1 = X25519Field.Create(), t2 = X25519Field.Create();
|
||||||
|
|
||||||
|
X25519Field.Sqr(p.X, A);
|
||||||
|
X25519Field.Sqr(p.Y, B);
|
||||||
|
X25519Field.Sqr(p.Z, t1); X25519Field.Add(t1, t1, C); // C = 2Z²
|
||||||
|
X25519Field.Add(p.X, p.Y, t1); X25519Field.Sqr(t1, t2); // t2 = (X+Y)²
|
||||||
|
X25519Field.Sub(t2, A, t1); X25519Field.Sub(t1, B, E); X25519Field.Carry(E); // E = (X+Y)² - A - B
|
||||||
|
X25519Field.Sub(B, A, G); X25519Field.Carry(G); // G = B - A
|
||||||
|
X25519Field.Sub(G, C, F); X25519Field.Carry(F); // F = G - C
|
||||||
|
X25519Field.Add(A, B, H); X25519Field.CNegate(1, H); X25519Field.Carry(H); // H = -(A + B)
|
||||||
|
X25519Field.Mul(E, F, outp.X);
|
||||||
|
X25519Field.Mul(G, H, outp.Y);
|
||||||
|
X25519Field.Mul(E, H, outp.T);
|
||||||
|
X25519Field.Mul(F, G, outp.Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CMov(int cond, Pt src, Pt dst)
|
||||||
|
{
|
||||||
|
int mask = -(cond & 1); // BC's CMov wants a full word mask (0 or 0xFFFFFFFF), not 0/1
|
||||||
|
X25519Field.CMov(mask, src.X, 0, dst.X, 0);
|
||||||
|
X25519Field.CMov(mask, src.Y, 0, dst.Y, 0);
|
||||||
|
X25519Field.CMov(mask, src.Z, 0, dst.Z, 0);
|
||||||
|
X25519Field.CMov(mask, src.T, 0, dst.T, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Encode(Pt p)
|
||||||
|
{
|
||||||
|
int[] zInv = X25519Field.Create(), x = X25519Field.Create(), y = X25519Field.Create();
|
||||||
|
X25519Field.Inv(p.Z, zInv);
|
||||||
|
X25519Field.Mul(p.X, zInv, x); X25519Field.Normalize(x);
|
||||||
|
X25519Field.Mul(p.Y, zInv, y); X25519Field.Normalize(y);
|
||||||
|
|
||||||
|
var yBytes = new byte[32];
|
||||||
|
X25519Field.Encode(y, yBytes, 0);
|
||||||
|
var xBytes = new byte[32];
|
||||||
|
X25519Field.Encode(x, xBytes, 0);
|
||||||
|
yBytes[31] |= (byte)((xBytes[0] & 1) << 7);
|
||||||
|
return yBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ref10 constant-time scalar arithmetic mod L (faithful portable port of sc.c) ──
|
||||||
|
|
||||||
|
private static long Load3(byte[] x, int o) =>
|
||||||
|
(x[o] & 0xFFL) | ((x[o + 1] & 0xFFL) << 8) | ((x[o + 2] & 0xFFL) << 16);
|
||||||
|
|
||||||
|
private static long Load4(byte[] x, int o) =>
|
||||||
|
(x[o] & 0xFFL) | ((x[o + 1] & 0xFFL) << 8) | ((x[o + 2] & 0xFFL) << 16) | ((x[o + 3] & 0xFFL) << 24);
|
||||||
|
|
||||||
|
/// <summary>Reduces a 64-byte little-endian value mod L → 32 bytes.</summary>
|
||||||
|
public static byte[] ScReduce(byte[] s)
|
||||||
|
{
|
||||||
|
long s0 = 0x1FFFFF & Load3(s, 0);
|
||||||
|
long s1 = 0x1FFFFF & (Load4(s, 2) >> 5);
|
||||||
|
long s2 = 0x1FFFFF & (Load3(s, 5) >> 2);
|
||||||
|
long s3 = 0x1FFFFF & (Load4(s, 7) >> 7);
|
||||||
|
long s4 = 0x1FFFFF & (Load4(s, 10) >> 4);
|
||||||
|
long s5 = 0x1FFFFF & (Load3(s, 13) >> 1);
|
||||||
|
long s6 = 0x1FFFFF & (Load4(s, 15) >> 6);
|
||||||
|
long s7 = 0x1FFFFF & (Load3(s, 18) >> 3);
|
||||||
|
long s8 = 0x1FFFFF & Load3(s, 21);
|
||||||
|
long s9 = 0x1FFFFF & (Load4(s, 23) >> 5);
|
||||||
|
long s10 = 0x1FFFFF & (Load3(s, 26) >> 2);
|
||||||
|
long s11 = 0x1FFFFF & (Load4(s, 28) >> 7);
|
||||||
|
long s12 = 0x1FFFFF & (Load4(s, 31) >> 4);
|
||||||
|
long s13 = 0x1FFFFF & (Load3(s, 34) >> 1);
|
||||||
|
long s14 = 0x1FFFFF & (Load4(s, 36) >> 6);
|
||||||
|
long s15 = 0x1FFFFF & (Load3(s, 39) >> 3);
|
||||||
|
long s16 = 0x1FFFFF & Load3(s, 42);
|
||||||
|
long s17 = 0x1FFFFF & (Load4(s, 44) >> 5);
|
||||||
|
long s18 = 0x1FFFFF & (Load3(s, 47) >> 2);
|
||||||
|
long s19 = 0x1FFFFF & (Load4(s, 49) >> 7);
|
||||||
|
long s20 = 0x1FFFFF & (Load4(s, 52) >> 4);
|
||||||
|
long s21 = 0x1FFFFF & (Load3(s, 55) >> 1);
|
||||||
|
long s22 = 0x1FFFFF & (Load4(s, 57) >> 6);
|
||||||
|
long s23 = Load4(s, 60) >> 3;
|
||||||
|
long carry;
|
||||||
|
|
||||||
|
s11 += s23 * 666643; s12 += s23 * 470296; s13 += s23 * 654183; s14 -= s23 * 997805; s15 += s23 * 136657; s16 -= s23 * 683901;
|
||||||
|
s10 += s22 * 666643; s11 += s22 * 470296; s12 += s22 * 654183; s13 -= s22 * 997805; s14 += s22 * 136657; s15 -= s22 * 683901;
|
||||||
|
s9 += s21 * 666643; s10 += s21 * 470296; s11 += s21 * 654183; s12 -= s21 * 997805; s13 += s21 * 136657; s14 -= s21 * 683901;
|
||||||
|
s8 += s20 * 666643; s9 += s20 * 470296; s10 += s20 * 654183; s11 -= s20 * 997805; s12 += s20 * 136657; s13 -= s20 * 683901;
|
||||||
|
s7 += s19 * 666643; s8 += s19 * 470296; s9 += s19 * 654183; s10 -= s19 * 997805; s11 += s19 * 136657; s12 -= s19 * 683901;
|
||||||
|
s6 += s18 * 666643; s7 += s18 * 470296; s8 += s18 * 654183; s9 -= s18 * 997805; s10 += s18 * 136657; s11 -= s18 * 683901;
|
||||||
|
|
||||||
|
carry = (s6 + (1 << 20)) >> 21; s7 += carry; s6 -= carry << 21;
|
||||||
|
carry = (s8 + (1 << 20)) >> 21; s9 += carry; s8 -= carry << 21;
|
||||||
|
carry = (s10 + (1 << 20)) >> 21; s11 += carry; s10 -= carry << 21;
|
||||||
|
carry = (s12 + (1 << 20)) >> 21; s13 += carry; s12 -= carry << 21;
|
||||||
|
carry = (s14 + (1 << 20)) >> 21; s15 += carry; s14 -= carry << 21;
|
||||||
|
carry = (s16 + (1 << 20)) >> 21; s17 += carry; s16 -= carry << 21;
|
||||||
|
carry = (s7 + (1 << 20)) >> 21; s8 += carry; s7 -= carry << 21;
|
||||||
|
carry = (s9 + (1 << 20)) >> 21; s10 += carry; s9 -= carry << 21;
|
||||||
|
carry = (s11 + (1 << 20)) >> 21; s12 += carry; s11 -= carry << 21;
|
||||||
|
carry = (s13 + (1 << 20)) >> 21; s14 += carry; s13 -= carry << 21;
|
||||||
|
carry = (s15 + (1 << 20)) >> 21; s16 += carry; s15 -= carry << 21;
|
||||||
|
|
||||||
|
s5 += s17 * 666643; s6 += s17 * 470296; s7 += s17 * 654183; s8 -= s17 * 997805; s9 += s17 * 136657; s10 -= s17 * 683901;
|
||||||
|
s4 += s16 * 666643; s5 += s16 * 470296; s6 += s16 * 654183; s7 -= s16 * 997805; s8 += s16 * 136657; s9 -= s16 * 683901;
|
||||||
|
s3 += s15 * 666643; s4 += s15 * 470296; s5 += s15 * 654183; s6 -= s15 * 997805; s7 += s15 * 136657; s8 -= s15 * 683901;
|
||||||
|
s2 += s14 * 666643; s3 += s14 * 470296; s4 += s14 * 654183; s5 -= s14 * 997805; s6 += s14 * 136657; s7 -= s14 * 683901;
|
||||||
|
s1 += s13 * 666643; s2 += s13 * 470296; s3 += s13 * 654183; s4 -= s13 * 997805; s5 += s13 * 136657; s6 -= s13 * 683901;
|
||||||
|
s0 += s12 * 666643; s1 += s12 * 470296; s2 += s12 * 654183; s3 -= s12 * 997805; s4 += s12 * 136657; s5 -= s12 * 683901;
|
||||||
|
s12 = 0;
|
||||||
|
|
||||||
|
carry = (s0 + (1 << 20)) >> 21; s1 += carry; s0 -= carry << 21;
|
||||||
|
carry = (s2 + (1 << 20)) >> 21; s3 += carry; s2 -= carry << 21;
|
||||||
|
carry = (s4 + (1 << 20)) >> 21; s5 += carry; s4 -= carry << 21;
|
||||||
|
carry = (s6 + (1 << 20)) >> 21; s7 += carry; s6 -= carry << 21;
|
||||||
|
carry = (s8 + (1 << 20)) >> 21; s9 += carry; s8 -= carry << 21;
|
||||||
|
carry = (s10 + (1 << 20)) >> 21; s11 += carry; s10 -= carry << 21;
|
||||||
|
carry = (s1 + (1 << 20)) >> 21; s2 += carry; s1 -= carry << 21;
|
||||||
|
carry = (s3 + (1 << 20)) >> 21; s4 += carry; s3 -= carry << 21;
|
||||||
|
carry = (s5 + (1 << 20)) >> 21; s6 += carry; s5 -= carry << 21;
|
||||||
|
carry = (s7 + (1 << 20)) >> 21; s8 += carry; s7 -= carry << 21;
|
||||||
|
carry = (s9 + (1 << 20)) >> 21; s10 += carry; s9 -= carry << 21;
|
||||||
|
carry = (s11 + (1 << 20)) >> 21; s12 += carry; s11 -= carry << 21;
|
||||||
|
|
||||||
|
s0 += s12 * 666643; s1 += s12 * 470296; s2 += s12 * 654183; s3 -= s12 * 997805; s4 += s12 * 136657; s5 -= s12 * 683901;
|
||||||
|
s12 = 0;
|
||||||
|
|
||||||
|
carry = s0 >> 21; s1 += carry; s0 -= carry << 21;
|
||||||
|
carry = s1 >> 21; s2 += carry; s1 -= carry << 21;
|
||||||
|
carry = s2 >> 21; s3 += carry; s2 -= carry << 21;
|
||||||
|
carry = s3 >> 21; s4 += carry; s3 -= carry << 21;
|
||||||
|
carry = s4 >> 21; s5 += carry; s4 -= carry << 21;
|
||||||
|
carry = s5 >> 21; s6 += carry; s5 -= carry << 21;
|
||||||
|
carry = s6 >> 21; s7 += carry; s6 -= carry << 21;
|
||||||
|
carry = s7 >> 21; s8 += carry; s7 -= carry << 21;
|
||||||
|
carry = s8 >> 21; s9 += carry; s8 -= carry << 21;
|
||||||
|
carry = s9 >> 21; s10 += carry; s9 -= carry << 21;
|
||||||
|
carry = s10 >> 21; s11 += carry; s10 -= carry << 21;
|
||||||
|
carry = s11 >> 21; s12 += carry; s11 -= carry << 21;
|
||||||
|
|
||||||
|
s0 += s12 * 666643; s1 += s12 * 470296; s2 += s12 * 654183; s3 -= s12 * 997805; s4 += s12 * 136657; s5 -= s12 * 683901;
|
||||||
|
|
||||||
|
carry = s0 >> 21; s1 += carry; s0 -= carry << 21;
|
||||||
|
carry = s1 >> 21; s2 += carry; s1 -= carry << 21;
|
||||||
|
carry = s2 >> 21; s3 += carry; s2 -= carry << 21;
|
||||||
|
carry = s3 >> 21; s4 += carry; s3 -= carry << 21;
|
||||||
|
carry = s4 >> 21; s5 += carry; s4 -= carry << 21;
|
||||||
|
carry = s5 >> 21; s6 += carry; s5 -= carry << 21;
|
||||||
|
carry = s6 >> 21; s7 += carry; s6 -= carry << 21;
|
||||||
|
carry = s7 >> 21; s8 += carry; s7 -= carry << 21;
|
||||||
|
carry = s8 >> 21; s9 += carry; s8 -= carry << 21;
|
||||||
|
carry = s9 >> 21; s10 += carry; s9 -= carry << 21;
|
||||||
|
carry = s10 >> 21; s11 += carry; s10 -= carry << 21;
|
||||||
|
|
||||||
|
return Pack(s0, s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns (a*b + c) mod L, all 32-byte little-endian scalars.</summary>
|
||||||
|
public static byte[] ScMulAdd(byte[] a, byte[] b, byte[] c)
|
||||||
|
{
|
||||||
|
long a0 = 0x1FFFFF & Load3(a, 0);
|
||||||
|
long a1 = 0x1FFFFF & (Load4(a, 2) >> 5);
|
||||||
|
long a2 = 0x1FFFFF & (Load3(a, 5) >> 2);
|
||||||
|
long a3 = 0x1FFFFF & (Load4(a, 7) >> 7);
|
||||||
|
long a4 = 0x1FFFFF & (Load4(a, 10) >> 4);
|
||||||
|
long a5 = 0x1FFFFF & (Load3(a, 13) >> 1);
|
||||||
|
long a6 = 0x1FFFFF & (Load4(a, 15) >> 6);
|
||||||
|
long a7 = 0x1FFFFF & (Load3(a, 18) >> 3);
|
||||||
|
long a8 = 0x1FFFFF & Load3(a, 21);
|
||||||
|
long a9 = 0x1FFFFF & (Load4(a, 23) >> 5);
|
||||||
|
long a10 = 0x1FFFFF & (Load3(a, 26) >> 2);
|
||||||
|
long a11 = Load4(a, 28) >> 7;
|
||||||
|
long b0 = 0x1FFFFF & Load3(b, 0);
|
||||||
|
long b1 = 0x1FFFFF & (Load4(b, 2) >> 5);
|
||||||
|
long b2 = 0x1FFFFF & (Load3(b, 5) >> 2);
|
||||||
|
long b3 = 0x1FFFFF & (Load4(b, 7) >> 7);
|
||||||
|
long b4 = 0x1FFFFF & (Load4(b, 10) >> 4);
|
||||||
|
long b5 = 0x1FFFFF & (Load3(b, 13) >> 1);
|
||||||
|
long b6 = 0x1FFFFF & (Load4(b, 15) >> 6);
|
||||||
|
long b7 = 0x1FFFFF & (Load3(b, 18) >> 3);
|
||||||
|
long b8 = 0x1FFFFF & Load3(b, 21);
|
||||||
|
long b9 = 0x1FFFFF & (Load4(b, 23) >> 5);
|
||||||
|
long b10 = 0x1FFFFF & (Load3(b, 26) >> 2);
|
||||||
|
long b11 = Load4(b, 28) >> 7;
|
||||||
|
long c0 = 0x1FFFFF & Load3(c, 0);
|
||||||
|
long c1 = 0x1FFFFF & (Load4(c, 2) >> 5);
|
||||||
|
long c2 = 0x1FFFFF & (Load3(c, 5) >> 2);
|
||||||
|
long c3 = 0x1FFFFF & (Load4(c, 7) >> 7);
|
||||||
|
long c4 = 0x1FFFFF & (Load4(c, 10) >> 4);
|
||||||
|
long c5 = 0x1FFFFF & (Load3(c, 13) >> 1);
|
||||||
|
long c6 = 0x1FFFFF & (Load4(c, 15) >> 6);
|
||||||
|
long c7 = 0x1FFFFF & (Load3(c, 18) >> 3);
|
||||||
|
long c8 = 0x1FFFFF & Load3(c, 21);
|
||||||
|
long c9 = 0x1FFFFF & (Load4(c, 23) >> 5);
|
||||||
|
long c10 = 0x1FFFFF & (Load3(c, 26) >> 2);
|
||||||
|
long c11 = Load4(c, 28) >> 7;
|
||||||
|
long carry;
|
||||||
|
|
||||||
|
long s0 = c0 + a0 * b0;
|
||||||
|
long s1 = c1 + a0 * b1 + a1 * b0;
|
||||||
|
long s2 = c2 + a0 * b2 + a1 * b1 + a2 * b0;
|
||||||
|
long s3 = c3 + a0 * b3 + a1 * b2 + a2 * b1 + a3 * b0;
|
||||||
|
long s4 = c4 + a0 * b4 + a1 * b3 + a2 * b2 + a3 * b1 + a4 * b0;
|
||||||
|
long s5 = c5 + a0 * b5 + a1 * b4 + a2 * b3 + a3 * b2 + a4 * b1 + a5 * b0;
|
||||||
|
long s6 = c6 + a0 * b6 + a1 * b5 + a2 * b4 + a3 * b3 + a4 * b2 + a5 * b1 + a6 * b0;
|
||||||
|
long s7 = c7 + a0 * b7 + a1 * b6 + a2 * b5 + a3 * b4 + a4 * b3 + a5 * b2 + a6 * b1 + a7 * b0;
|
||||||
|
long s8 = c8 + a0 * b8 + a1 * b7 + a2 * b6 + a3 * b5 + a4 * b4 + a5 * b3 + a6 * b2 + a7 * b1 + a8 * b0;
|
||||||
|
long s9 = c9 + a0 * b9 + a1 * b8 + a2 * b7 + a3 * b6 + a4 * b5 + a5 * b4 + a6 * b3 + a7 * b2 + a8 * b1 + a9 * b0;
|
||||||
|
long s10 = c10 + a0 * b10 + a1 * b9 + a2 * b8 + a3 * b7 + a4 * b6 + a5 * b5 + a6 * b4 + a7 * b3 + a8 * b2 + a9 * b1 + a10 * b0;
|
||||||
|
long s11 = c11 + a0 * b11 + a1 * b10 + a2 * b9 + a3 * b8 + a4 * b7 + a5 * b6 + a6 * b5 + a7 * b4 + a8 * b3 + a9 * b2 + a10 * b1 + a11 * b0;
|
||||||
|
long s12 = a1 * b11 + a2 * b10 + a3 * b9 + a4 * b8 + a5 * b7 + a6 * b6 + a7 * b5 + a8 * b4 + a9 * b3 + a10 * b2 + a11 * b1;
|
||||||
|
long s13 = a2 * b11 + a3 * b10 + a4 * b9 + a5 * b8 + a6 * b7 + a7 * b6 + a8 * b5 + a9 * b4 + a10 * b3 + a11 * b2;
|
||||||
|
long s14 = a3 * b11 + a4 * b10 + a5 * b9 + a6 * b8 + a7 * b7 + a8 * b6 + a9 * b5 + a10 * b4 + a11 * b3;
|
||||||
|
long s15 = a4 * b11 + a5 * b10 + a6 * b9 + a7 * b8 + a8 * b7 + a9 * b6 + a10 * b5 + a11 * b4;
|
||||||
|
long s16 = a5 * b11 + a6 * b10 + a7 * b9 + a8 * b8 + a9 * b7 + a10 * b6 + a11 * b5;
|
||||||
|
long s17 = a6 * b11 + a7 * b10 + a8 * b9 + a9 * b8 + a10 * b7 + a11 * b6;
|
||||||
|
long s18 = a7 * b11 + a8 * b10 + a9 * b9 + a10 * b8 + a11 * b7;
|
||||||
|
long s19 = a8 * b11 + a9 * b10 + a10 * b9 + a11 * b8;
|
||||||
|
long s20 = a9 * b11 + a10 * b10 + a11 * b9;
|
||||||
|
long s21 = a10 * b11 + a11 * b10;
|
||||||
|
long s22 = a11 * b11;
|
||||||
|
long s23 = 0;
|
||||||
|
|
||||||
|
carry = (s0 + (1 << 20)) >> 21; s1 += carry; s0 -= carry << 21;
|
||||||
|
carry = (s2 + (1 << 20)) >> 21; s3 += carry; s2 -= carry << 21;
|
||||||
|
carry = (s4 + (1 << 20)) >> 21; s5 += carry; s4 -= carry << 21;
|
||||||
|
carry = (s6 + (1 << 20)) >> 21; s7 += carry; s6 -= carry << 21;
|
||||||
|
carry = (s8 + (1 << 20)) >> 21; s9 += carry; s8 -= carry << 21;
|
||||||
|
carry = (s10 + (1 << 20)) >> 21; s11 += carry; s10 -= carry << 21;
|
||||||
|
carry = (s12 + (1 << 20)) >> 21; s13 += carry; s12 -= carry << 21;
|
||||||
|
carry = (s14 + (1 << 20)) >> 21; s15 += carry; s14 -= carry << 21;
|
||||||
|
carry = (s16 + (1 << 20)) >> 21; s17 += carry; s16 -= carry << 21;
|
||||||
|
carry = (s18 + (1 << 20)) >> 21; s19 += carry; s18 -= carry << 21;
|
||||||
|
carry = (s20 + (1 << 20)) >> 21; s21 += carry; s20 -= carry << 21;
|
||||||
|
carry = (s22 + (1 << 20)) >> 21; s23 += carry; s22 -= carry << 21;
|
||||||
|
carry = (s1 + (1 << 20)) >> 21; s2 += carry; s1 -= carry << 21;
|
||||||
|
carry = (s3 + (1 << 20)) >> 21; s4 += carry; s3 -= carry << 21;
|
||||||
|
carry = (s5 + (1 << 20)) >> 21; s6 += carry; s5 -= carry << 21;
|
||||||
|
carry = (s7 + (1 << 20)) >> 21; s8 += carry; s7 -= carry << 21;
|
||||||
|
carry = (s9 + (1 << 20)) >> 21; s10 += carry; s9 -= carry << 21;
|
||||||
|
carry = (s11 + (1 << 20)) >> 21; s12 += carry; s11 -= carry << 21;
|
||||||
|
carry = (s13 + (1 << 20)) >> 21; s14 += carry; s13 -= carry << 21;
|
||||||
|
carry = (s15 + (1 << 20)) >> 21; s16 += carry; s15 -= carry << 21;
|
||||||
|
carry = (s17 + (1 << 20)) >> 21; s18 += carry; s17 -= carry << 21;
|
||||||
|
carry = (s19 + (1 << 20)) >> 21; s20 += carry; s19 -= carry << 21;
|
||||||
|
carry = (s21 + (1 << 20)) >> 21; s22 += carry; s21 -= carry << 21;
|
||||||
|
|
||||||
|
s11 += s23 * 666643; s12 += s23 * 470296; s13 += s23 * 654183; s14 -= s23 * 997805; s15 += s23 * 136657; s16 -= s23 * 683901;
|
||||||
|
s10 += s22 * 666643; s11 += s22 * 470296; s12 += s22 * 654183; s13 -= s22 * 997805; s14 += s22 * 136657; s15 -= s22 * 683901;
|
||||||
|
s9 += s21 * 666643; s10 += s21 * 470296; s11 += s21 * 654183; s12 -= s21 * 997805; s13 += s21 * 136657; s14 -= s21 * 683901;
|
||||||
|
s8 += s20 * 666643; s9 += s20 * 470296; s10 += s20 * 654183; s11 -= s20 * 997805; s12 += s20 * 136657; s13 -= s20 * 683901;
|
||||||
|
s7 += s19 * 666643; s8 += s19 * 470296; s9 += s19 * 654183; s10 -= s19 * 997805; s11 += s19 * 136657; s12 -= s19 * 683901;
|
||||||
|
s6 += s18 * 666643; s7 += s18 * 470296; s8 += s18 * 654183; s9 -= s18 * 997805; s10 += s18 * 136657; s11 -= s18 * 683901;
|
||||||
|
|
||||||
|
carry = (s6 + (1 << 20)) >> 21; s7 += carry; s6 -= carry << 21;
|
||||||
|
carry = (s8 + (1 << 20)) >> 21; s9 += carry; s8 -= carry << 21;
|
||||||
|
carry = (s10 + (1 << 20)) >> 21; s11 += carry; s10 -= carry << 21;
|
||||||
|
carry = (s12 + (1 << 20)) >> 21; s13 += carry; s12 -= carry << 21;
|
||||||
|
carry = (s14 + (1 << 20)) >> 21; s15 += carry; s14 -= carry << 21;
|
||||||
|
carry = (s16 + (1 << 20)) >> 21; s17 += carry; s16 -= carry << 21;
|
||||||
|
carry = (s7 + (1 << 20)) >> 21; s8 += carry; s7 -= carry << 21;
|
||||||
|
carry = (s9 + (1 << 20)) >> 21; s10 += carry; s9 -= carry << 21;
|
||||||
|
carry = (s11 + (1 << 20)) >> 21; s12 += carry; s11 -= carry << 21;
|
||||||
|
carry = (s13 + (1 << 20)) >> 21; s14 += carry; s13 -= carry << 21;
|
||||||
|
carry = (s15 + (1 << 20)) >> 21; s16 += carry; s15 -= carry << 21;
|
||||||
|
|
||||||
|
s5 += s17 * 666643; s6 += s17 * 470296; s7 += s17 * 654183; s8 -= s17 * 997805; s9 += s17 * 136657; s10 -= s17 * 683901;
|
||||||
|
s4 += s16 * 666643; s5 += s16 * 470296; s6 += s16 * 654183; s7 -= s16 * 997805; s8 += s16 * 136657; s9 -= s16 * 683901;
|
||||||
|
s3 += s15 * 666643; s4 += s15 * 470296; s5 += s15 * 654183; s6 -= s15 * 997805; s7 += s15 * 136657; s8 -= s15 * 683901;
|
||||||
|
s2 += s14 * 666643; s3 += s14 * 470296; s4 += s14 * 654183; s5 -= s14 * 997805; s6 += s14 * 136657; s7 -= s14 * 683901;
|
||||||
|
s1 += s13 * 666643; s2 += s13 * 470296; s3 += s13 * 654183; s4 -= s13 * 997805; s5 += s13 * 136657; s6 -= s13 * 683901;
|
||||||
|
s0 += s12 * 666643; s1 += s12 * 470296; s2 += s12 * 654183; s3 -= s12 * 997805; s4 += s12 * 136657; s5 -= s12 * 683901;
|
||||||
|
s12 = 0;
|
||||||
|
|
||||||
|
carry = (s0 + (1 << 20)) >> 21; s1 += carry; s0 -= carry << 21;
|
||||||
|
carry = (s2 + (1 << 20)) >> 21; s3 += carry; s2 -= carry << 21;
|
||||||
|
carry = (s4 + (1 << 20)) >> 21; s5 += carry; s4 -= carry << 21;
|
||||||
|
carry = (s6 + (1 << 20)) >> 21; s7 += carry; s6 -= carry << 21;
|
||||||
|
carry = (s8 + (1 << 20)) >> 21; s9 += carry; s8 -= carry << 21;
|
||||||
|
carry = (s10 + (1 << 20)) >> 21; s11 += carry; s10 -= carry << 21;
|
||||||
|
carry = (s1 + (1 << 20)) >> 21; s2 += carry; s1 -= carry << 21;
|
||||||
|
carry = (s3 + (1 << 20)) >> 21; s4 += carry; s3 -= carry << 21;
|
||||||
|
carry = (s5 + (1 << 20)) >> 21; s6 += carry; s5 -= carry << 21;
|
||||||
|
carry = (s7 + (1 << 20)) >> 21; s8 += carry; s7 -= carry << 21;
|
||||||
|
carry = (s9 + (1 << 20)) >> 21; s10 += carry; s9 -= carry << 21;
|
||||||
|
carry = (s11 + (1 << 20)) >> 21; s12 += carry; s11 -= carry << 21;
|
||||||
|
|
||||||
|
s0 += s12 * 666643; s1 += s12 * 470296; s2 += s12 * 654183; s3 -= s12 * 997805; s4 += s12 * 136657; s5 -= s12 * 683901;
|
||||||
|
s12 = 0;
|
||||||
|
|
||||||
|
carry = s0 >> 21; s1 += carry; s0 -= carry << 21;
|
||||||
|
carry = s1 >> 21; s2 += carry; s1 -= carry << 21;
|
||||||
|
carry = s2 >> 21; s3 += carry; s2 -= carry << 21;
|
||||||
|
carry = s3 >> 21; s4 += carry; s3 -= carry << 21;
|
||||||
|
carry = s4 >> 21; s5 += carry; s4 -= carry << 21;
|
||||||
|
carry = s5 >> 21; s6 += carry; s5 -= carry << 21;
|
||||||
|
carry = s6 >> 21; s7 += carry; s6 -= carry << 21;
|
||||||
|
carry = s7 >> 21; s8 += carry; s7 -= carry << 21;
|
||||||
|
carry = s8 >> 21; s9 += carry; s8 -= carry << 21;
|
||||||
|
carry = s9 >> 21; s10 += carry; s9 -= carry << 21;
|
||||||
|
carry = s10 >> 21; s11 += carry; s10 -= carry << 21;
|
||||||
|
carry = s11 >> 21; s12 += carry; s11 -= carry << 21;
|
||||||
|
|
||||||
|
s0 += s12 * 666643; s1 += s12 * 470296; s2 += s12 * 654183; s3 -= s12 * 997805; s4 += s12 * 136657; s5 -= s12 * 683901;
|
||||||
|
|
||||||
|
carry = s0 >> 21; s1 += carry; s0 -= carry << 21;
|
||||||
|
carry = s1 >> 21; s2 += carry; s1 -= carry << 21;
|
||||||
|
carry = s2 >> 21; s3 += carry; s2 -= carry << 21;
|
||||||
|
carry = s3 >> 21; s4 += carry; s3 -= carry << 21;
|
||||||
|
carry = s4 >> 21; s5 += carry; s4 -= carry << 21;
|
||||||
|
carry = s5 >> 21; s6 += carry; s5 -= carry << 21;
|
||||||
|
carry = s6 >> 21; s7 += carry; s6 -= carry << 21;
|
||||||
|
carry = s7 >> 21; s8 += carry; s7 -= carry << 21;
|
||||||
|
carry = s8 >> 21; s9 += carry; s8 -= carry << 21;
|
||||||
|
carry = s9 >> 21; s10 += carry; s9 -= carry << 21;
|
||||||
|
carry = s10 >> 21; s11 += carry; s10 -= carry << 21;
|
||||||
|
|
||||||
|
return Pack(s0, s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Pack(long s0, long s1, long s2, long s3, long s4, long s5,
|
||||||
|
long s6, long s7, long s8, long s9, long s10, long s11)
|
||||||
|
{
|
||||||
|
var r = new byte[32];
|
||||||
|
r[0] = (byte)s0; r[1] = (byte)(s0 >> 8); r[2] = (byte)((s0 >> 16) | (s1 << 5));
|
||||||
|
r[3] = (byte)(s1 >> 3); r[4] = (byte)(s1 >> 11); r[5] = (byte)((s1 >> 19) | (s2 << 2));
|
||||||
|
r[6] = (byte)(s2 >> 6); r[7] = (byte)((s2 >> 14) | (s3 << 7)); r[8] = (byte)(s3 >> 1);
|
||||||
|
r[9] = (byte)(s3 >> 9); r[10] = (byte)((s3 >> 17) | (s4 << 4)); r[11] = (byte)(s4 >> 4);
|
||||||
|
r[12] = (byte)(s4 >> 12); r[13] = (byte)((s4 >> 20) | (s5 << 1)); r[14] = (byte)(s5 >> 7);
|
||||||
|
r[15] = (byte)((s5 >> 15) | (s6 << 6)); r[16] = (byte)(s6 >> 2); r[17] = (byte)(s6 >> 10);
|
||||||
|
r[18] = (byte)((s6 >> 18) | (s7 << 3)); r[19] = (byte)(s7 >> 5); r[20] = (byte)(s7 >> 13);
|
||||||
|
r[21] = (byte)s8; r[22] = (byte)(s8 >> 8); r[23] = (byte)((s8 >> 16) | (s9 << 5));
|
||||||
|
r[24] = (byte)(s9 >> 3); r[25] = (byte)(s9 >> 11); r[26] = (byte)((s9 >> 19) | (s10 << 2));
|
||||||
|
r[27] = (byte)(s10 >> 6); r[28] = (byte)((s10 >> 14) | (s11 << 7)); r[29] = (byte)(s11 >> 1);
|
||||||
|
r[30] = (byte)(s11 >> 9); r[31] = (byte)(s11 >> 17);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Curve;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compact reference implementation of the Ed25519 group over GF(2^255-19), using
|
||||||
|
/// <see cref="BigInteger"/> affine coordinates. Chosen for auditability over speed: signing a
|
||||||
|
/// handful of prekeys and verifying signatures does not need constant-time field arithmetic.
|
||||||
|
///
|
||||||
|
/// This mirrors the original ed25519 reference (djb / RFC 8032 "slow" reference). It is shared by
|
||||||
|
/// both <see cref="XEd25519"/> and the RFC 8032 known-answer tests, so passing those KATs validates
|
||||||
|
/// the field/group/scalar/encode/decode routines used in production.
|
||||||
|
/// </summary>
|
||||||
|
internal static class Ed25519Math
|
||||||
|
{
|
||||||
|
/// <summary>Field prime 2^255 - 19.</summary>
|
||||||
|
internal static readonly BigInteger P = BigInteger.Pow(2, 255) - 19;
|
||||||
|
|
||||||
|
/// <summary>Group order L = 2^252 + 27742317777372353535851937790883648493.</summary>
|
||||||
|
internal static readonly BigInteger L =
|
||||||
|
BigInteger.Pow(2, 252) + BigInteger.Parse("27742317777372353535851937790883648493");
|
||||||
|
|
||||||
|
/// <summary>Curve constant d = -121665/121666 mod p.</summary>
|
||||||
|
private static readonly BigInteger D = Mod(-121665 * Inverse(121666), P);
|
||||||
|
|
||||||
|
/// <summary>sqrt(-1) mod p = 2^((p-1)/4).</summary>
|
||||||
|
private static readonly BigInteger SqrtM1 = BigInteger.ModPow(2, (P - 1) / 4, P);
|
||||||
|
|
||||||
|
/// <summary>Base point B = (Bx, 4/5).</summary>
|
||||||
|
private static readonly Point B = MakeBasePoint();
|
||||||
|
|
||||||
|
internal readonly struct Point
|
||||||
|
{
|
||||||
|
internal readonly BigInteger X;
|
||||||
|
internal readonly BigInteger Y;
|
||||||
|
internal Point(BigInteger x, BigInteger y) { X = x; Y = y; }
|
||||||
|
internal Point Negate() => new Point(Mod(-X, P), Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly Point Identity = new Point(BigInteger.Zero, BigInteger.One);
|
||||||
|
|
||||||
|
private static Point MakeBasePoint()
|
||||||
|
{
|
||||||
|
BigInteger by = Mod(4 * Inverse(5), P);
|
||||||
|
BigInteger bx = RecoverX(by, 0);
|
||||||
|
return new Point(bx, by);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static BigInteger Mod(BigInteger a, BigInteger m)
|
||||||
|
{
|
||||||
|
BigInteger r = a % m;
|
||||||
|
return r.Sign < 0 ? r + m : r;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static BigInteger Inverse(BigInteger z) => BigInteger.ModPow(Mod(z, P), P - 2, P);
|
||||||
|
|
||||||
|
/// <summary>Edwards addition (unified; also doubles) on -x^2 + y^2 = 1 + d x^2 y^2.</summary>
|
||||||
|
internal static Point Add(Point p1, Point p2)
|
||||||
|
{
|
||||||
|
BigInteger x1 = p1.X, y1 = p1.Y, x2 = p2.X, y2 = p2.Y;
|
||||||
|
BigInteger dxy = Mod(D * x1 * x2 % P * y1 % P * y2, P);
|
||||||
|
BigInteger x3 = Mod((x1 * y2 + x2 * y1) * Inverse(Mod(1 + dxy, P)), P);
|
||||||
|
BigInteger y3 = Mod((y1 * y2 + x1 * x2) * Inverse(Mod(1 - dxy, P)), P);
|
||||||
|
return new Point(x3, y3);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Point ScalarMult(Point p, BigInteger e)
|
||||||
|
{
|
||||||
|
Point result = Identity;
|
||||||
|
Point addend = p;
|
||||||
|
while (e.Sign > 0)
|
||||||
|
{
|
||||||
|
if (!e.IsEven) result = Add(result, addend);
|
||||||
|
addend = Add(addend, addend);
|
||||||
|
e >>= 1;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Point ScalarMultBase(BigInteger e) => ScalarMult(B, e);
|
||||||
|
|
||||||
|
/// <summary>Encode a point to 32 bytes (little-endian y with the low bit of x in bit 255).</summary>
|
||||||
|
internal static byte[] Encode(Point p)
|
||||||
|
{
|
||||||
|
byte[] bytes = ToLe32(Mod(p.Y, P));
|
||||||
|
if (!Mod(p.X, P).IsEven) bytes[31] |= 0x80;
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BigInteger RecoverX(BigInteger y, int sign)
|
||||||
|
{
|
||||||
|
BigInteger y2 = Mod(y * y, P);
|
||||||
|
BigInteger num = Mod(y2 - 1, P);
|
||||||
|
BigInteger den = Mod(D * y2 + 1, P);
|
||||||
|
BigInteger xx = Mod(num * Inverse(den), P);
|
||||||
|
BigInteger x = BigInteger.ModPow(xx, (P + 3) / 8, P);
|
||||||
|
if (!Mod(x * x - xx, P).IsZero) x = Mod(x * SqrtM1, P);
|
||||||
|
if (!Mod(x * x - xx, P).IsZero) return BigInteger.MinusOne; // not on curve
|
||||||
|
if (((int)(x & 1)) != sign) x = Mod(-x, P);
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Decode a point from its y-coordinate and sign bit. Returns false if not on curve.</summary>
|
||||||
|
internal static bool TryDecode(BigInteger y, int sign, out Point point)
|
||||||
|
{
|
||||||
|
BigInteger x = RecoverX(Mod(y, P), sign);
|
||||||
|
if (x.Sign < 0) { point = default; return false; }
|
||||||
|
point = new Point(x, y);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reduce a 64-byte little-endian hash to a scalar mod L.</summary>
|
||||||
|
internal static BigInteger ScReduce(ReadOnlySpan<byte> hash64) => Mod(FromLe(hash64), L);
|
||||||
|
|
||||||
|
internal static BigInteger FromLe(ReadOnlySpan<byte> bytes) =>
|
||||||
|
new BigInteger(bytes, isUnsigned: true, isBigEndian: false);
|
||||||
|
|
||||||
|
internal static byte[] ToLe32(BigInteger value)
|
||||||
|
{
|
||||||
|
byte[] raw = value.ToByteArray(isUnsigned: true, isBigEndian: false);
|
||||||
|
var result = new byte[32];
|
||||||
|
Array.Copy(raw, result, Math.Min(raw.Length, 32));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
namespace Wingnal.Protocol.Curve;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// libsignal serializes KEM public keys and ciphertexts with a one-byte key-type prefix (analogous
|
||||||
|
/// to the 0x05 DjbECPublicKey prefix). Kyber-1024 is type 0x08. The signed-prekey signature is
|
||||||
|
/// computed over this prefixed form, and prekey bundles carry the prefixed public key.
|
||||||
|
/// </summary>
|
||||||
|
public static class KemKeySerialization
|
||||||
|
{
|
||||||
|
/// <summary>libsignal KEM key type for Kyber-1024.</summary>
|
||||||
|
public const byte Kyber1024Type = 0x08;
|
||||||
|
|
||||||
|
/// <summary>Prepends the Kyber-1024 type byte to a raw public key or ciphertext.</summary>
|
||||||
|
public static byte[] Serialize(byte[] raw)
|
||||||
|
{
|
||||||
|
var serialized = new byte[raw.Length + 1];
|
||||||
|
serialized[0] = Kyber1024Type;
|
||||||
|
Array.Copy(raw, 0, serialized, 1, raw.Length);
|
||||||
|
return serialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Strips the type byte from a serialized Kyber-1024 public key or ciphertext.</summary>
|
||||||
|
public static byte[] Deserialize(ReadOnlySpan<byte> serialized)
|
||||||
|
{
|
||||||
|
if (serialized.Length < 1 || serialized[0] != Kyber1024Type)
|
||||||
|
throw new ArgumentException($"unsupported KEM key type {(serialized.Length > 0 ? serialized[0] : -1)}");
|
||||||
|
return serialized[1..].ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Curve;
|
||||||
|
|
||||||
|
/// <summary>An ML-KEM/Kyber key pair (encoded public/private key bytes).</summary>
|
||||||
|
public sealed class KyberKeyPair
|
||||||
|
{
|
||||||
|
public byte[] PublicKey { get; }
|
||||||
|
public byte[] PrivateKey { get; }
|
||||||
|
public KyberKeyPair(byte[] publicKey, byte[] privateKey)
|
||||||
|
{
|
||||||
|
PublicKey = publicKey;
|
||||||
|
PrivateKey = privateKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Result of an encapsulation: the ciphertext to send and the shared secret.</summary>
|
||||||
|
public sealed class KyberEncapsulation
|
||||||
|
{
|
||||||
|
public byte[] CipherText { get; }
|
||||||
|
public byte[] SharedSecret { get; }
|
||||||
|
public KyberEncapsulation(byte[] cipherText, byte[] sharedSecret)
|
||||||
|
{
|
||||||
|
CipherText = cipherText;
|
||||||
|
SharedSecret = sharedSecret;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Round-3 Kyber-1024 KEM, used for PQXDH pqkem prekeys. This matches libsignal's <c>KYBER_1024</c>
|
||||||
|
/// (type byte 0x08), so prekeys and ciphertexts interoperate with the Signal ecosystem. The raw
|
||||||
|
/// public/private/ciphertext encodings here carry no type-byte prefix — see <see cref="KemKeySerialization"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class Kyber
|
||||||
|
{
|
||||||
|
public static KyberKeyPair GenerateKeyPair()
|
||||||
|
{
|
||||||
|
byte[] d = RandomNumberGenerator.GetBytes(Kyber1024.SymBytes);
|
||||||
|
byte[] z = RandomNumberGenerator.GetBytes(Kyber1024.SymBytes);
|
||||||
|
Kyber1024.KeyPair(d, z, out byte[] pk, out byte[] sk);
|
||||||
|
return new KyberKeyPair(pk, sk);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Encapsulate to a peer's public key. Returns ciphertext + shared secret.</summary>
|
||||||
|
public static KyberEncapsulation Encapsulate(byte[] publicKey)
|
||||||
|
{
|
||||||
|
byte[] m = RandomNumberGenerator.GetBytes(Kyber1024.SymBytes);
|
||||||
|
Kyber1024.Encapsulate(publicKey, m, out byte[] ct, out byte[] ss);
|
||||||
|
return new KyberEncapsulation(ct, ss);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Decapsulate a received ciphertext with our private key. Returns the shared secret.</summary>
|
||||||
|
public static byte[] Decapsulate(byte[] privateKey, byte[] cipherText) =>
|
||||||
|
Kyber1024.Decapsulate(cipherText, privateKey);
|
||||||
|
}
|
||||||
@@ -0,0 +1,701 @@
|
|||||||
|
using Org.BouncyCastle.Crypto.Digests;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Curve;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure-C# implementation of round-3 Kyber-1024 (the CRYSTALS-Kyber NIST round-3 submission, as used
|
||||||
|
/// by libsignal's <c>KYBER_1024</c> KEM for PQXDH). Faithfully ported from the pq-crystals reference
|
||||||
|
/// (tag v3.0, ref/), using BouncyCastle only for SHA3/SHAKE. Validated against the reference's
|
||||||
|
/// published test-vector SHA-256 (see KyberKatTests).
|
||||||
|
///
|
||||||
|
/// All polynomial coefficients are 16-bit; arithmetic is unchecked to mirror C int16_t wraparound.
|
||||||
|
/// </summary>
|
||||||
|
internal static class Kyber1024
|
||||||
|
{
|
||||||
|
public const int N = 256;
|
||||||
|
public const int Q = 3329;
|
||||||
|
public const int K = 4;
|
||||||
|
public const int Eta1 = 2;
|
||||||
|
public const int Eta2 = 2;
|
||||||
|
public const int SymBytes = 32;
|
||||||
|
|
||||||
|
public const int PolyBytes = 384;
|
||||||
|
public const int PolyVecBytes = K * PolyBytes; // 1536
|
||||||
|
public const int PolyCompressedBytes = 160; // dv = 5
|
||||||
|
public const int PolyVecCompressedBytes = K * 352; // 1408, du = 11
|
||||||
|
public const int IndcpaPublicKeyBytes = PolyVecBytes + SymBytes; // 1568
|
||||||
|
public const int IndcpaSecretKeyBytes = PolyVecBytes; // 1536
|
||||||
|
public const int IndcpaBytes = PolyVecCompressedBytes + PolyCompressedBytes; // 1568
|
||||||
|
|
||||||
|
public const int PublicKeyBytes = IndcpaPublicKeyBytes; // 1568
|
||||||
|
public const int SecretKeyBytes = IndcpaSecretKeyBytes + IndcpaPublicKeyBytes + 2 * SymBytes; // 3168
|
||||||
|
public const int CiphertextBytes = IndcpaBytes; // 1568
|
||||||
|
public const int SsBytes = 32;
|
||||||
|
|
||||||
|
private const short MONT = -1044; // 2^16 mod q
|
||||||
|
private const short QINV = -3327; // q^-1 mod 2^16
|
||||||
|
|
||||||
|
private static readonly short[] Zetas =
|
||||||
|
{
|
||||||
|
-1044, -758, -359, -1517, 1493, 1422, 287, 202,
|
||||||
|
-171, 622, 1577, 182, 962, -1202, -1474, 1468,
|
||||||
|
573, -1325, 264, 383, -829, 1458, -1602, -130,
|
||||||
|
-681, 1017, 732, 608, -1542, 411, -205, -1571,
|
||||||
|
1223, 652, -552, 1015, -1293, 1491, -282, -1544,
|
||||||
|
516, -8, -320, -666, -1618, -1162, 126, 1469,
|
||||||
|
-853, -90, -271, 830, 107, -1421, -247, -951,
|
||||||
|
-398, 961, -1508, -725, 448, -1065, 677, -1275,
|
||||||
|
-1103, 430, 555, 843, -1251, 871, 1550, 105,
|
||||||
|
422, 587, 177, -235, -291, -460, 1574, 1653,
|
||||||
|
-246, 778, 1159, -147, -777, 1483, -602, 1119,
|
||||||
|
-1590, 644, -872, 349, 418, 329, -156, -75,
|
||||||
|
817, 1097, 603, 610, 1322, -1285, -1465, 384,
|
||||||
|
-1215, -136, 1218, -1335, -874, 220, -1187, -1659,
|
||||||
|
-1185, -1530, -1278, 794, -1510, -854, -870, 478,
|
||||||
|
-108, -308, 996, 991, 958, -1460, 1522, 1628,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- reductions ----
|
||||||
|
|
||||||
|
private static short MontgomeryReduce(int a)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
short t = (short)((short)a * QINV);
|
||||||
|
return (short)((a - (int)t * Q) >> 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static short BarrettReduce(short a)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
const int v = ((1 << 26) + Q / 2) / Q;
|
||||||
|
short t = (short)((v * a + (1 << 25)) >> 26);
|
||||||
|
return (short)(a - (short)(t * Q));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static short FqMul(short a, short b) => MontgomeryReduce(a * b);
|
||||||
|
|
||||||
|
// ---- NTT ----
|
||||||
|
|
||||||
|
private static void Ntt(short[] r)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
int k = 1;
|
||||||
|
for (int len = 128; len >= 2; len >>= 1)
|
||||||
|
{
|
||||||
|
for (int start = 0; start < 256; start += 2 * len)
|
||||||
|
{
|
||||||
|
short zeta = Zetas[k++];
|
||||||
|
for (int j = start; j < start + len; j++)
|
||||||
|
{
|
||||||
|
short t = FqMul(zeta, r[j + len]);
|
||||||
|
r[j + len] = (short)(r[j] - t);
|
||||||
|
r[j] = (short)(r[j] + t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void InvNtt(short[] r)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
const short f = 1441; // mont^2/128
|
||||||
|
int k = 127;
|
||||||
|
for (int len = 2; len <= 128; len <<= 1)
|
||||||
|
{
|
||||||
|
for (int start = 0; start < 256; start += 2 * len)
|
||||||
|
{
|
||||||
|
short zeta = Zetas[k--];
|
||||||
|
for (int j = start; j < start + len; j++)
|
||||||
|
{
|
||||||
|
short t = r[j];
|
||||||
|
r[j] = BarrettReduce((short)(t + r[j + len]));
|
||||||
|
r[j + len] = (short)(r[j + len] - t);
|
||||||
|
r[j + len] = FqMul(zeta, r[j + len]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int j = 0; j < 256; j++)
|
||||||
|
r[j] = FqMul(r[j], f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void BaseMul(short[] r, int rOff, short[] a, int aOff, short[] b, int bOff, short zeta)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
r[rOff] = FqMul(a[aOff + 1], b[bOff + 1]);
|
||||||
|
r[rOff] = FqMul(r[rOff], zeta);
|
||||||
|
r[rOff] = (short)(r[rOff] + FqMul(a[aOff], b[bOff]));
|
||||||
|
r[rOff + 1] = FqMul(a[aOff], b[bOff + 1]);
|
||||||
|
r[rOff + 1] = (short)(r[rOff + 1] + FqMul(a[aOff + 1], b[bOff]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- hashing (BouncyCastle SHA3/SHAKE) ----
|
||||||
|
|
||||||
|
private static byte[] Sha3_256(byte[] data, int off, int len)
|
||||||
|
{
|
||||||
|
var d = new Sha3Digest(256);
|
||||||
|
d.BlockUpdate(data, off, len);
|
||||||
|
var o = new byte[32];
|
||||||
|
d.DoFinal(o, 0);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Sha3_512(byte[] data, int off, int len)
|
||||||
|
{
|
||||||
|
var d = new Sha3Digest(512);
|
||||||
|
d.BlockUpdate(data, off, len);
|
||||||
|
var o = new byte[64];
|
||||||
|
d.DoFinal(o, 0);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Shake256(byte[] data, int len)
|
||||||
|
{
|
||||||
|
var d = new ShakeDigest(256);
|
||||||
|
d.BlockUpdate(data, 0, data.Length);
|
||||||
|
var o = new byte[len];
|
||||||
|
d.Output(o, 0, len);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- centered binomial distribution (eta = 2) ----
|
||||||
|
|
||||||
|
private static uint Load32Le(byte[] x, int off) =>
|
||||||
|
(uint)(x[off] | (x[off + 1] << 8) | (x[off + 2] << 16) | (x[off + 3] << 24));
|
||||||
|
|
||||||
|
private static void Cbd2(short[] r, byte[] buf)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
for (int i = 0; i < N / 8; i++)
|
||||||
|
{
|
||||||
|
uint t = Load32Le(buf, 4 * i);
|
||||||
|
uint d = t & 0x55555555u;
|
||||||
|
d += (t >> 1) & 0x55555555u;
|
||||||
|
for (int j = 0; j < 8; j++)
|
||||||
|
{
|
||||||
|
short a = (short)((d >> (4 * j + 0)) & 0x3);
|
||||||
|
short b = (short)((d >> (4 * j + 2)) & 0x3);
|
||||||
|
r[8 * i + j] = (short)(a - b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- poly serialization ----
|
||||||
|
|
||||||
|
private static void PolyToBytes(byte[] r, int rOff, short[] a)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
for (int i = 0; i < N / 2; i++)
|
||||||
|
{
|
||||||
|
ushort t0 = (ushort)(a[2 * i] + ((a[2 * i] >> 15) & Q));
|
||||||
|
ushort t1 = (ushort)(a[2 * i + 1] + ((a[2 * i + 1] >> 15) & Q));
|
||||||
|
r[rOff + 3 * i + 0] = (byte)t0;
|
||||||
|
r[rOff + 3 * i + 1] = (byte)((t0 >> 8) | (t1 << 4));
|
||||||
|
r[rOff + 3 * i + 2] = (byte)(t1 >> 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PolyFromBytes(short[] r, byte[] a, int aOff)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
for (int i = 0; i < N / 2; i++)
|
||||||
|
{
|
||||||
|
r[2 * i] = (short)(((a[aOff + 3 * i + 0] >> 0) | (a[aOff + 3 * i + 1] << 8)) & 0xFFF);
|
||||||
|
r[2 * i + 1] = (short)(((a[aOff + 3 * i + 1] >> 4) | (a[aOff + 3 * i + 2] << 4)) & 0xFFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PolyCompress(byte[] r, int rOff, short[] a)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
var t = new byte[8];
|
||||||
|
for (int i = 0; i < N / 8; i++)
|
||||||
|
{
|
||||||
|
for (int j = 0; j < 8; j++)
|
||||||
|
{
|
||||||
|
int u = a[8 * i + j];
|
||||||
|
u += (u >> 15) & Q;
|
||||||
|
t[j] = (byte)(((((uint)u << 5) + Q / 2) / Q) & 31);
|
||||||
|
}
|
||||||
|
r[rOff + 0] = (byte)((t[0] >> 0) | (t[1] << 5));
|
||||||
|
r[rOff + 1] = (byte)((t[1] >> 3) | (t[2] << 2) | (t[3] << 7));
|
||||||
|
r[rOff + 2] = (byte)((t[3] >> 1) | (t[4] << 4));
|
||||||
|
r[rOff + 3] = (byte)((t[4] >> 4) | (t[5] << 1) | (t[6] << 6));
|
||||||
|
r[rOff + 4] = (byte)((t[6] >> 2) | (t[7] << 3));
|
||||||
|
rOff += 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PolyDecompress(short[] r, byte[] a, int aOff)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
var t = new byte[8];
|
||||||
|
for (int i = 0; i < N / 8; i++)
|
||||||
|
{
|
||||||
|
t[0] = (byte)(a[aOff + 0] >> 0);
|
||||||
|
t[1] = (byte)((a[aOff + 0] >> 5) | (a[aOff + 1] << 3));
|
||||||
|
t[2] = (byte)(a[aOff + 1] >> 2);
|
||||||
|
t[3] = (byte)((a[aOff + 1] >> 7) | (a[aOff + 2] << 1));
|
||||||
|
t[4] = (byte)((a[aOff + 2] >> 4) | (a[aOff + 3] << 4));
|
||||||
|
t[5] = (byte)(a[aOff + 3] >> 1);
|
||||||
|
t[6] = (byte)((a[aOff + 3] >> 6) | (a[aOff + 4] << 2));
|
||||||
|
t[7] = (byte)(a[aOff + 4] >> 3);
|
||||||
|
aOff += 5;
|
||||||
|
for (int j = 0; j < 8; j++)
|
||||||
|
r[8 * i + j] = (short)(((uint)(t[j] & 31) * Q + 16) >> 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PolyFromMsg(short[] r, byte[] msg)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
for (int i = 0; i < N / 8; i++)
|
||||||
|
for (int j = 0; j < 8; j++)
|
||||||
|
{
|
||||||
|
short mask = (short)(-(short)((msg[i] >> j) & 1));
|
||||||
|
r[8 * i + j] = (short)(mask & ((Q + 1) / 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] PolyToMsg(short[] a)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
var msg = new byte[SymBytes];
|
||||||
|
for (int i = 0; i < N / 8; i++)
|
||||||
|
{
|
||||||
|
msg[i] = 0;
|
||||||
|
for (int j = 0; j < 8; j++)
|
||||||
|
{
|
||||||
|
int t = a[8 * i + j];
|
||||||
|
t += (t >> 15) & Q;
|
||||||
|
t = (((t << 1) + Q / 2) / Q) & 1;
|
||||||
|
msg[i] |= (byte)(t << j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static short[] PolyGetNoiseEta1(byte[] seed, byte nonce) => GetNoise(seed, nonce, Eta1);
|
||||||
|
private static short[] PolyGetNoiseEta2(byte[] seed, byte nonce) => GetNoise(seed, nonce, Eta2);
|
||||||
|
|
||||||
|
private static short[] GetNoise(byte[] seed, byte nonce, int eta)
|
||||||
|
{
|
||||||
|
var extkey = new byte[SymBytes + 1];
|
||||||
|
Array.Copy(seed, extkey, SymBytes);
|
||||||
|
extkey[SymBytes] = nonce;
|
||||||
|
byte[] buf = Shake256(extkey, eta * N / 4);
|
||||||
|
var r = new short[N];
|
||||||
|
Cbd2(r, buf); // eta1 == eta2 == 2 for Kyber-1024
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PolyNtt(short[] r) { Ntt(r); PolyReduce(r); }
|
||||||
|
private static void PolyInvNttToMont(short[] r) => InvNtt(r);
|
||||||
|
|
||||||
|
private static void PolyBaseMulMont(short[] r, short[] a, short[] b)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
for (int i = 0; i < N / 4; i++)
|
||||||
|
{
|
||||||
|
BaseMul(r, 4 * i, a, 4 * i, b, 4 * i, Zetas[64 + i]);
|
||||||
|
BaseMul(r, 4 * i + 2, a, 4 * i + 2, b, 4 * i + 2, (short)(-Zetas[64 + i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PolyToMont(short[] r)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
const short f = (short)(((1L << 32) % Q));
|
||||||
|
for (int i = 0; i < N; i++)
|
||||||
|
r[i] = MontgomeryReduce(r[i] * f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PolyReduce(short[] r)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < N; i++)
|
||||||
|
r[i] = BarrettReduce(r[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PolyAdd(short[] r, short[] a, short[] b)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
for (int i = 0; i < N; i++) r[i] = (short)(a[i] + b[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PolySub(short[] r, short[] a, short[] b)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
for (int i = 0; i < N; i++) r[i] = (short)(a[i] - b[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- polyvec (short[K][N]) ----
|
||||||
|
|
||||||
|
private static short[][] NewPolyVec()
|
||||||
|
{
|
||||||
|
var v = new short[K][];
|
||||||
|
for (int i = 0; i < K; i++) v[i] = new short[N];
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PolyVecToBytes(byte[] r, int rOff, short[][] a)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < K; i++) PolyToBytes(r, rOff + i * PolyBytes, a[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static short[][] PolyVecFromBytes(byte[] a, int aOff)
|
||||||
|
{
|
||||||
|
var r = NewPolyVec();
|
||||||
|
for (int i = 0; i < K; i++) PolyFromBytes(r[i], a, aOff + i * PolyBytes);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PolyVecCompress(byte[] r, int rOff, short[][] a)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
var t = new ushort[8];
|
||||||
|
for (int i = 0; i < K; i++)
|
||||||
|
{
|
||||||
|
for (int j = 0; j < N / 8; j++)
|
||||||
|
{
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int c = a[i][8 * j + k];
|
||||||
|
c += (c >> 15) & Q;
|
||||||
|
t[k] = (ushort)(((((uint)c << 11) + Q / 2) / Q) & 0x7ff);
|
||||||
|
}
|
||||||
|
r[rOff + 0] = (byte)(t[0] >> 0);
|
||||||
|
r[rOff + 1] = (byte)((t[0] >> 8) | (t[1] << 3));
|
||||||
|
r[rOff + 2] = (byte)((t[1] >> 5) | (t[2] << 6));
|
||||||
|
r[rOff + 3] = (byte)(t[2] >> 2);
|
||||||
|
r[rOff + 4] = (byte)((t[2] >> 10) | (t[3] << 1));
|
||||||
|
r[rOff + 5] = (byte)((t[3] >> 7) | (t[4] << 4));
|
||||||
|
r[rOff + 6] = (byte)((t[4] >> 4) | (t[5] << 7));
|
||||||
|
r[rOff + 7] = (byte)(t[5] >> 1);
|
||||||
|
r[rOff + 8] = (byte)((t[5] >> 9) | (t[6] << 2));
|
||||||
|
r[rOff + 9] = (byte)((t[6] >> 6) | (t[7] << 5));
|
||||||
|
r[rOff + 10] = (byte)(t[7] >> 3);
|
||||||
|
rOff += 11;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static short[][] PolyVecDecompress(byte[] a, int aOff)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
var r = NewPolyVec();
|
||||||
|
var t = new ushort[8];
|
||||||
|
for (int i = 0; i < K; i++)
|
||||||
|
{
|
||||||
|
for (int j = 0; j < N / 8; j++)
|
||||||
|
{
|
||||||
|
t[0] = (ushort)((a[aOff + 0] >> 0) | (a[aOff + 1] << 8));
|
||||||
|
t[1] = (ushort)((a[aOff + 1] >> 3) | (a[aOff + 2] << 5));
|
||||||
|
t[2] = (ushort)((a[aOff + 2] >> 6) | (a[aOff + 3] << 2) | (a[aOff + 4] << 10));
|
||||||
|
t[3] = (ushort)((a[aOff + 4] >> 1) | (a[aOff + 5] << 7));
|
||||||
|
t[4] = (ushort)((a[aOff + 5] >> 4) | (a[aOff + 6] << 4));
|
||||||
|
t[5] = (ushort)((a[aOff + 6] >> 7) | (a[aOff + 7] << 1) | (a[aOff + 8] << 9));
|
||||||
|
t[6] = (ushort)((a[aOff + 8] >> 2) | (a[aOff + 9] << 6));
|
||||||
|
t[7] = (ushort)((a[aOff + 9] >> 5) | (a[aOff + 10] << 3));
|
||||||
|
aOff += 11;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
r[i][8 * j + k] = (short)(((uint)(t[k] & 0x7FF) * Q + 1024) >> 11);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PolyVecNtt(short[][] r) { for (int i = 0; i < K; i++) PolyNtt(r[i]); }
|
||||||
|
private static void PolyVecInvNttToMont(short[][] r) { for (int i = 0; i < K; i++) PolyInvNttToMont(r[i]); }
|
||||||
|
|
||||||
|
private static void PolyVecBaseMulAccMont(short[] r, short[][] a, short[][] b)
|
||||||
|
{
|
||||||
|
var t = new short[N];
|
||||||
|
PolyBaseMulMont(r, a[0], b[0]);
|
||||||
|
for (int i = 1; i < K; i++)
|
||||||
|
{
|
||||||
|
PolyBaseMulMont(t, a[i], b[i]);
|
||||||
|
PolyAdd(r, r, t);
|
||||||
|
}
|
||||||
|
PolyReduce(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PolyVecReduce(short[][] r) { for (int i = 0; i < K; i++) PolyReduce(r[i]); }
|
||||||
|
private static void PolyVecAdd(short[][] r, short[][] a, short[][] b) { for (int i = 0; i < K; i++) PolyAdd(r[i], a[i], b[i]); }
|
||||||
|
|
||||||
|
// ---- matrix generation (rejection sampling on SHAKE128) ----
|
||||||
|
|
||||||
|
private const int XofBlockBytes = 168; // SHAKE128 rate
|
||||||
|
private const int GenMatrixNBlocks = (12 * N / 8 * (1 << 12) / Q + XofBlockBytes) / XofBlockBytes; // 3
|
||||||
|
|
||||||
|
private static int RejUniform(short[] r, int rOff, int len, byte[] buf, int buflen)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
int ctr = 0, pos = 0;
|
||||||
|
while (ctr < len && pos + 3 <= buflen)
|
||||||
|
{
|
||||||
|
ushort val0 = (ushort)(((buf[pos + 0] >> 0) | (buf[pos + 1] << 8)) & 0xFFF);
|
||||||
|
ushort val1 = (ushort)(((buf[pos + 1] >> 4) | (buf[pos + 2] << 4)) & 0xFFF);
|
||||||
|
pos += 3;
|
||||||
|
if (val0 < Q) r[rOff + ctr++] = (short)val0;
|
||||||
|
if (ctr < len && val1 < Q) r[rOff + ctr++] = (short)val1;
|
||||||
|
}
|
||||||
|
return ctr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static short[][][] GenMatrix(byte[] seed, bool transposed)
|
||||||
|
{
|
||||||
|
var a = new short[K][][];
|
||||||
|
for (int i = 0; i < K; i++)
|
||||||
|
{
|
||||||
|
a[i] = NewPolyVec();
|
||||||
|
for (int j = 0; j < K; j++)
|
||||||
|
{
|
||||||
|
var extseed = new byte[SymBytes + 2];
|
||||||
|
Array.Copy(seed, extseed, SymBytes);
|
||||||
|
extseed[SymBytes] = (byte)(transposed ? i : j);
|
||||||
|
extseed[SymBytes + 1] = (byte)(transposed ? j : i);
|
||||||
|
|
||||||
|
var xof = new ShakeDigest(128);
|
||||||
|
xof.BlockUpdate(extseed, 0, extseed.Length);
|
||||||
|
|
||||||
|
var buf = new byte[GenMatrixNBlocks * XofBlockBytes + 2];
|
||||||
|
xof.Output(buf, 0, GenMatrixNBlocks * XofBlockBytes);
|
||||||
|
int buflen = GenMatrixNBlocks * XofBlockBytes;
|
||||||
|
int ctr = RejUniform(a[i][j], 0, N, buf, buflen);
|
||||||
|
|
||||||
|
while (ctr < N)
|
||||||
|
{
|
||||||
|
int off = buflen % 3;
|
||||||
|
for (int k = 0; k < off; k++) buf[k] = buf[buflen - off + k];
|
||||||
|
xof.Output(buf, off, XofBlockBytes);
|
||||||
|
buflen = off + XofBlockBytes;
|
||||||
|
ctr += RejUniform(a[i][j], ctr, N - ctr, buf, buflen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- IND-CPA ----
|
||||||
|
|
||||||
|
private static void IndcpaKeypair(byte[] d, out byte[] pk, out byte[] sk)
|
||||||
|
{
|
||||||
|
byte[] buf = Sha3_512(d, 0, SymBytes); // publicseed || noiseseed
|
||||||
|
var publicseed = new byte[SymBytes];
|
||||||
|
var noiseseed = new byte[SymBytes];
|
||||||
|
Array.Copy(buf, 0, publicseed, 0, SymBytes);
|
||||||
|
Array.Copy(buf, SymBytes, noiseseed, 0, SymBytes);
|
||||||
|
|
||||||
|
short[][][] a = GenMatrix(publicseed, transposed: false);
|
||||||
|
|
||||||
|
var skpv = NewPolyVec();
|
||||||
|
var e = NewPolyVec();
|
||||||
|
byte nonce = 0;
|
||||||
|
for (int i = 0; i < K; i++) skpv[i] = PolyGetNoiseEta1(noiseseed, nonce++);
|
||||||
|
for (int i = 0; i < K; i++) e[i] = PolyGetNoiseEta1(noiseseed, nonce++);
|
||||||
|
|
||||||
|
PolyVecNtt(skpv);
|
||||||
|
PolyVecNtt(e);
|
||||||
|
|
||||||
|
var pkpv = NewPolyVec();
|
||||||
|
for (int i = 0; i < K; i++)
|
||||||
|
{
|
||||||
|
PolyVecBaseMulAccMont(pkpv[i], a[i], skpv);
|
||||||
|
PolyToMont(pkpv[i]);
|
||||||
|
}
|
||||||
|
PolyVecAdd(pkpv, pkpv, e);
|
||||||
|
PolyVecReduce(pkpv);
|
||||||
|
|
||||||
|
sk = new byte[IndcpaSecretKeyBytes];
|
||||||
|
PolyVecToBytes(sk, 0, skpv);
|
||||||
|
|
||||||
|
pk = new byte[IndcpaPublicKeyBytes];
|
||||||
|
PolyVecToBytes(pk, 0, pkpv);
|
||||||
|
Array.Copy(publicseed, 0, pk, PolyVecBytes, SymBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] IndcpaEnc(byte[] m, byte[] pk, byte[] coins)
|
||||||
|
{
|
||||||
|
short[][] pkpv = PolyVecFromBytes(pk, 0);
|
||||||
|
var seed = new byte[SymBytes];
|
||||||
|
Array.Copy(pk, PolyVecBytes, seed, 0, SymBytes);
|
||||||
|
|
||||||
|
short[] k = new short[N];
|
||||||
|
PolyFromMsg(k, m);
|
||||||
|
short[][][] at = GenMatrix(seed, transposed: true);
|
||||||
|
|
||||||
|
var sp = NewPolyVec();
|
||||||
|
var ep = NewPolyVec();
|
||||||
|
byte nonce = 0;
|
||||||
|
for (int i = 0; i < K; i++) sp[i] = PolyGetNoiseEta1(coins, nonce++);
|
||||||
|
for (int i = 0; i < K; i++) ep[i] = PolyGetNoiseEta2(coins, nonce++);
|
||||||
|
short[] epp = PolyGetNoiseEta2(coins, nonce);
|
||||||
|
|
||||||
|
PolyVecNtt(sp);
|
||||||
|
|
||||||
|
var b = NewPolyVec();
|
||||||
|
for (int i = 0; i < K; i++) PolyVecBaseMulAccMont(b[i], at[i], sp);
|
||||||
|
var v = new short[N];
|
||||||
|
PolyVecBaseMulAccMont(v, pkpv, sp);
|
||||||
|
|
||||||
|
PolyVecInvNttToMont(b);
|
||||||
|
PolyInvNttToMont(v);
|
||||||
|
|
||||||
|
PolyVecAdd(b, b, ep);
|
||||||
|
PolyAdd(v, v, epp);
|
||||||
|
PolyAdd(v, v, k);
|
||||||
|
PolyVecReduce(b);
|
||||||
|
PolyReduce(v);
|
||||||
|
|
||||||
|
var c = new byte[IndcpaBytes];
|
||||||
|
PolyVecCompress(c, 0, b);
|
||||||
|
PolyCompress(c, PolyVecCompressedBytes, v);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] IndcpaDec(byte[] c, byte[] sk)
|
||||||
|
{
|
||||||
|
short[][] b = PolyVecDecompress(c, 0);
|
||||||
|
short[] v = new short[N];
|
||||||
|
PolyDecompress(v, c, PolyVecCompressedBytes);
|
||||||
|
|
||||||
|
short[][] skpv = PolyVecFromBytes(sk, 0);
|
||||||
|
|
||||||
|
PolyVecNtt(b);
|
||||||
|
var mp = new short[N];
|
||||||
|
PolyVecBaseMulAccMont(mp, skpv, b);
|
||||||
|
PolyInvNttToMont(mp);
|
||||||
|
|
||||||
|
PolySub(mp, v, mp);
|
||||||
|
PolyReduce(mp);
|
||||||
|
return PolyToMsg(mp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CCA-KEM ----
|
||||||
|
|
||||||
|
/// <summary>Generates a key pair from the two 32-byte coins consumed by the reference
|
||||||
|
/// (<paramref name="d"/> drives IND-CPA keygen, <paramref name="z"/> is the implicit-rejection value).</summary>
|
||||||
|
public static void KeyPair(byte[] d, byte[] z, out byte[] pk, out byte[] sk)
|
||||||
|
{
|
||||||
|
IndcpaKeypair(d, out pk, out byte[] indcpaSk);
|
||||||
|
sk = new byte[SecretKeyBytes];
|
||||||
|
Array.Copy(indcpaSk, 0, sk, 0, IndcpaSecretKeyBytes);
|
||||||
|
Array.Copy(pk, 0, sk, IndcpaSecretKeyBytes, IndcpaPublicKeyBytes);
|
||||||
|
byte[] hpk = Sha3_256(pk, 0, PublicKeyBytes);
|
||||||
|
Array.Copy(hpk, 0, sk, SecretKeyBytes - 2 * SymBytes, SymBytes);
|
||||||
|
Array.Copy(z, 0, sk, SecretKeyBytes - SymBytes, SymBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Encapsulates to <paramref name="pk"/> using the 32-byte message coin <paramref name="m"/>.</summary>
|
||||||
|
public static void Encapsulate(byte[] pk, byte[] m, out byte[] ct, out byte[] ss)
|
||||||
|
{
|
||||||
|
var buf = new byte[2 * SymBytes];
|
||||||
|
byte[] mh = Sha3_256(m, 0, SymBytes); // don't release system RNG output
|
||||||
|
Array.Copy(mh, 0, buf, 0, SymBytes);
|
||||||
|
byte[] hpk = Sha3_256(pk, 0, PublicKeyBytes);
|
||||||
|
Array.Copy(hpk, 0, buf, SymBytes, SymBytes);
|
||||||
|
|
||||||
|
byte[] kr = Sha3_512(buf, 0, 2 * SymBytes);
|
||||||
|
var coins = new byte[SymBytes];
|
||||||
|
Array.Copy(kr, SymBytes, coins, 0, SymBytes);
|
||||||
|
|
||||||
|
var msg = new byte[SymBytes];
|
||||||
|
Array.Copy(buf, 0, msg, 0, SymBytes);
|
||||||
|
ct = IndcpaEnc(msg, pk, coins);
|
||||||
|
|
||||||
|
byte[] hc = Sha3_256(ct, 0, CiphertextBytes);
|
||||||
|
var krFinal = new byte[2 * SymBytes];
|
||||||
|
Array.Copy(kr, 0, krFinal, 0, SymBytes);
|
||||||
|
Array.Copy(hc, 0, krFinal, SymBytes, SymBytes);
|
||||||
|
ss = Shake256(krFinal, SsBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Decapsulates <paramref name="ct"/> with <paramref name="sk"/>, returning the 32-byte shared secret
|
||||||
|
/// (a pseudo-random value on implicit-rejection failure).</summary>
|
||||||
|
public static byte[] Decapsulate(byte[] ct, byte[] sk)
|
||||||
|
{
|
||||||
|
var skCpa = new byte[IndcpaSecretKeyBytes];
|
||||||
|
Array.Copy(sk, 0, skCpa, 0, IndcpaSecretKeyBytes);
|
||||||
|
var pk = new byte[IndcpaPublicKeyBytes];
|
||||||
|
Array.Copy(sk, IndcpaSecretKeyBytes, pk, 0, IndcpaPublicKeyBytes);
|
||||||
|
|
||||||
|
byte[] m = IndcpaDec(ct, skCpa);
|
||||||
|
|
||||||
|
var buf = new byte[2 * SymBytes];
|
||||||
|
Array.Copy(m, 0, buf, 0, SymBytes);
|
||||||
|
Array.Copy(sk, SecretKeyBytes - 2 * SymBytes, buf, SymBytes, SymBytes); // stored H(pk)
|
||||||
|
|
||||||
|
byte[] kr = Sha3_512(buf, 0, 2 * SymBytes);
|
||||||
|
var coins = new byte[SymBytes];
|
||||||
|
Array.Copy(kr, SymBytes, coins, 0, SymBytes);
|
||||||
|
|
||||||
|
byte[] cmp = IndcpaEnc(buf[..SymBytes], pk, coins);
|
||||||
|
int fail = Verify(ct, cmp, CiphertextBytes);
|
||||||
|
|
||||||
|
byte[] hc = Sha3_256(ct, 0, CiphertextBytes);
|
||||||
|
var krFinal = new byte[2 * SymBytes];
|
||||||
|
Array.Copy(kr, 0, krFinal, 0, SymBytes);
|
||||||
|
Array.Copy(hc, 0, krFinal, SymBytes, SymBytes);
|
||||||
|
|
||||||
|
// cmov: replace pre-k with z on failure (constant time)
|
||||||
|
CMov(krFinal, 0, sk, SecretKeyBytes - SymBytes, SymBytes, (byte)fail);
|
||||||
|
return Shake256(krFinal, SsBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int Verify(byte[] a, byte[] b, int len)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
byte r = 0;
|
||||||
|
for (int i = 0; i < len; i++) r |= (byte)(a[i] ^ b[i]);
|
||||||
|
return (int)((ulong)(0 - (ulong)r) >> 63);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CMov(byte[] r, int rOff, byte[] x, int xOff, int len, byte b)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
b = (byte)(-(sbyte)b);
|
||||||
|
for (int i = 0; i < len; i++)
|
||||||
|
r[rOff + i] ^= (byte)(b & (r[rOff + i] ^ x[xOff + i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Curve;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// XEdDSA over Curve25519 / Ed25519 (Trevor Perrin's spec, as used by Signal).
|
||||||
|
/// Signs/verifies Ed25519-style signatures using a Montgomery (X25519) key pair, so the same
|
||||||
|
/// identity key can be used for both ECDH (X25519) and signatures.
|
||||||
|
///
|
||||||
|
/// Implemented on top of a compact, auditable BigInteger reference of the Ed25519 group
|
||||||
|
/// (<see cref="Ed25519Math"/>). Correctness of the underlying group/field/scalar arithmetic is
|
||||||
|
/// validated against RFC 8032 known-answer vectors; XEdDSA verify is validated against libsignal's
|
||||||
|
/// own curve25519 known-answer vector (XEd25519VectorTests).
|
||||||
|
///
|
||||||
|
/// Signal-specific detail: the Edwards public key's sign bit is NOT forced to 0. The signer stashes
|
||||||
|
/// A's natural sign bit in the high bit of s (s < L leaves it free), and the verifier reads it from
|
||||||
|
/// signature[63] to reconstruct A with the correct sign before clearing the bit to parse s. (Our
|
||||||
|
/// signer happens to always produce sign-bit-0 keys, which is the special case libsignal accepts.)
|
||||||
|
/// </summary>
|
||||||
|
public static class XEd25519
|
||||||
|
{
|
||||||
|
// hash_1 prefix per XEdDSA spec: little-endian encoding of (2^256 - 1 - 1) = 2^256 - 2.
|
||||||
|
private static readonly byte[] Hash1Prefix = BuildHash1Prefix();
|
||||||
|
|
||||||
|
// (L-1) as a 32-byte little-endian scalar, used to negate a scalar mod L (constant-time).
|
||||||
|
private static readonly byte[] ScalarMinusOne = Ed25519Math.ToLe32(Ed25519Math.Mod(BigInteger.MinusOne, Ed25519Math.L));
|
||||||
|
private static readonly byte[] Zero32 = new byte[32];
|
||||||
|
|
||||||
|
private static byte[] BuildHash1Prefix()
|
||||||
|
{
|
||||||
|
var p = new byte[32];
|
||||||
|
p[0] = 0xFE;
|
||||||
|
for (int i = 1; i < 32; i++) p[i] = 0xFF;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// XEdDSA sign. <paramref name="privateKey"/> is the 32-byte (clamped) Montgomery/X25519
|
||||||
|
/// private scalar, little-endian. <paramref name="random"/> must be 64 fresh random bytes.
|
||||||
|
/// Returns a 64-byte signature (R || s).
|
||||||
|
/// </summary>
|
||||||
|
public static byte[] CalculateSignature(ReadOnlySpan<byte> privateKey, ReadOnlySpan<byte> message, ReadOnlySpan<byte> random)
|
||||||
|
{
|
||||||
|
if (privateKey.Length != 32) throw new ArgumentException("private key must be 32 bytes", nameof(privateKey));
|
||||||
|
if (random.Length != 64) throw new ArgumentException("random must be 64 bytes", nameof(random));
|
||||||
|
|
||||||
|
// Constant-time signing: the operations that touch the private key (the two fixed-base scalar
|
||||||
|
// multiplies on the secret k and nonce r, and the scalar arithmetic mod L) run through Ed25519Ct
|
||||||
|
// (BouncyCastle's constant-time field). The hash-to-scalar h is over public data (R, A, M) only,
|
||||||
|
// so it stays on the BigInteger reference. Validated byte-identical to the reference (Ed25519CtTests).
|
||||||
|
byte[] sk = privateKey.ToArray();
|
||||||
|
|
||||||
|
// calculate_key_pair(k): A has sign bit 0; a is adjusted so that a·B == A.
|
||||||
|
byte[] enc = Ed25519Ct.ScalarMultBaseEncode(sk); // k·B
|
||||||
|
int xOdd = (enc[31] >> 7) & 1;
|
||||||
|
byte[] aEnc = (byte[])enc.Clone();
|
||||||
|
aEnc[31] &= 0x7F; // A's x is forced even (sign bit 0)
|
||||||
|
|
||||||
|
var k64 = new byte[64];
|
||||||
|
Array.Copy(sk, k64, 32);
|
||||||
|
byte[] kModL = Ed25519Ct.ScReduce(k64); // k mod L
|
||||||
|
byte[] aBytes = xOdd == 1 ? Ed25519Ct.ScMulAdd(ScalarMinusOne, kModL, Zero32) : kModL; // a = ±k mod L
|
||||||
|
|
||||||
|
// r = hash_1(a || M || Z) mod L
|
||||||
|
byte[] r;
|
||||||
|
using (var sha = SHA512.Create())
|
||||||
|
{
|
||||||
|
sha.TransformBlock(Hash1Prefix, 0, Hash1Prefix.Length, null, 0);
|
||||||
|
sha.TransformBlock(aBytes, 0, aBytes.Length, null, 0);
|
||||||
|
TransformSpan(sha, message);
|
||||||
|
TransformSpan(sha, random, final: true);
|
||||||
|
r = Ed25519Ct.ScReduce(sha.Hash!);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] rEnc = Ed25519Ct.ScalarMultBaseEncode(r); // R = r·B
|
||||||
|
|
||||||
|
// h = hash(R || A || M) mod L (public inputs only)
|
||||||
|
byte[] hBytes = Ed25519Math.ToLe32(HashToScalar(rEnc, aEnc, message));
|
||||||
|
|
||||||
|
byte[] s = Ed25519Ct.ScMulAdd(hBytes, aBytes, r); // s = h·a + r (mod L)
|
||||||
|
|
||||||
|
var sig = new byte[64];
|
||||||
|
Array.Copy(rEnc, 0, sig, 0, 32);
|
||||||
|
Array.Copy(s, 0, sig, 32, 32);
|
||||||
|
return sig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// XEdDSA verify. <paramref name="montgomeryPublicKey"/> is the 32-byte X25519 public key
|
||||||
|
/// (Montgomery u-coordinate, little-endian). <paramref name="signature"/> is 64 bytes (R || s).
|
||||||
|
/// </summary>
|
||||||
|
public static bool VerifySignature(ReadOnlySpan<byte> montgomeryPublicKey, ReadOnlySpan<byte> message, ReadOnlySpan<byte> signature)
|
||||||
|
{
|
||||||
|
if (montgomeryPublicKey.Length != 32 || signature.Length != 64) return false;
|
||||||
|
|
||||||
|
// Mask the high bit per RFC 7748, then reject u >= p.
|
||||||
|
Span<byte> u32 = stackalloc byte[32];
|
||||||
|
montgomeryPublicKey.CopyTo(u32);
|
||||||
|
u32[31] &= 0x7F;
|
||||||
|
BigInteger u = Ed25519Math.FromLe(u32);
|
||||||
|
if (u >= Ed25519Math.P) return false;
|
||||||
|
|
||||||
|
// Montgomery u -> Edwards y = (u - 1) / (u + 1)
|
||||||
|
BigInteger denom = Ed25519Math.Mod(u + 1, Ed25519Math.P);
|
||||||
|
if (denom.IsZero) return false;
|
||||||
|
BigInteger y = Ed25519Math.Mod((u - 1) * Ed25519Math.Inverse(denom), Ed25519Math.P);
|
||||||
|
|
||||||
|
// Signal's curve25519 XEdDSA stashes the Edwards public key's sign bit in the high bit of s
|
||||||
|
// (signature[63]); the verifier reads it back to reconstruct A with the correct sign, then
|
||||||
|
// clears it before parsing s. Matches libsignal rust/core curve25519 verify_signature.
|
||||||
|
int sign = (signature[63] & 0x80) >> 7;
|
||||||
|
|
||||||
|
Span<byte> s32 = stackalloc byte[32];
|
||||||
|
signature.Slice(32, 32).CopyTo(s32);
|
||||||
|
s32[31] &= 0x7F;
|
||||||
|
if ((s32[31] & 0xE0) != 0) return false; // scalar out of range
|
||||||
|
BigInteger s = Ed25519Math.FromLe(s32);
|
||||||
|
|
||||||
|
// A = decode(y, sign-from-signature); its encoding carries that sign bit and is what's hashed.
|
||||||
|
if (!Ed25519Math.TryDecode(y, sign, out Ed25519Math.Point a)) return false;
|
||||||
|
byte[] aEnc = Ed25519Math.Encode(a);
|
||||||
|
|
||||||
|
byte[] rEnc = signature.Slice(0, 32).ToArray();
|
||||||
|
BigInteger h = HashToScalar(rEnc, aEnc, message);
|
||||||
|
|
||||||
|
// R_check = s*B - h*A
|
||||||
|
Ed25519Math.Point sB = Ed25519Math.ScalarMultBase(s);
|
||||||
|
Ed25519Math.Point hA = Ed25519Math.ScalarMult(a, h);
|
||||||
|
Ed25519Math.Point rCheck = Ed25519Math.Add(sB, hA.Negate());
|
||||||
|
|
||||||
|
return CryptographicOperations.FixedTimeEquals(Ed25519Math.Encode(rCheck), rEnc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BigInteger HashToScalar(byte[] rEnc, byte[] aEnc, ReadOnlySpan<byte> message)
|
||||||
|
{
|
||||||
|
using var sha = SHA512.Create();
|
||||||
|
sha.TransformBlock(rEnc, 0, rEnc.Length, null, 0);
|
||||||
|
sha.TransformBlock(aEnc, 0, aEnc.Length, null, 0);
|
||||||
|
TransformSpan(sha, message, final: true);
|
||||||
|
return Ed25519Math.ScReduce(sha.Hash!);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TransformSpan(SHA512 sha, ReadOnlySpan<byte> data, bool final = false)
|
||||||
|
{
|
||||||
|
byte[] buf = data.ToArray();
|
||||||
|
if (final)
|
||||||
|
sha.TransformFinalBlock(buf, 0, buf.Length);
|
||||||
|
else
|
||||||
|
sha.TransformBlock(buf, 0, buf.Length, null, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Wingnal.Protocol.Curve;
|
||||||
|
using Wingnal.Protocol.State;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Groups;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets up sender-key sessions. The sender calls <see cref="Create"/> once per group/distribution to
|
||||||
|
/// produce a SenderKeyDistributionMessage (fanned out 1:1 to members); each member calls
|
||||||
|
/// <see cref="Process"/> on that SKDM to install a receiving state. Mirrors libsignal's
|
||||||
|
/// GroupSessionBuilder.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GroupSessionBuilder
|
||||||
|
{
|
||||||
|
private const int MessageVersion = SenderKeyWire.CurrentVersion;
|
||||||
|
|
||||||
|
private readonly ISenderKeyStore _store;
|
||||||
|
|
||||||
|
public GroupSessionBuilder(ISenderKeyStore store) => _store = store;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates (or returns) our outgoing sender-key state for <paramref name="sender"/> +
|
||||||
|
/// <paramref name="distributionId"/> and returns the SKDM describing it. If no state exists yet,
|
||||||
|
/// a fresh chain (random 32-byte chain key, iteration 0), a random 31-bit chain id, and a new
|
||||||
|
/// signing key pair are generated.
|
||||||
|
/// </summary>
|
||||||
|
public SenderKeyDistributionMessage Create(SignalProtocolAddress sender, Guid distributionId)
|
||||||
|
{
|
||||||
|
SenderKeyRecord record = _store.LoadSenderKey(sender, distributionId) ?? new SenderKeyRecord();
|
||||||
|
|
||||||
|
if (record.IsEmpty)
|
||||||
|
{
|
||||||
|
// 31-bit chain id (Java-compatible: top bit cleared) per libsignal.
|
||||||
|
uint chainId = RandomUInt32() >> 1;
|
||||||
|
byte[] chainKey = RandomNumberGenerator.GetBytes(32);
|
||||||
|
ECKeyPair signingKey = Curve25519.GenerateKeyPair();
|
||||||
|
|
||||||
|
record.AddState(chainId, MessageVersion, iteration: 0, chainKey,
|
||||||
|
signingKey.PublicKey, signingKey.PrivateKey);
|
||||||
|
_store.StoreSenderKey(sender, distributionId, record);
|
||||||
|
}
|
||||||
|
|
||||||
|
SenderKeyState state = record.State;
|
||||||
|
return new SenderKeyDistributionMessage(state.MessageVersion, distributionId, state.ChainId,
|
||||||
|
state.ChainKey.Iteration, state.ChainKey.Seed, state.SigningKeyPublic);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Installs the receiving state described by <paramref name="skdm"/> for the given
|
||||||
|
/// sender + the SKDM's distribution id.</summary>
|
||||||
|
public void Process(SignalProtocolAddress sender, SenderKeyDistributionMessage skdm)
|
||||||
|
{
|
||||||
|
SenderKeyRecord record = _store.LoadSenderKey(sender, skdm.DistributionId) ?? new SenderKeyRecord();
|
||||||
|
record.AddState(skdm.ChainId, skdm.MessageVersion, skdm.Iteration, skdm.ChainKey,
|
||||||
|
skdm.SigningKeyPublic, signingKeyPrivate: null);
|
||||||
|
_store.StoreSenderKey(sender, skdm.DistributionId, record);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint RandomUInt32() => BitConverter.ToUInt32(RandomNumberGenerator.GetBytes(4));
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using Wingnal.Protocol.Crypto;
|
||||||
|
using Wingnal.Protocol.Messages;
|
||||||
|
using Wingnal.Protocol.State;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Groups;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encrypts/decrypts group messages with a sender key. Encrypt advances our own sending chain and
|
||||||
|
/// signs a SenderKeyMessage; decrypt selects the named chain, derives (and caches skipped) message
|
||||||
|
/// keys, verifies the signature, and AES-256-CBC decrypts. Mirrors libsignal's group_cipher.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GroupSessionCipher
|
||||||
|
{
|
||||||
|
/// <summary>libsignal <c>consts::MAX_FORWARD_JUMPS</c> — reject messages too far in the future.</summary>
|
||||||
|
private const int MaxForwardJumps = 25_000;
|
||||||
|
|
||||||
|
private readonly ISenderKeyStore _store;
|
||||||
|
|
||||||
|
public GroupSessionCipher(ISenderKeyStore store) => _store = store;
|
||||||
|
|
||||||
|
/// <summary>Encrypts <paramref name="plaintext"/> under our sending chain for (sender,
|
||||||
|
/// distributionId). Requires that <see cref="GroupSessionBuilder.Create"/> ran first.</summary>
|
||||||
|
public SenderKeyMessage Encrypt(SignalProtocolAddress sender, Guid distributionId, byte[] plaintext)
|
||||||
|
{
|
||||||
|
SenderKeyRecord record = _store.LoadSenderKey(sender, distributionId)
|
||||||
|
?? throw new InvalidMessageException("no sender key to encrypt with");
|
||||||
|
SenderKeyState state = record.State;
|
||||||
|
if (state.SigningKeyPrivate is null)
|
||||||
|
throw new InvalidMessageException("no private signing key (receive-only state)");
|
||||||
|
|
||||||
|
SenderChainKey chainKey = state.ChainKey;
|
||||||
|
SenderMessageKey messageKey = chainKey.MessageKey();
|
||||||
|
|
||||||
|
byte[] ciphertext = CryptoPrimitives.AesCbcEncrypt(messageKey.CipherKey, messageKey.Iv, plaintext);
|
||||||
|
|
||||||
|
var skm = new SenderKeyMessage(state.MessageVersion, distributionId, state.ChainId,
|
||||||
|
messageKey.Iteration, ciphertext, state.SigningKeyPrivate);
|
||||||
|
|
||||||
|
state.ChainKey = chainKey.Next();
|
||||||
|
_store.StoreSenderKey(sender, distributionId, record);
|
||||||
|
return skm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Decrypts a received <paramref name="message"/> from <paramref name="sender"/>.</summary>
|
||||||
|
public byte[] Decrypt(SignalProtocolAddress sender, SenderKeyMessage message)
|
||||||
|
{
|
||||||
|
SenderKeyRecord record = _store.LoadSenderKey(sender, message.DistributionId)
|
||||||
|
?? throw new InvalidMessageException("no sender key for this distribution");
|
||||||
|
SenderKeyState state = record.StateForChainId(message.ChainId)
|
||||||
|
?? throw new InvalidMessageException($"no sender key state for chain id {message.ChainId}");
|
||||||
|
|
||||||
|
if (!message.VerifySignature(state.SigningKeyPublic))
|
||||||
|
throw new InvalidMessageException("invalid SenderKeyMessage signature");
|
||||||
|
|
||||||
|
SenderMessageKey messageKey = GetMessageKey(state, message.Iteration);
|
||||||
|
|
||||||
|
byte[] plaintext = CryptoPrimitives.AesCbcDecrypt(messageKey.CipherKey, messageKey.Iv, message.Ciphertext);
|
||||||
|
_store.StoreSenderKey(sender, message.DistributionId, record);
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors libsignal get_sender_key: serve a cached past key, else advance (caching skipped keys)
|
||||||
|
// up to the requested iteration. Bounded by MAX_FORWARD_JUMPS.
|
||||||
|
private static SenderMessageKey GetMessageKey(SenderKeyState state, uint iteration)
|
||||||
|
{
|
||||||
|
SenderChainKey chainKey = state.ChainKey;
|
||||||
|
uint current = chainKey.Iteration;
|
||||||
|
|
||||||
|
if (current > iteration)
|
||||||
|
{
|
||||||
|
SenderMessageKey? cached = state.RemoveMessageKey(iteration);
|
||||||
|
return cached ?? throw new DuplicateMessageException(
|
||||||
|
$"message key for iteration {iteration} already used or skipped");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iteration - current > MaxForwardJumps)
|
||||||
|
throw new InvalidMessageException("message from too far into the future");
|
||||||
|
|
||||||
|
while (chainKey.Iteration < iteration)
|
||||||
|
{
|
||||||
|
state.AddMessageKey(chainKey.MessageKey());
|
||||||
|
chainKey = chainKey.Next();
|
||||||
|
}
|
||||||
|
|
||||||
|
state.ChainKey = chainKey.Next();
|
||||||
|
return chainKey.MessageKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Wingnal.Protocol.Curve;
|
||||||
|
using Wingnal.Protocol.Messages;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Groups;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared constants + helpers for the Sender Key (group) wire format. Byte-exact with libsignal
|
||||||
|
/// (rust/protocol: <c>protocol.rs</c>, <c>proto/wire.proto</c>, tag v0.96.1).
|
||||||
|
/// </summary>
|
||||||
|
internal static class SenderKeyWire
|
||||||
|
{
|
||||||
|
/// <summary>libsignal <c>SENDERKEY_MESSAGE_CURRENT_VERSION</c> — the low nibble of the version byte.</summary>
|
||||||
|
public const int CurrentVersion = 3;
|
||||||
|
|
||||||
|
/// <summary>Version byte: high nibble = message version, low nibble = current ciphertext version.</summary>
|
||||||
|
public static byte VersionByte(int messageVersion) =>
|
||||||
|
(byte)(((messageVersion & 0xF) << 4) | CurrentVersion);
|
||||||
|
|
||||||
|
/// <summary>A UUID's 16 bytes in RFC 4122 / network (big-endian) order — what libsignal's uuid
|
||||||
|
/// crate emits via <c>as_bytes()</c>. (.NET's default <see cref="Guid.ToByteArray()"/> is
|
||||||
|
/// mixed-endian; the <c>bigEndian</c> overload gives the RFC-4122 order directly.)</summary>
|
||||||
|
public static byte[] DistributionBytes(Guid id) => id.ToByteArray(bigEndian: true);
|
||||||
|
|
||||||
|
public static Guid DistributionId(byte[] be)
|
||||||
|
{
|
||||||
|
if (be.Length != 16) throw new InvalidMessageException("bad distribution id length");
|
||||||
|
return new Guid(be, bigEndian: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A group message ("SenderKeyMessage"): <c>version || protobuf(distribution_uuid, chain_id,
|
||||||
|
/// iteration, ciphertext) || signature[64]</c>. The signature is XEdDSA over <c>version||protobuf</c>
|
||||||
|
/// using the sender's per-distribution signing key.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SenderKeyMessage
|
||||||
|
{
|
||||||
|
private const int SignatureLen = 64;
|
||||||
|
|
||||||
|
public int MessageVersion { get; }
|
||||||
|
public Guid DistributionId { get; }
|
||||||
|
public uint ChainId { get; }
|
||||||
|
public uint Iteration { get; }
|
||||||
|
public byte[] Ciphertext { get; }
|
||||||
|
private readonly byte[] _serialized;
|
||||||
|
|
||||||
|
/// <summary>Builds + signs a SenderKeyMessage. <paramref name="signingPrivateKey"/> is the raw
|
||||||
|
/// 32-byte Curve25519 private signing scalar.</summary>
|
||||||
|
public SenderKeyMessage(int messageVersion, Guid distributionId, uint chainId, uint iteration,
|
||||||
|
byte[] ciphertext, byte[] signingPrivateKey)
|
||||||
|
{
|
||||||
|
MessageVersion = messageVersion;
|
||||||
|
DistributionId = distributionId;
|
||||||
|
ChainId = chainId;
|
||||||
|
Iteration = iteration;
|
||||||
|
Ciphertext = ciphertext;
|
||||||
|
|
||||||
|
var proto = new ProtoWriter();
|
||||||
|
proto.WriteBytes(1, SenderKeyWire.DistributionBytes(distributionId));
|
||||||
|
proto.WriteUInt32(2, chainId);
|
||||||
|
proto.WriteUInt32(3, iteration);
|
||||||
|
proto.WriteBytes(4, ciphertext);
|
||||||
|
byte[] protoBytes = proto.ToArray();
|
||||||
|
|
||||||
|
var signed = new byte[1 + protoBytes.Length];
|
||||||
|
signed[0] = SenderKeyWire.VersionByte(messageVersion);
|
||||||
|
Array.Copy(protoBytes, 0, signed, 1, protoBytes.Length);
|
||||||
|
|
||||||
|
byte[] signature = XEd25519.CalculateSignature(signingPrivateKey, signed, RandomNumberGenerator.GetBytes(64));
|
||||||
|
|
||||||
|
_serialized = new byte[signed.Length + SignatureLen];
|
||||||
|
Array.Copy(signed, 0, _serialized, 0, signed.Length);
|
||||||
|
Array.Copy(signature, 0, _serialized, signed.Length, SignatureLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinct parameter order (serialized first) so this doesn't collide with the signing ctor above.
|
||||||
|
private SenderKeyMessage(byte[] serialized, int version, Guid id, uint chainId, uint iteration, byte[] ciphertext)
|
||||||
|
{
|
||||||
|
_serialized = serialized;
|
||||||
|
MessageVersion = version;
|
||||||
|
DistributionId = id;
|
||||||
|
ChainId = chainId;
|
||||||
|
Iteration = iteration;
|
||||||
|
Ciphertext = ciphertext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] Serialize() => _serialized;
|
||||||
|
|
||||||
|
public static SenderKeyMessage Parse(byte[] serialized)
|
||||||
|
{
|
||||||
|
if (serialized.Length < 1 + SignatureLen)
|
||||||
|
throw new InvalidMessageException("SenderKeyMessage too short");
|
||||||
|
|
||||||
|
int version = (serialized[0] >> 4) & 0xF;
|
||||||
|
var reader = new ProtoReader(serialized.AsSpan(1, serialized.Length - 1 - SignatureLen));
|
||||||
|
|
||||||
|
byte[]? distribution = null, ciphertext = null;
|
||||||
|
uint chainId = 0, iteration = 0;
|
||||||
|
while (reader.TryReadTag(out int field, out int wireType))
|
||||||
|
{
|
||||||
|
switch (field)
|
||||||
|
{
|
||||||
|
case 1: distribution = reader.ReadBytes(); break;
|
||||||
|
case 2: chainId = reader.ReadUInt32(); break;
|
||||||
|
case 3: iteration = reader.ReadUInt32(); break;
|
||||||
|
case 4: ciphertext = reader.ReadBytes(); break;
|
||||||
|
default: reader.SkipField(wireType); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (distribution is null || ciphertext is null)
|
||||||
|
throw new InvalidMessageException("incomplete SenderKeyMessage");
|
||||||
|
|
||||||
|
return new SenderKeyMessage(serialized, version, SenderKeyWire.DistributionId(distribution),
|
||||||
|
chainId, iteration, ciphertext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies the XEdDSA signature against the signer's public key (raw 32-byte Montgomery).</summary>
|
||||||
|
public bool VerifySignature(byte[] signingPublicKey)
|
||||||
|
{
|
||||||
|
int splitAt = _serialized.Length - SignatureLen;
|
||||||
|
return XEd25519.VerifySignature(
|
||||||
|
signingPublicKey,
|
||||||
|
_serialized.AsSpan(0, splitAt),
|
||||||
|
_serialized.AsSpan(splitAt, SignatureLen));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A SenderKeyDistributionMessage (SKDM): <c>version || protobuf(distribution_uuid, chain_id,
|
||||||
|
/// iteration, chain_key[32], signing_key[33])</c>. Sent (1:1, sealed) to each group member so they can
|
||||||
|
/// build a receiving sender-key state. No signature of its own — the included signing public key
|
||||||
|
/// authenticates subsequent SenderKeyMessages.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SenderKeyDistributionMessage
|
||||||
|
{
|
||||||
|
public int MessageVersion { get; }
|
||||||
|
public Guid DistributionId { get; }
|
||||||
|
public uint ChainId { get; }
|
||||||
|
public uint Iteration { get; }
|
||||||
|
public byte[] ChainKey { get; } // 32-byte chain key seed
|
||||||
|
public byte[] SigningKeyPublic { get; } // raw 32-byte Montgomery public
|
||||||
|
private readonly byte[] _serialized;
|
||||||
|
|
||||||
|
public SenderKeyDistributionMessage(int messageVersion, Guid distributionId, uint chainId,
|
||||||
|
uint iteration, byte[] chainKey, byte[] signingKeyPublic)
|
||||||
|
{
|
||||||
|
MessageVersion = messageVersion;
|
||||||
|
DistributionId = distributionId;
|
||||||
|
ChainId = chainId;
|
||||||
|
Iteration = iteration;
|
||||||
|
ChainKey = chainKey;
|
||||||
|
SigningKeyPublic = signingKeyPublic;
|
||||||
|
|
||||||
|
var proto = new ProtoWriter();
|
||||||
|
proto.WriteBytes(1, SenderKeyWire.DistributionBytes(distributionId));
|
||||||
|
proto.WriteUInt32(2, chainId);
|
||||||
|
proto.WriteUInt32(3, iteration);
|
||||||
|
proto.WriteBytes(4, chainKey);
|
||||||
|
proto.WriteBytes(5, Curve25519.EncodePoint(signingKeyPublic));
|
||||||
|
byte[] protoBytes = proto.ToArray();
|
||||||
|
|
||||||
|
_serialized = new byte[1 + protoBytes.Length];
|
||||||
|
_serialized[0] = SenderKeyWire.VersionByte(messageVersion);
|
||||||
|
Array.Copy(protoBytes, 0, _serialized, 1, protoBytes.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SenderKeyDistributionMessage(int version, Guid id, uint chainId, uint iteration,
|
||||||
|
byte[] chainKey, byte[] signingPublic, byte[] serialized)
|
||||||
|
{
|
||||||
|
MessageVersion = version;
|
||||||
|
DistributionId = id;
|
||||||
|
ChainId = chainId;
|
||||||
|
Iteration = iteration;
|
||||||
|
ChainKey = chainKey;
|
||||||
|
SigningKeyPublic = signingPublic;
|
||||||
|
_serialized = serialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] Serialize() => _serialized;
|
||||||
|
|
||||||
|
public static SenderKeyDistributionMessage Parse(byte[] serialized)
|
||||||
|
{
|
||||||
|
if (serialized.Length < 1) throw new InvalidMessageException("SKDM too short");
|
||||||
|
|
||||||
|
int version = (serialized[0] >> 4) & 0xF;
|
||||||
|
var reader = new ProtoReader(serialized.AsSpan(1));
|
||||||
|
|
||||||
|
byte[]? distribution = null, chainKey = null, signingKey = null;
|
||||||
|
uint chainId = 0, iteration = 0;
|
||||||
|
while (reader.TryReadTag(out int field, out int wireType))
|
||||||
|
{
|
||||||
|
switch (field)
|
||||||
|
{
|
||||||
|
case 1: distribution = reader.ReadBytes(); break;
|
||||||
|
case 2: chainId = reader.ReadUInt32(); break;
|
||||||
|
case 3: iteration = reader.ReadUInt32(); break;
|
||||||
|
case 4: chainKey = reader.ReadBytes(); break;
|
||||||
|
case 5: signingKey = Curve25519.DecodePoint(reader.ReadBytes()); break;
|
||||||
|
default: reader.SkipField(wireType); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (distribution is null || chainKey is null || signingKey is null)
|
||||||
|
throw new InvalidMessageException("incomplete SenderKeyDistributionMessage");
|
||||||
|
|
||||||
|
return new SenderKeyDistributionMessage(version, SenderKeyWire.DistributionId(distribution),
|
||||||
|
chainId, iteration, chainKey, signingKey, serialized);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.IO;
|
||||||
|
using Wingnal.Protocol.Messages;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Groups;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// All sender-key states for one (sender, distribution-id). The most-recently-added state is current
|
||||||
|
/// (used for encrypting / the latest received chain); older states are kept (bounded) so in-flight
|
||||||
|
/// messages under a superseded chain still decrypt. Mirrors libsignal's SenderKeyRecord.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SenderKeyRecord
|
||||||
|
{
|
||||||
|
/// <summary>libsignal <c>consts::MAX_SENDER_KEY_STATES</c>.</summary>
|
||||||
|
public const int MaxStates = 5;
|
||||||
|
|
||||||
|
private readonly List<SenderKeyState> _states = new(); // index 0 = current
|
||||||
|
|
||||||
|
public bool IsEmpty => _states.Count == 0;
|
||||||
|
|
||||||
|
/// <summary>The current (most recent) state.</summary>
|
||||||
|
public SenderKeyState State =>
|
||||||
|
_states.Count > 0 ? _states[0] : throw new InvalidMessageException("no sender key state");
|
||||||
|
|
||||||
|
/// <summary>The state for a specific chain id (a received message names its chain), or null.</summary>
|
||||||
|
public SenderKeyState? StateForChainId(uint chainId) =>
|
||||||
|
_states.Find(s => s.ChainId == chainId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Installs a sender-key state (from our own keygen, or from a processed SKDM). Idempotent for a
|
||||||
|
/// repeated SKDM: if a state with the same chain id and signing key already exists it's left
|
||||||
|
/// untouched (so re-processing the same distribution message doesn't rewind the chain).
|
||||||
|
/// </summary>
|
||||||
|
public void AddState(uint chainId, int messageVersion, uint iteration, byte[] chainKeySeed,
|
||||||
|
byte[] signingKeyPublic, byte[]? signingKeyPrivate)
|
||||||
|
{
|
||||||
|
SenderKeyState? existing = _states.Find(s => s.ChainId == chainId);
|
||||||
|
if (existing is not null && existing.SigningKeyPublic.AsSpan().SequenceEqual(signingKeyPublic))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_states.RemoveAll(s => s.ChainId == chainId);
|
||||||
|
_states.Insert(0, new SenderKeyState(chainId, messageVersion, iteration, chainKeySeed,
|
||||||
|
signingKeyPublic, signingKeyPrivate));
|
||||||
|
while (_states.Count > MaxStates)
|
||||||
|
_states.RemoveAt(_states.Count - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── durable persistence (local-only binary; never sent to a peer) ──
|
||||||
|
|
||||||
|
/// <summary>Serializes every state (index 0 = current) for durable storage.</summary>
|
||||||
|
public byte[] Serialize()
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
using var w = new BinaryWriter(ms);
|
||||||
|
w.Write(_states.Count);
|
||||||
|
foreach (SenderKeyState s in _states) s.Write(w);
|
||||||
|
w.Flush();
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SenderKeyRecord Deserialize(byte[] bytes)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream(bytes);
|
||||||
|
using var r = new BinaryReader(ms);
|
||||||
|
var record = new SenderKeyRecord();
|
||||||
|
int n = r.ReadInt32();
|
||||||
|
for (int i = 0; i < n; i++) record._states.Add(SenderKeyState.Read(r));
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using Wingnal.Protocol.Crypto;
|
||||||
|
using Wingnal.Protocol.Spqr; // Bin.WriteBlob/ReadBlob length-prefixed helpers
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Groups;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A per-message symmetric key derived from a sender chain. Mirrors libsignal's
|
||||||
|
/// <c>SenderMessageKey</c>: HKDF-SHA256 expand of the chain's <c>0x01</c> derivative with info
|
||||||
|
/// "WhisperGroup" to 48 bytes = iv[16] || cipherKey[32].
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SenderMessageKey
|
||||||
|
{
|
||||||
|
private static readonly byte[] Info = Encoding.UTF8.GetBytes("WhisperGroup");
|
||||||
|
|
||||||
|
public uint Iteration { get; }
|
||||||
|
public byte[] Seed { get; } // the 32-byte 0x01-derivative (persisted form)
|
||||||
|
public byte[] Iv { get; } // 16
|
||||||
|
public byte[] CipherKey { get; } // 32
|
||||||
|
|
||||||
|
public SenderMessageKey(uint iteration, byte[] seed)
|
||||||
|
{
|
||||||
|
Iteration = iteration;
|
||||||
|
Seed = seed;
|
||||||
|
byte[] derived = CryptoPrimitives.Hkdf(seed, salt: null, Info, 48);
|
||||||
|
Iv = derived.AsSpan(0, 16).ToArray();
|
||||||
|
CipherKey = derived.AsSpan(16, 32).ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A sender chain key. <c>messageKey = HMAC-SHA256(chainKey, 0x01)</c>; the next chain key is
|
||||||
|
/// <c>HMAC-SHA256(chainKey, 0x02)</c>. Identical construction to the 1:1 <c>ChainKey</c>, but the
|
||||||
|
/// message-key seed is expanded with the group info string.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SenderChainKey
|
||||||
|
{
|
||||||
|
private static readonly byte[] MessageKeySeed = { 0x01 };
|
||||||
|
private static readonly byte[] ChainKeySeed = { 0x02 };
|
||||||
|
|
||||||
|
public uint Iteration { get; }
|
||||||
|
public byte[] Seed { get; }
|
||||||
|
|
||||||
|
public SenderChainKey(uint iteration, byte[] seed)
|
||||||
|
{
|
||||||
|
Iteration = iteration;
|
||||||
|
Seed = seed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SenderMessageKey MessageKey() =>
|
||||||
|
new(Iteration, CryptoPrimitives.HmacSha256(Seed, MessageKeySeed));
|
||||||
|
|
||||||
|
public SenderChainKey Next() =>
|
||||||
|
new(Iteration + 1, CryptoPrimitives.HmacSha256(Seed, ChainKeySeed));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One sender-key state for a (sender, distribution-id) chain: the symmetric chain, the signing key
|
||||||
|
/// pair (private present only for our own outgoing chain), the chain id, message version, and a
|
||||||
|
/// bounded FIFO cache of skipped/out-of-order message keys. Mirrors libsignal's SenderKeyState.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SenderKeyState
|
||||||
|
{
|
||||||
|
/// <summary>libsignal <c>consts::MAX_MESSAGE_KEYS</c> — bound on the skipped-key cache.</summary>
|
||||||
|
public const int MaxMessageKeys = 2000;
|
||||||
|
|
||||||
|
public uint ChainId { get; }
|
||||||
|
public int MessageVersion { get; }
|
||||||
|
public byte[] SigningKeyPublic { get; } // raw 32-byte Montgomery
|
||||||
|
public byte[]? SigningKeyPrivate { get; } // raw 32 (null for receive-only state)
|
||||||
|
public SenderChainKey ChainKey { get; set; }
|
||||||
|
|
||||||
|
private readonly List<SenderMessageKey> _messageKeys = new();
|
||||||
|
|
||||||
|
public SenderKeyState(uint chainId, int messageVersion, uint iteration, byte[] chainKeySeed,
|
||||||
|
byte[] signingKeyPublic, byte[]? signingKeyPrivate)
|
||||||
|
{
|
||||||
|
ChainId = chainId;
|
||||||
|
MessageVersion = messageVersion;
|
||||||
|
SigningKeyPublic = signingKeyPublic;
|
||||||
|
SigningKeyPrivate = signingKeyPrivate;
|
||||||
|
ChainKey = new SenderChainKey(iteration, chainKeySeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddMessageKey(SenderMessageKey key)
|
||||||
|
{
|
||||||
|
_messageKeys.Add(key);
|
||||||
|
while (_messageKeys.Count > MaxMessageKeys)
|
||||||
|
_messageKeys.RemoveAt(0); // FIFO eviction (oldest first), matching libsignal
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes and returns the cached key for <paramref name="iteration"/>, or null if absent
|
||||||
|
/// (already used / never skipped).</summary>
|
||||||
|
public SenderMessageKey? RemoveMessageKey(uint iteration)
|
||||||
|
{
|
||||||
|
int idx = _messageKeys.FindIndex(k => k.Iteration == iteration);
|
||||||
|
if (idx < 0) return null;
|
||||||
|
SenderMessageKey key = _messageKeys[idx];
|
||||||
|
_messageKeys.RemoveAt(idx);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── durable persistence (local-only binary; never sent to a peer) ──
|
||||||
|
|
||||||
|
internal void Write(BinaryWriter w)
|
||||||
|
{
|
||||||
|
w.Write(ChainId);
|
||||||
|
w.Write(MessageVersion);
|
||||||
|
w.WriteBlob(SigningKeyPublic);
|
||||||
|
w.Write(SigningKeyPrivate is not null);
|
||||||
|
if (SigningKeyPrivate is not null) w.WriteBlob(SigningKeyPrivate);
|
||||||
|
w.Write(ChainKey.Iteration);
|
||||||
|
w.WriteBlob(ChainKey.Seed);
|
||||||
|
w.Write(_messageKeys.Count);
|
||||||
|
foreach (SenderMessageKey k in _messageKeys) { w.Write(k.Iteration); w.WriteBlob(k.Seed); }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static SenderKeyState Read(BinaryReader r)
|
||||||
|
{
|
||||||
|
uint chainId = r.ReadUInt32();
|
||||||
|
int messageVersion = r.ReadInt32();
|
||||||
|
byte[] signingPublic = r.ReadBlob();
|
||||||
|
byte[]? signingPrivate = r.ReadBoolean() ? r.ReadBlob() : null;
|
||||||
|
uint iteration = r.ReadUInt32();
|
||||||
|
byte[] chainSeed = r.ReadBlob();
|
||||||
|
var state = new SenderKeyState(chainId, messageVersion, iteration, chainSeed, signingPublic, signingPrivate);
|
||||||
|
int n = r.ReadInt32();
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
state.AddMessageKey(new SenderMessageKey(r.ReadUInt32(), r.ReadBlob()));
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Wingnal.Protocol.State;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Groups;
|
||||||
|
|
||||||
|
/// <summary>Persists sender-key records, keyed by (sender address, distribution id). Mirrors
|
||||||
|
/// libsignal's SenderKeyStore.</summary>
|
||||||
|
public interface ISenderKeyStore
|
||||||
|
{
|
||||||
|
void StoreSenderKey(SignalProtocolAddress sender, Guid distributionId, SenderKeyRecord record);
|
||||||
|
SenderKeyRecord? LoadSenderKey(SignalProtocolAddress sender, Guid distributionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>In-memory <see cref="ISenderKeyStore"/> for tests and the group crypto core.</summary>
|
||||||
|
public sealed class InMemorySenderKeyStore : ISenderKeyStore
|
||||||
|
{
|
||||||
|
private readonly Dictionary<(SignalProtocolAddress, Guid), SenderKeyRecord> _store = new();
|
||||||
|
|
||||||
|
public void StoreSenderKey(SignalProtocolAddress sender, Guid distributionId, SenderKeyRecord record) =>
|
||||||
|
_store[(sender, distributionId)] = record;
|
||||||
|
|
||||||
|
public SenderKeyRecord? LoadSenderKey(SignalProtocolAddress sender, Guid distributionId) =>
|
||||||
|
_store.TryGetValue((sender, distributionId), out SenderKeyRecord? r) ? r : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Wingnal.Protocol.State;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Identity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes Signal's numeric "safety number" (a.k.a. fingerprint) for two identity keys, so a user can
|
||||||
|
/// verify out-of-band that they have the right keys for a contact — and detect a man-in-the-middle.
|
||||||
|
/// Byte-exact with libsignal v0.96.1 (rust/protocol/src/fingerprint.rs): per party, iterate
|
||||||
|
/// <c>SHA-512(prevHash ‖ key)</c> 5200× starting from <c>SHA-512(0x0000 ‖ key ‖ stableId ‖ key)</c>,
|
||||||
|
/// take 30 bytes as six 5-byte big-endian chunks mod 100000 (→ 30 digits), then concatenate the two
|
||||||
|
/// parties' halves in sorted order (so both sides see the same 60-digit number).
|
||||||
|
///
|
||||||
|
/// The modern (ACI-based) safety number uses version 2 and each party's 16-byte ACI UUID as the stable
|
||||||
|
/// identifier — matching what the official Signal app shows for the same contact.
|
||||||
|
/// </summary>
|
||||||
|
public static class SafetyNumber
|
||||||
|
{
|
||||||
|
public const int DefaultIterations = 5200;
|
||||||
|
|
||||||
|
/// <summary>The 60-digit safety number for the two parties (order-independent).</summary>
|
||||||
|
public static string Generate(byte[] localStableId, IdentityKey localKey,
|
||||||
|
byte[] remoteStableId, IdentityKey remoteKey, int iterations = DefaultIterations)
|
||||||
|
{
|
||||||
|
string local = Encode(GetFingerprint(iterations, localStableId, localKey));
|
||||||
|
string remote = Encode(GetFingerprint(iterations, remoteStableId, remoteKey));
|
||||||
|
// Sorted concatenation makes both participants compute the identical string.
|
||||||
|
return string.CompareOrdinal(local, remote) <= 0 ? local + remote : remote + local;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Convenience for the ACI-based safety number: pass each party's ACI UUID.</summary>
|
||||||
|
public static string GenerateForAci(Guid localAci, IdentityKey localKey, Guid remoteAci, IdentityKey remoteKey) =>
|
||||||
|
Generate(UuidBytes(localAci), localKey, UuidBytes(remoteAci), remoteKey);
|
||||||
|
|
||||||
|
/// <summary>Groups the 60 digits into the usual 12 blocks of 5 for display.</summary>
|
||||||
|
public static string FormatForDisplay(string digits)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder(digits.Length + digits.Length / 5);
|
||||||
|
for (int i = 0; i < digits.Length; i += 5)
|
||||||
|
{
|
||||||
|
if (i > 0) sb.Append(i % 25 == 0 ? '\n' : ' ');
|
||||||
|
sb.Append(digits.AsSpan(i, Math.Min(5, digits.Length - i)));
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] GetFingerprint(int iterations, byte[] stableId, IdentityKey key)
|
||||||
|
{
|
||||||
|
if (iterations <= 1) throw new ArgumentOutOfRangeException(nameof(iterations));
|
||||||
|
byte[] keyBytes = key.Serialize(); // 33-byte DjbECPublicKey
|
||||||
|
|
||||||
|
// Iteration 0: SHA-512( 0x0000 ‖ key ‖ stableId ‖ key ).
|
||||||
|
byte[] buf = SHA512.HashData(Concat(new byte[] { 0, 0 }, keyBytes, stableId, keyBytes));
|
||||||
|
for (int i = 1; i < iterations; i++)
|
||||||
|
buf = SHA512.HashData(Concat(buf, keyBytes));
|
||||||
|
return buf; // 64 bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Encode(byte[] fingerprint)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder(30);
|
||||||
|
for (int chunk = 0; chunk < 6; chunk++)
|
||||||
|
{
|
||||||
|
ulong x = 0;
|
||||||
|
for (int i = 0; i < 5; i++)
|
||||||
|
x = (x << 8) | fingerprint[chunk * 5 + i];
|
||||||
|
sb.Append((x % 100000).ToString("D5"));
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A UUID's 16 bytes in RFC 4122 / big-endian order (the ACI service-id bytes).</summary>
|
||||||
|
public static byte[] UuidBytes(Guid id) => id.ToByteArray(bigEndian: true);
|
||||||
|
|
||||||
|
private static byte[] Concat(params byte[][] parts)
|
||||||
|
{
|
||||||
|
var result = new byte[parts.Sum(p => p.Length)];
|
||||||
|
int o = 0;
|
||||||
|
foreach (byte[] p in parts) { Buffer.BlockCopy(p, 0, result, o, p.Length); o += p.Length; }
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Wingnal.Protocol.Crypto;
|
||||||
|
using Wingnal.Protocol.Curve;
|
||||||
|
using Wingnal.Protocol.State;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Messages;
|
||||||
|
|
||||||
|
/// <summary>Thrown when a ciphertext is malformed or fails authentication.</summary>
|
||||||
|
public sealed class InvalidMessageException : Exception
|
||||||
|
{
|
||||||
|
public InvalidMessageException(string message) : base(message) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Thrown when a message key has already been used (duplicate / replayed message).</summary>
|
||||||
|
public sealed class DuplicateMessageException : Exception
|
||||||
|
{
|
||||||
|
public DuplicateMessageException(string message) : base(message) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when a peer presents an identity key that differs from the one we previously trusted (a
|
||||||
|
/// possible man-in-the-middle, or a legitimate reinstall). The session is NOT established until the
|
||||||
|
/// user verifies the new safety number and approves it.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UntrustedIdentityException : Exception
|
||||||
|
{
|
||||||
|
public State.SignalProtocolAddress Address { get; }
|
||||||
|
public State.IdentityKey Identity { get; }
|
||||||
|
|
||||||
|
public UntrustedIdentityException(State.SignalProtocolAddress address, State.IdentityKey identity)
|
||||||
|
: base($"untrusted identity for {address.Name}.{address.DeviceId}")
|
||||||
|
{
|
||||||
|
Address = address;
|
||||||
|
Identity = identity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CiphertextMessageType
|
||||||
|
{
|
||||||
|
Whisper = 2,
|
||||||
|
PreKey = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ICiphertextMessage
|
||||||
|
{
|
||||||
|
CiphertextMessageType Type { get; }
|
||||||
|
byte[] Serialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A Double Ratchet message ("WhisperMessage"): version || protobuf(ratchetKey, counter,
|
||||||
|
/// previousCounter, ciphertext) || MAC[8]. The MAC is HMAC-SHA256 over sender||receiver identity
|
||||||
|
/// keys and (version||protobuf), truncated to 8 bytes.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SignalMessage : ICiphertextMessage
|
||||||
|
{
|
||||||
|
private const int MacLength = 8;
|
||||||
|
|
||||||
|
public int MessageVersion { get; }
|
||||||
|
public byte[] SenderRatchetKey { get; } // raw 32-byte Montgomery key
|
||||||
|
public uint Counter { get; }
|
||||||
|
public uint PreviousCounter { get; }
|
||||||
|
public byte[] Body { get; } // ciphertext
|
||||||
|
public byte[]? PqRatchet { get; } // SPQR field 5 (null/empty when SPQR disabled)
|
||||||
|
private readonly byte[] _serialized;
|
||||||
|
|
||||||
|
public CiphertextMessageType Type => CiphertextMessageType.Whisper;
|
||||||
|
|
||||||
|
public SignalMessage(int messageVersion, byte[] macKey, byte[] senderRatchetKey, uint counter,
|
||||||
|
uint previousCounter, byte[] ciphertext, IdentityKey senderIdentity, IdentityKey receiverIdentity,
|
||||||
|
byte[]? pqRatchet = null)
|
||||||
|
{
|
||||||
|
MessageVersion = messageVersion;
|
||||||
|
SenderRatchetKey = senderRatchetKey;
|
||||||
|
Counter = counter;
|
||||||
|
PreviousCounter = previousCounter;
|
||||||
|
Body = ciphertext;
|
||||||
|
PqRatchet = pqRatchet is { Length: > 0 } ? pqRatchet : null;
|
||||||
|
|
||||||
|
var proto = new ProtoWriter();
|
||||||
|
proto.WriteBytes(1, Curve25519.EncodePoint(senderRatchetKey));
|
||||||
|
proto.WriteUInt32(2, counter);
|
||||||
|
proto.WriteUInt32(3, previousCounter);
|
||||||
|
proto.WriteBytes(4, ciphertext);
|
||||||
|
if (PqRatchet is not null) proto.WriteBytes(5, PqRatchet);
|
||||||
|
byte[] protoBytes = proto.ToArray();
|
||||||
|
|
||||||
|
byte version = (byte)((messageVersion << 4) | messageVersion);
|
||||||
|
var message = new byte[1 + protoBytes.Length];
|
||||||
|
message[0] = version;
|
||||||
|
Array.Copy(protoBytes, 0, message, 1, protoBytes.Length);
|
||||||
|
|
||||||
|
byte[] mac = GetMac(senderIdentity, receiverIdentity, macKey, message);
|
||||||
|
_serialized = new byte[message.Length + MacLength];
|
||||||
|
Array.Copy(message, 0, _serialized, 0, message.Length);
|
||||||
|
Array.Copy(mac, 0, _serialized, message.Length, MacLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SignalMessage(int version, byte[] senderRatchetKey, uint counter, uint previousCounter,
|
||||||
|
byte[] body, byte[]? pqRatchet, byte[] serialized)
|
||||||
|
{
|
||||||
|
MessageVersion = version;
|
||||||
|
SenderRatchetKey = senderRatchetKey;
|
||||||
|
Counter = counter;
|
||||||
|
PreviousCounter = previousCounter;
|
||||||
|
Body = body;
|
||||||
|
PqRatchet = pqRatchet is { Length: > 0 } ? pqRatchet : null;
|
||||||
|
_serialized = serialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] Serialize() => _serialized;
|
||||||
|
|
||||||
|
public static SignalMessage Parse(byte[] serialized)
|
||||||
|
{
|
||||||
|
if (serialized.Length < 1 + MacLength)
|
||||||
|
throw new InvalidMessageException("message too short");
|
||||||
|
|
||||||
|
int version = (serialized[0] >> 4) & 0xF;
|
||||||
|
var reader = new ProtoReader(serialized.AsSpan(1, serialized.Length - 1 - MacLength));
|
||||||
|
|
||||||
|
byte[]? ratchetKey = null;
|
||||||
|
uint counter = 0, previousCounter = 0;
|
||||||
|
byte[]? body = null, pqRatchet = null;
|
||||||
|
while (reader.TryReadTag(out int field, out int wireType))
|
||||||
|
{
|
||||||
|
switch (field)
|
||||||
|
{
|
||||||
|
case 1: ratchetKey = Curve25519.DecodePoint(reader.ReadBytes()); break;
|
||||||
|
case 2: counter = reader.ReadUInt32(); break;
|
||||||
|
case 3: previousCounter = reader.ReadUInt32(); break;
|
||||||
|
case 4: body = reader.ReadBytes(); break;
|
||||||
|
case 5: pqRatchet = reader.ReadBytes(); break;
|
||||||
|
default: reader.SkipField(wireType); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ratchetKey is null || body is null)
|
||||||
|
throw new InvalidMessageException("incomplete SignalMessage");
|
||||||
|
|
||||||
|
return new SignalMessage(version, ratchetKey, counter, previousCounter, body, pqRatchet, serialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool VerifyMac(IdentityKey senderIdentity, IdentityKey receiverIdentity, byte[] macKey)
|
||||||
|
{
|
||||||
|
int splitAt = _serialized.Length - MacLength;
|
||||||
|
byte[] theirMac = _serialized.AsSpan(splitAt).ToArray();
|
||||||
|
byte[] ourMac = GetMac(senderIdentity, receiverIdentity, macKey, _serialized.AsSpan(0, splitAt).ToArray());
|
||||||
|
return CryptographicOperations.FixedTimeEquals(theirMac, ourMac);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] GetMac(IdentityKey sender, IdentityKey receiver, byte[] macKey, byte[] message)
|
||||||
|
{
|
||||||
|
using var hmac = new HMACSHA256(macKey);
|
||||||
|
hmac.TransformBlock(sender.Serialize(), 0, 33, null, 0);
|
||||||
|
hmac.TransformBlock(receiver.Serialize(), 0, 33, null, 0);
|
||||||
|
hmac.TransformFinalBlock(message, 0, message.Length);
|
||||||
|
return hmac.Hash!.AsSpan(0, MacLength).ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A PreKeySignalMessage: carries the X3DH/PQXDH session-setup material (which prekeys the sender
|
||||||
|
/// used, its base/identity keys, the optional Kyber ciphertext) wrapping an inner SignalMessage.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PreKeySignalMessage : ICiphertextMessage
|
||||||
|
{
|
||||||
|
public int MessageVersion { get; }
|
||||||
|
public uint RegistrationId { get; }
|
||||||
|
public uint? PreKeyId { get; }
|
||||||
|
public uint SignedPreKeyId { get; }
|
||||||
|
public uint? KyberPreKeyId { get; }
|
||||||
|
public byte[]? KyberCiphertext { get; }
|
||||||
|
public byte[] BaseKey { get; } // raw 32
|
||||||
|
public IdentityKey IdentityKey { get; }
|
||||||
|
public SignalMessage Message { get; }
|
||||||
|
private readonly byte[] _serialized;
|
||||||
|
|
||||||
|
public CiphertextMessageType Type => CiphertextMessageType.PreKey;
|
||||||
|
|
||||||
|
public PreKeySignalMessage(int messageVersion, uint registrationId, uint? preKeyId, uint signedPreKeyId,
|
||||||
|
uint? kyberPreKeyId, byte[]? kyberCiphertext, byte[] baseKey, IdentityKey identityKey, SignalMessage message)
|
||||||
|
{
|
||||||
|
MessageVersion = messageVersion;
|
||||||
|
RegistrationId = registrationId;
|
||||||
|
PreKeyId = preKeyId;
|
||||||
|
SignedPreKeyId = signedPreKeyId;
|
||||||
|
KyberPreKeyId = kyberPreKeyId;
|
||||||
|
KyberCiphertext = kyberCiphertext;
|
||||||
|
BaseKey = baseKey;
|
||||||
|
IdentityKey = identityKey;
|
||||||
|
Message = message;
|
||||||
|
|
||||||
|
var proto = new ProtoWriter();
|
||||||
|
proto.WriteUInt32(5, registrationId);
|
||||||
|
if (preKeyId.HasValue) proto.WriteUInt32(1, preKeyId.Value);
|
||||||
|
proto.WriteUInt32(6, signedPreKeyId);
|
||||||
|
if (kyberPreKeyId.HasValue) proto.WriteUInt32(7, kyberPreKeyId.Value);
|
||||||
|
if (kyberCiphertext is not null) proto.WriteBytes(8, kyberCiphertext);
|
||||||
|
proto.WriteBytes(2, Curve25519.EncodePoint(baseKey));
|
||||||
|
proto.WriteBytes(3, identityKey.Serialize());
|
||||||
|
proto.WriteBytes(4, message.Serialize());
|
||||||
|
byte[] protoBytes = proto.ToArray();
|
||||||
|
|
||||||
|
byte version = (byte)((messageVersion << 4) | messageVersion);
|
||||||
|
_serialized = new byte[1 + protoBytes.Length];
|
||||||
|
_serialized[0] = version;
|
||||||
|
Array.Copy(protoBytes, 0, _serialized, 1, protoBytes.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] Serialize() => _serialized;
|
||||||
|
|
||||||
|
public static PreKeySignalMessage Parse(byte[] serialized)
|
||||||
|
{
|
||||||
|
if (serialized.Length < 1) throw new InvalidMessageException("message too short");
|
||||||
|
|
||||||
|
int version = (serialized[0] >> 4) & 0xF;
|
||||||
|
var reader = new ProtoReader(serialized.AsSpan(1));
|
||||||
|
|
||||||
|
uint registrationId = 0, signedPreKeyId = 0;
|
||||||
|
uint? preKeyId = null, kyberPreKeyId = null;
|
||||||
|
byte[]? kyberCiphertext = null, baseKey = null, identityKey = null, message = null;
|
||||||
|
while (reader.TryReadTag(out int field, out int wireType))
|
||||||
|
{
|
||||||
|
switch (field)
|
||||||
|
{
|
||||||
|
case 5: registrationId = reader.ReadUInt32(); break;
|
||||||
|
case 1: preKeyId = reader.ReadUInt32(); break;
|
||||||
|
case 6: signedPreKeyId = reader.ReadUInt32(); break;
|
||||||
|
case 7: kyberPreKeyId = reader.ReadUInt32(); break;
|
||||||
|
case 8: kyberCiphertext = reader.ReadBytes(); break;
|
||||||
|
case 2: baseKey = Curve25519.DecodePoint(reader.ReadBytes()); break;
|
||||||
|
case 3: identityKey = reader.ReadBytes(); break;
|
||||||
|
case 4: message = reader.ReadBytes(); break;
|
||||||
|
default: reader.SkipField(wireType); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseKey is null || identityKey is null || message is null)
|
||||||
|
throw new InvalidMessageException("incomplete PreKeySignalMessage");
|
||||||
|
|
||||||
|
return new PreKeySignalMessage(version, registrationId, preKeyId, signedPreKeyId, kyberPreKeyId,
|
||||||
|
kyberCiphertext, baseKey, State.IdentityKey.Decode(identityKey), SignalMessage.Parse(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
namespace Wingnal.Protocol.Messages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal hand-written protobuf wire encoder/decoder. The Signal ciphertext messages are tiny and
|
||||||
|
/// live in the Protocol layer (which intentionally has no protobuf-compiler dependency — that is
|
||||||
|
/// reserved for the Service layer), so we encode them directly. Wire types: 0 = varint, 2 = length.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ProtoWriter
|
||||||
|
{
|
||||||
|
private readonly List<byte> _buf = new();
|
||||||
|
|
||||||
|
public void WriteUInt32(int field, uint value)
|
||||||
|
{
|
||||||
|
WriteTag(field, 0);
|
||||||
|
WriteVarint(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void WriteBytes(int field, byte[] value)
|
||||||
|
{
|
||||||
|
WriteTag(field, 2);
|
||||||
|
WriteVarint((ulong)value.Length);
|
||||||
|
_buf.AddRange(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] ToArray() => _buf.ToArray();
|
||||||
|
|
||||||
|
private void WriteTag(int field, int wireType) => WriteVarint(((ulong)field << 3) | (uint)wireType);
|
||||||
|
|
||||||
|
private void WriteVarint(ulong v)
|
||||||
|
{
|
||||||
|
while (v >= 0x80)
|
||||||
|
{
|
||||||
|
_buf.Add((byte)(v | 0x80));
|
||||||
|
v >>= 7;
|
||||||
|
}
|
||||||
|
_buf.Add((byte)v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal ref struct ProtoReader
|
||||||
|
{
|
||||||
|
private readonly ReadOnlySpan<byte> _data;
|
||||||
|
private int _pos;
|
||||||
|
|
||||||
|
public ProtoReader(ReadOnlySpan<byte> data)
|
||||||
|
{
|
||||||
|
_data = data;
|
||||||
|
_pos = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryReadTag(out int field, out int wireType)
|
||||||
|
{
|
||||||
|
if (_pos >= _data.Length)
|
||||||
|
{
|
||||||
|
field = 0;
|
||||||
|
wireType = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ulong tag = ReadVarint();
|
||||||
|
field = (int)(tag >> 3);
|
||||||
|
wireType = (int)(tag & 0x7);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public uint ReadUInt32() => (uint)ReadVarint();
|
||||||
|
|
||||||
|
public byte[] ReadBytes()
|
||||||
|
{
|
||||||
|
int len = (int)ReadVarint();
|
||||||
|
byte[] result = _data.Slice(_pos, len).ToArray();
|
||||||
|
_pos += len;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SkipField(int wireType)
|
||||||
|
{
|
||||||
|
switch (wireType)
|
||||||
|
{
|
||||||
|
case 0: ReadVarint(); break;
|
||||||
|
// Read the length varint first (it advances _pos), then skip that many bytes. Writing
|
||||||
|
// `_pos += (int)ReadVarint()` would add to the pre-ReadVarint _pos and lose the length bytes.
|
||||||
|
case 2: { int len = (int)ReadVarint(); _pos += len; break; }
|
||||||
|
case 5: _pos += 4; break;
|
||||||
|
case 1: _pos += 8; break;
|
||||||
|
default: throw new FormatException($"unsupported wire type {wireType}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ulong ReadVarint()
|
||||||
|
{
|
||||||
|
ulong result = 0;
|
||||||
|
int shift = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
byte b = _data[_pos++];
|
||||||
|
result |= (ulong)(b & 0x7F) << shift;
|
||||||
|
if ((b & 0x80) == 0) break;
|
||||||
|
shift += 7;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Wingnal.Protocol.Crypto;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Ratchet;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A symmetric-ratchet chain key. Each step is HMAC-SHA256(chainKey, 0x02); message keys are
|
||||||
|
/// derived via HMAC-SHA256(chainKey, 0x01) then HKDF "WhisperMessageKeys".
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChainKey
|
||||||
|
{
|
||||||
|
private static readonly byte[] MessageKeySeed = { 0x01 };
|
||||||
|
private static readonly byte[] ChainKeySeed = { 0x02 };
|
||||||
|
private static readonly byte[] MessageKeysInfo = Encoding.UTF8.GetBytes("WhisperMessageKeys");
|
||||||
|
|
||||||
|
public byte[] Key { get; }
|
||||||
|
public uint Index { get; }
|
||||||
|
|
||||||
|
public ChainKey(byte[] key, uint index)
|
||||||
|
{
|
||||||
|
Key = key;
|
||||||
|
Index = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChainKey Next() => new(CryptoPrimitives.HmacSha256(Key, ChainKeySeed), Index + 1);
|
||||||
|
|
||||||
|
/// <summary>The per-message key seed (HMAC(chainKey, 0x01)); the input keying material for
|
||||||
|
/// <see cref="DeriveMessageKeys"/>. Cached for skipped messages so the SPQR salt can be applied
|
||||||
|
/// lazily when the out-of-order message actually arrives.</summary>
|
||||||
|
public byte[] MessageKeySeedBytes => CryptoPrimitives.HmacSha256(Key, MessageKeySeed);
|
||||||
|
|
||||||
|
/// <summary>Derives the AES/HMAC/IV message keys from a seed. <paramref name="pqrSalt"/> is the
|
||||||
|
/// SPQR per-message key used as the HKDF salt (null for classic sessions). Matches libsignal
|
||||||
|
/// <c>MessageKeys::derive_keys(seed, salt=pqr_key, "WhisperMessageKeys")</c>.</summary>
|
||||||
|
public static MessageKeys DeriveMessageKeys(byte[] seed, byte[]? pqrSalt, uint counter)
|
||||||
|
{
|
||||||
|
byte[] material = CryptoPrimitives.Hkdf(seed, salt: pqrSalt, MessageKeysInfo, 80);
|
||||||
|
var cipherKey = material.AsSpan(0, 32).ToArray();
|
||||||
|
var macKey = material.AsSpan(32, 32).ToArray();
|
||||||
|
var iv = material.AsSpan(64, 16).ToArray();
|
||||||
|
return new MessageKeys(cipherKey, macKey, iv, counter);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MessageKeys GetMessageKeys(byte[]? pqrSalt = null) =>
|
||||||
|
DeriveMessageKeys(MessageKeySeedBytes, pqrSalt, Index);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace Wingnal.Protocol.Ratchet;
|
||||||
|
|
||||||
|
/// <summary>The per-message keys derived from a chain key: AES-256 key, HMAC-SHA256 key, and IV.</summary>
|
||||||
|
public sealed class MessageKeys
|
||||||
|
{
|
||||||
|
public byte[] CipherKey { get; } // 32
|
||||||
|
public byte[] MacKey { get; } // 32
|
||||||
|
public byte[] Iv { get; } // 16
|
||||||
|
public uint Counter { get; }
|
||||||
|
|
||||||
|
public MessageKeys(byte[] cipherKey, byte[] macKey, byte[] iv, uint counter)
|
||||||
|
{
|
||||||
|
CipherKey = cipherKey;
|
||||||
|
MacKey = macKey;
|
||||||
|
Iv = iv;
|
||||||
|
Counter = counter;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Wingnal.Protocol.Crypto;
|
||||||
|
using Wingnal.Protocol.Curve;
|
||||||
|
using Wingnal.Protocol.Spqr;
|
||||||
|
using Wingnal.Protocol.State;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Ratchet;
|
||||||
|
|
||||||
|
/// <summary>Initiator (Alice) X3DH/PQXDH inputs.</summary>
|
||||||
|
public sealed class AliceParameters
|
||||||
|
{
|
||||||
|
public required IdentityKeyPair OurIdentityKey { get; init; }
|
||||||
|
public required ECKeyPair OurBaseKey { get; init; }
|
||||||
|
public required IdentityKey TheirIdentityKey { get; init; }
|
||||||
|
public required byte[] TheirSignedPreKey { get; init; } // raw 32
|
||||||
|
public required byte[] TheirRatchetKey { get; init; } // raw 32 (== signed prekey)
|
||||||
|
public byte[]? TheirOneTimePreKey { get; init; } // raw 32
|
||||||
|
public byte[]? KyberSharedSecret { get; init; } // from encapsulation (PQXDH)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Responder (Bob) X3DH/PQXDH inputs.</summary>
|
||||||
|
public sealed class BobParameters
|
||||||
|
{
|
||||||
|
public required IdentityKeyPair OurIdentityKey { get; init; }
|
||||||
|
public required ECKeyPair OurSignedPreKey { get; init; }
|
||||||
|
public required ECKeyPair OurRatchetKey { get; init; } // == signed prekey
|
||||||
|
public ECKeyPair? OurOneTimePreKey { get; init; }
|
||||||
|
public required IdentityKey TheirIdentityKey { get; init; }
|
||||||
|
public required byte[] TheirBaseKey { get; init; } // raw 32
|
||||||
|
public byte[]? KyberSharedSecret { get; init; } // from decapsulation (PQXDH)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the initial Double Ratchet state from X3DH/PQXDH agreements. The DH and (for PQXDH) Kyber
|
||||||
|
/// secrets are concatenated after 32 discontinuity bytes (0xFF), then HKDF "WhisperText" yields the
|
||||||
|
/// root and initial chain key. Initiator then performs one DH ratchet step to open its sending chain.
|
||||||
|
/// </summary>
|
||||||
|
public static class RatchetingSession
|
||||||
|
{
|
||||||
|
private static readonly byte[] DiscontinuityBytes = BuildDiscontinuity();
|
||||||
|
private static readonly byte[] DeriveInfo = Encoding.UTF8.GetBytes("WhisperText");
|
||||||
|
// PQXDH uses a distinct HKDF label and derives an extra 32-byte slice (the SPQR auth_key) beyond
|
||||||
|
// the root and chain keys. Matches libsignal pqxdh.rs HandshakeKeys::derive.
|
||||||
|
private static readonly byte[] PqxdhDeriveInfo =
|
||||||
|
Encoding.UTF8.GetBytes("WhisperText_X25519_SHA-256_CRYSTALS-KYBER-1024");
|
||||||
|
|
||||||
|
public static void InitializeAlice(SessionState state, AliceParameters p)
|
||||||
|
{
|
||||||
|
state.SessionVersion = p.KyberSharedSecret is not null ? 4 : 3;
|
||||||
|
state.LocalIdentity = p.OurIdentityKey.PublicKey;
|
||||||
|
state.RemoteIdentity = p.TheirIdentityKey;
|
||||||
|
|
||||||
|
ECKeyPair sendingRatchetKey = Curve25519.GenerateKeyPair();
|
||||||
|
|
||||||
|
using var secrets = new MemoryStream();
|
||||||
|
secrets.Write(DiscontinuityBytes);
|
||||||
|
secrets.Write(Curve25519.CalculateAgreement(p.TheirSignedPreKey, p.OurIdentityKey.PrivateKey)); // DH1
|
||||||
|
secrets.Write(Curve25519.CalculateAgreement(p.TheirIdentityKey.PublicKey, p.OurBaseKey.PrivateKey)); // DH2
|
||||||
|
secrets.Write(Curve25519.CalculateAgreement(p.TheirSignedPreKey, p.OurBaseKey.PrivateKey)); // DH3
|
||||||
|
if (p.TheirOneTimePreKey is not null)
|
||||||
|
secrets.Write(Curve25519.CalculateAgreement(p.TheirOneTimePreKey, p.OurBaseKey.PrivateKey)); // DH4
|
||||||
|
if (p.KyberSharedSecret is not null)
|
||||||
|
secrets.Write(p.KyberSharedSecret);
|
||||||
|
|
||||||
|
(RootKey rootKey, ChainKey chainKey, byte[]? authKey) = DeriveKeys(secrets.ToArray(), p.KyberSharedSecret is not null);
|
||||||
|
state.SpqrAuthKey = authKey;
|
||||||
|
state.Spqr = CreateSpqr(authKey, Direction.A2B);
|
||||||
|
(RootKey sendingRoot, ChainKey sendingChain) = rootKey.CreateChain(p.TheirRatchetKey, sendingRatchetKey);
|
||||||
|
|
||||||
|
state.AddReceiverChain(p.TheirRatchetKey, chainKey);
|
||||||
|
state.SenderRatchetKeyPair = sendingRatchetKey;
|
||||||
|
state.SenderChainKey = sendingChain;
|
||||||
|
state.RootKey = sendingRoot;
|
||||||
|
state.PreviousCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void InitializeBob(SessionState state, BobParameters p)
|
||||||
|
{
|
||||||
|
state.SessionVersion = p.KyberSharedSecret is not null ? 4 : 3;
|
||||||
|
state.LocalIdentity = p.OurIdentityKey.PublicKey;
|
||||||
|
state.RemoteIdentity = p.TheirIdentityKey;
|
||||||
|
|
||||||
|
using var secrets = new MemoryStream();
|
||||||
|
secrets.Write(DiscontinuityBytes);
|
||||||
|
secrets.Write(Curve25519.CalculateAgreement(p.TheirIdentityKey.PublicKey, p.OurSignedPreKey.PrivateKey)); // DH1
|
||||||
|
secrets.Write(Curve25519.CalculateAgreement(p.TheirBaseKey, p.OurIdentityKey.PrivateKey)); // DH2
|
||||||
|
secrets.Write(Curve25519.CalculateAgreement(p.TheirBaseKey, p.OurSignedPreKey.PrivateKey)); // DH3
|
||||||
|
if (p.OurOneTimePreKey is not null)
|
||||||
|
secrets.Write(Curve25519.CalculateAgreement(p.TheirBaseKey, p.OurOneTimePreKey.PrivateKey)); // DH4
|
||||||
|
if (p.KyberSharedSecret is not null)
|
||||||
|
secrets.Write(p.KyberSharedSecret);
|
||||||
|
|
||||||
|
(RootKey rootKey, ChainKey chainKey, byte[]? authKey) = DeriveKeys(secrets.ToArray(), p.KyberSharedSecret is not null);
|
||||||
|
state.SpqrAuthKey = authKey;
|
||||||
|
state.Spqr = CreateSpqr(authKey, Direction.B2A);
|
||||||
|
|
||||||
|
state.SenderRatchetKeyPair = p.OurRatchetKey;
|
||||||
|
state.SenderChainKey = chainKey;
|
||||||
|
state.RootKey = rootKey;
|
||||||
|
state.PreviousCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (RootKey, ChainKey, byte[]? AuthKey) DeriveKeys(byte[] masterSecret, bool pqxdh)
|
||||||
|
{
|
||||||
|
if (!pqxdh)
|
||||||
|
{
|
||||||
|
byte[] derived = CryptoPrimitives.Hkdf(masterSecret, salt: null, DeriveInfo, 64);
|
||||||
|
return (new RootKey(derived.AsSpan(0, 32).ToArray()), new ChainKey(derived.AsSpan(32, 32).ToArray(), 0), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PQXDH: HKDF expands to root[32] || chain[32] || pqr_key[32]; the last slice seeds SPQR.
|
||||||
|
byte[] pq = CryptoPrimitives.Hkdf(masterSecret, salt: null, PqxdhDeriveInfo, 96);
|
||||||
|
return (new RootKey(pq.AsSpan(0, 32).ToArray()), new ChainKey(pq.AsSpan(32, 32).ToArray(), 0),
|
||||||
|
pq.AsSpan(64, 32).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the Sparse Post-Quantum Ratchet for a PQXDH (v4) session. Both ends mandate V1.
|
||||||
|
// chain_params default to libsignal's non-self-session values (max_jump=25000, max_ooo=2000).
|
||||||
|
private static SpqrRatchet? CreateSpqr(byte[]? authKey, Direction direction)
|
||||||
|
{
|
||||||
|
if (authKey is null) return null;
|
||||||
|
return SpqrRatchet.InitialState(new SpqrParams
|
||||||
|
{
|
||||||
|
Direction = direction,
|
||||||
|
Version = SpqrVersion.V1,
|
||||||
|
MinVersion = SpqrVersion.V1,
|
||||||
|
AuthKey = authKey,
|
||||||
|
ChainParams = new ChainParams(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] BuildDiscontinuity()
|
||||||
|
{
|
||||||
|
var bytes = new byte[32];
|
||||||
|
Array.Fill(bytes, (byte)0xFF);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Wingnal.Protocol.Crypto;
|
||||||
|
using Wingnal.Protocol.Curve;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Ratchet;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Double Ratchet root key. A DH ratchet step derives a new root key and a fresh chain key from
|
||||||
|
/// the current root key (as HKDF salt) and a new DH output, with info "WhisperRatchet".
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RootKey
|
||||||
|
{
|
||||||
|
private static readonly byte[] Info = Encoding.UTF8.GetBytes("WhisperRatchet");
|
||||||
|
|
||||||
|
public byte[] Key { get; }
|
||||||
|
|
||||||
|
public RootKey(byte[] key) => Key = key;
|
||||||
|
|
||||||
|
public (RootKey RootKey, ChainKey ChainKey) CreateChain(byte[] theirRatchetKey, ECKeyPair ourRatchetKey)
|
||||||
|
{
|
||||||
|
byte[] dh = Curve25519.CalculateAgreement(theirRatchetKey, ourRatchetKey.PrivateKey);
|
||||||
|
byte[] derived = CryptoPrimitives.Hkdf(dh, salt: Key, Info, 64);
|
||||||
|
var newRootKey = new RootKey(derived.AsSpan(0, 32).ToArray());
|
||||||
|
var newChainKey = new ChainKey(derived.AsSpan(32, 32).ToArray(), 0);
|
||||||
|
return (newRootKey, newChainKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
using Wingnal.Protocol.Curve;
|
||||||
|
using Wingnal.Protocol.Messages;
|
||||||
|
using Wingnal.Protocol.State;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Ratchet;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Establishes sessions: from a fetched <see cref="PreKeyBundle"/> (initiator) or from an inbound
|
||||||
|
/// <see cref="PreKeySignalMessage"/> (responder). Verifies prekey signatures with XEdDSA.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SessionBuilder
|
||||||
|
{
|
||||||
|
private readonly ISessionStore _sessionStore;
|
||||||
|
private readonly IPreKeyStore _preKeyStore;
|
||||||
|
private readonly ISignedPreKeyStore _signedPreKeyStore;
|
||||||
|
private readonly IKyberPreKeyStore _kyberPreKeyStore;
|
||||||
|
private readonly IIdentityKeyStore _identityStore;
|
||||||
|
private readonly SignalProtocolAddress _remoteAddress;
|
||||||
|
|
||||||
|
public SessionBuilder(ISessionStore sessionStore, IPreKeyStore preKeyStore,
|
||||||
|
ISignedPreKeyStore signedPreKeyStore, IKyberPreKeyStore kyberPreKeyStore,
|
||||||
|
IIdentityKeyStore identityStore, SignalProtocolAddress remoteAddress)
|
||||||
|
{
|
||||||
|
_sessionStore = sessionStore;
|
||||||
|
_preKeyStore = preKeyStore;
|
||||||
|
_signedPreKeyStore = signedPreKeyStore;
|
||||||
|
_kyberPreKeyStore = kyberPreKeyStore;
|
||||||
|
_identityStore = identityStore;
|
||||||
|
_remoteAddress = remoteAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Initiator: build an outgoing session from a fetched bundle.</summary>
|
||||||
|
public void Process(PreKeyBundle bundle)
|
||||||
|
{
|
||||||
|
// Refuse to build a session to an identity that doesn't match the one we already trust (MITM /
|
||||||
|
// reinstall). The caller surfaces this so the user can verify the safety number + approve.
|
||||||
|
if (!_identityStore.IsTrustedIdentity(_remoteAddress, bundle.IdentityKey))
|
||||||
|
throw new UntrustedIdentityException(_remoteAddress, bundle.IdentityKey);
|
||||||
|
|
||||||
|
if (!XEd25519.VerifySignature(bundle.IdentityKey.PublicKey,
|
||||||
|
Curve25519.EncodePoint(bundle.SignedPreKeyPublic), bundle.SignedPreKeySignature))
|
||||||
|
throw new InvalidMessageException("invalid signed prekey signature");
|
||||||
|
|
||||||
|
byte[]? kyberCiphertext = null, kyberSharedSecret = null;
|
||||||
|
if (bundle.KyberPreKeyPublic is not null)
|
||||||
|
{
|
||||||
|
if (bundle.KyberPreKeySignature is null ||
|
||||||
|
!XEd25519.VerifySignature(bundle.IdentityKey.PublicKey,
|
||||||
|
KemKeySerialization.Serialize(bundle.KyberPreKeyPublic), bundle.KyberPreKeySignature))
|
||||||
|
throw new InvalidMessageException("invalid kyber prekey signature");
|
||||||
|
|
||||||
|
KyberEncapsulation encapsulation = Kyber.Encapsulate(bundle.KyberPreKeyPublic);
|
||||||
|
// The wire carries the libsignal-serialized (type-prefixed) ciphertext.
|
||||||
|
kyberCiphertext = KemKeySerialization.Serialize(encapsulation.CipherText);
|
||||||
|
kyberSharedSecret = encapsulation.SharedSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
ECKeyPair ourBaseKey = Curve25519.GenerateKeyPair();
|
||||||
|
|
||||||
|
var parameters = new AliceParameters
|
||||||
|
{
|
||||||
|
OurIdentityKey = _identityStore.GetIdentityKeyPair(),
|
||||||
|
OurBaseKey = ourBaseKey,
|
||||||
|
TheirIdentityKey = bundle.IdentityKey,
|
||||||
|
TheirSignedPreKey = bundle.SignedPreKeyPublic,
|
||||||
|
TheirRatchetKey = bundle.SignedPreKeyPublic,
|
||||||
|
TheirOneTimePreKey = bundle.PreKeyPublic,
|
||||||
|
KyberSharedSecret = kyberSharedSecret,
|
||||||
|
};
|
||||||
|
|
||||||
|
SessionRecord record = _sessionStore.ContainsSession(_remoteAddress)
|
||||||
|
? _sessionStore.LoadSession(_remoteAddress)
|
||||||
|
: new SessionRecord();
|
||||||
|
record.ArchiveCurrentState();
|
||||||
|
|
||||||
|
RatchetingSession.InitializeAlice(record.State, parameters);
|
||||||
|
|
||||||
|
record.State.PendingPreKey = new PendingPreKey(bundle.PreKeyId, bundle.SignedPreKeyId,
|
||||||
|
bundle.KyberPreKeyId, kyberCiphertext, ourBaseKey.PublicKey);
|
||||||
|
record.State.LocalRegistrationId = _identityStore.GetLocalRegistrationId();
|
||||||
|
record.State.RemoteRegistrationId = bundle.RegistrationId;
|
||||||
|
|
||||||
|
_identityStore.SaveIdentity(_remoteAddress, bundle.IdentityKey);
|
||||||
|
_sessionStore.StoreSession(_remoteAddress, record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Responder: initialize the session from an inbound PreKeySignalMessage (mutates the given
|
||||||
|
/// record). Returns the one-time prekey id consumed, if any, so the caller can delete it.
|
||||||
|
/// </summary>
|
||||||
|
public uint? Process(SessionRecord record, PreKeySignalMessage message)
|
||||||
|
{
|
||||||
|
if (record.State.AliceBaseKey is not null && record.State.AliceBaseKey.AsSpan().SequenceEqual(message.BaseKey))
|
||||||
|
return null; // already processed this prekey message
|
||||||
|
|
||||||
|
// Don't accept a session from an identity we don't trust (changed key) until the user approves.
|
||||||
|
if (!_identityStore.IsTrustedIdentity(_remoteAddress, message.IdentityKey))
|
||||||
|
throw new UntrustedIdentityException(_remoteAddress, message.IdentityKey);
|
||||||
|
|
||||||
|
SignedPreKeyRecord signedPreKey = _signedPreKeyStore.LoadSignedPreKey(message.SignedPreKeyId);
|
||||||
|
|
||||||
|
ECKeyPair? oneTimePreKey = null;
|
||||||
|
if (message.PreKeyId.HasValue)
|
||||||
|
oneTimePreKey = _preKeyStore.LoadPreKey(message.PreKeyId.Value).KeyPair;
|
||||||
|
|
||||||
|
byte[]? kyberSharedSecret = null;
|
||||||
|
if (message.KyberPreKeyId.HasValue)
|
||||||
|
{
|
||||||
|
KyberPreKeyRecord kyberRecord = _kyberPreKeyStore.LoadKyberPreKey(message.KyberPreKeyId.Value);
|
||||||
|
kyberSharedSecret = Kyber.Decapsulate(kyberRecord.KeyPair.PrivateKey,
|
||||||
|
KemKeySerialization.Deserialize(message.KyberCiphertext!));
|
||||||
|
}
|
||||||
|
|
||||||
|
var parameters = new BobParameters
|
||||||
|
{
|
||||||
|
OurIdentityKey = _identityStore.GetIdentityKeyPair(),
|
||||||
|
OurSignedPreKey = signedPreKey.KeyPair,
|
||||||
|
OurRatchetKey = signedPreKey.KeyPair,
|
||||||
|
OurOneTimePreKey = oneTimePreKey,
|
||||||
|
TheirIdentityKey = message.IdentityKey,
|
||||||
|
TheirBaseKey = message.BaseKey,
|
||||||
|
KyberSharedSecret = kyberSharedSecret,
|
||||||
|
};
|
||||||
|
|
||||||
|
record.ArchiveCurrentState();
|
||||||
|
RatchetingSession.InitializeBob(record.State, parameters);
|
||||||
|
|
||||||
|
record.State.LocalRegistrationId = _identityStore.GetLocalRegistrationId();
|
||||||
|
record.State.RemoteRegistrationId = message.RegistrationId;
|
||||||
|
record.State.AliceBaseKey = message.BaseKey;
|
||||||
|
|
||||||
|
_identityStore.SaveIdentity(_remoteAddress, message.IdentityKey);
|
||||||
|
|
||||||
|
if (message.KyberPreKeyId.HasValue)
|
||||||
|
_kyberPreKeyStore.MarkKyberPreKeyUsed(message.KyberPreKeyId.Value);
|
||||||
|
|
||||||
|
return message.PreKeyId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
using Wingnal.Protocol.Crypto;
|
||||||
|
using Wingnal.Protocol.Curve;
|
||||||
|
using Wingnal.Protocol.Messages;
|
||||||
|
using Wingnal.Protocol.Spqr;
|
||||||
|
using Wingnal.Protocol.State;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Ratchet;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encrypts/decrypts messages for one peer using the Double Ratchet. Encrypt advances the sending
|
||||||
|
/// chain; decrypt performs DH ratchet steps on new ratchet keys and handles skipped (out-of-order)
|
||||||
|
/// message keys. Mirrors libsignal's SessionCipher.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SessionCipher
|
||||||
|
{
|
||||||
|
private const int MaxSkip = 2000;
|
||||||
|
|
||||||
|
private readonly ISessionStore _sessionStore;
|
||||||
|
private readonly IPreKeyStore _preKeyStore;
|
||||||
|
private readonly IIdentityKeyStore _identityStore;
|
||||||
|
private readonly SignalProtocolAddress _remoteAddress;
|
||||||
|
private readonly SessionBuilder _sessionBuilder;
|
||||||
|
|
||||||
|
public SessionCipher(ISessionStore sessionStore, IPreKeyStore preKeyStore,
|
||||||
|
ISignedPreKeyStore signedPreKeyStore, IKyberPreKeyStore kyberPreKeyStore,
|
||||||
|
IIdentityKeyStore identityStore, SignalProtocolAddress remoteAddress)
|
||||||
|
{
|
||||||
|
_sessionStore = sessionStore;
|
||||||
|
_preKeyStore = preKeyStore;
|
||||||
|
_identityStore = identityStore;
|
||||||
|
_remoteAddress = remoteAddress;
|
||||||
|
_sessionBuilder = new SessionBuilder(sessionStore, preKeyStore, signedPreKeyStore,
|
||||||
|
kyberPreKeyStore, identityStore, remoteAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ICiphertextMessage Encrypt(byte[] plaintext)
|
||||||
|
{
|
||||||
|
SessionRecord record = _sessionStore.LoadSession(_remoteAddress);
|
||||||
|
SessionState state = record.State;
|
||||||
|
|
||||||
|
ChainKey chainKey = state.SenderChainKey ?? throw new InvalidOperationException("no sender chain");
|
||||||
|
|
||||||
|
// Advance the Sparse Post-Quantum Ratchet (if enabled): the produced bytes ride in
|
||||||
|
// SignalMessage.pq_ratchet, and the produced key salts the message-key derivation.
|
||||||
|
byte[]? pqRatchet = null, pqrSalt = null;
|
||||||
|
if (state.Spqr is not null)
|
||||||
|
{
|
||||||
|
SpqrRatchet.SendOutput sent = state.Spqr.Send();
|
||||||
|
pqRatchet = sent.Message;
|
||||||
|
pqrSalt = sent.Key;
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageKeys messageKeys = chainKey.GetMessageKeys(pqrSalt);
|
||||||
|
|
||||||
|
byte[] ciphertextBody = CryptoPrimitives.AesCbcEncrypt(messageKeys.CipherKey, messageKeys.Iv, plaintext);
|
||||||
|
|
||||||
|
var signalMessage = new SignalMessage(state.SessionVersion, messageKeys.MacKey,
|
||||||
|
state.SenderRatchetKeyPair!.PublicKey, chainKey.Index, state.PreviousCounter,
|
||||||
|
ciphertextBody, state.LocalIdentity!, state.RemoteIdentity!, pqRatchet);
|
||||||
|
|
||||||
|
ICiphertextMessage result = signalMessage;
|
||||||
|
if (state.PendingPreKey is { } pending)
|
||||||
|
{
|
||||||
|
result = new PreKeySignalMessage(state.SessionVersion, state.LocalRegistrationId,
|
||||||
|
pending.PreKeyId, pending.SignedPreKeyId, pending.KyberPreKeyId, pending.KyberCiphertext,
|
||||||
|
pending.BaseKey, state.LocalIdentity!, signalMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.SenderChainKey = chainKey.Next();
|
||||||
|
_sessionStore.StoreSession(_remoteAddress, record);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] DecryptPreKeyMessage(PreKeySignalMessage message)
|
||||||
|
{
|
||||||
|
SessionRecord record = _sessionStore.ContainsSession(_remoteAddress)
|
||||||
|
? _sessionStore.LoadSession(_remoteAddress)
|
||||||
|
: new SessionRecord();
|
||||||
|
|
||||||
|
uint? unsignedPreKeyId = _sessionBuilder.Process(record, message);
|
||||||
|
byte[] plaintext = Decrypt(record, message.Message);
|
||||||
|
|
||||||
|
_sessionStore.StoreSession(_remoteAddress, record);
|
||||||
|
if (unsignedPreKeyId.HasValue)
|
||||||
|
_preKeyStore.RemovePreKey(unsignedPreKeyId.Value);
|
||||||
|
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] DecryptSignalMessage(SignalMessage message)
|
||||||
|
{
|
||||||
|
SessionRecord record = _sessionStore.LoadSession(_remoteAddress);
|
||||||
|
byte[] plaintext = Decrypt(record, message);
|
||||||
|
_sessionStore.StoreSession(_remoteAddress, record);
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] Decrypt(SessionRecord record, SignalMessage message)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return DecryptWithState(record.State, message);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is InvalidMessageException or DuplicateMessageException)
|
||||||
|
{
|
||||||
|
foreach (SessionState previous in record.PreviousStates)
|
||||||
|
{
|
||||||
|
try { return DecryptWithState(previous, message); }
|
||||||
|
catch (Exception inner) when (inner is InvalidMessageException or DuplicateMessageException) { }
|
||||||
|
}
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] DecryptWithState(SessionState state, SignalMessage message)
|
||||||
|
{
|
||||||
|
if (!state.HasSenderChain)
|
||||||
|
throw new InvalidMessageException("uninitialized session");
|
||||||
|
|
||||||
|
byte[] theirEphemeral = message.SenderRatchetKey;
|
||||||
|
ChainKey chainKey = GetOrCreateChainKey(state, theirEphemeral);
|
||||||
|
byte[] seed = GetOrCreateMessageSeed(state, theirEphemeral, chainKey, message.Counter);
|
||||||
|
|
||||||
|
// Advance the SPQR receiving ratchet with this message's pq_ratchet bytes; the returned key
|
||||||
|
// salts the message-key derivation (matching libsignal's per-message WhisperMessageKeys salt).
|
||||||
|
byte[]? pqrSalt = state.Spqr?.Recv(message.PqRatchet ?? Array.Empty<byte>());
|
||||||
|
MessageKeys messageKeys = ChainKey.DeriveMessageKeys(seed, pqrSalt, message.Counter);
|
||||||
|
|
||||||
|
if (!message.VerifyMac(state.RemoteIdentity!, state.LocalIdentity!, messageKeys.MacKey))
|
||||||
|
throw new InvalidMessageException("bad MAC");
|
||||||
|
|
||||||
|
byte[] plaintext = CryptoPrimitives.AesCbcDecrypt(messageKeys.CipherKey, messageKeys.Iv, message.Body);
|
||||||
|
state.PendingPreKey = null;
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChainKey GetOrCreateChainKey(SessionState state, byte[] theirEphemeral)
|
||||||
|
{
|
||||||
|
ReceiverChain? existing = state.FindReceiverChain(theirEphemeral);
|
||||||
|
if (existing is not null)
|
||||||
|
return existing.ChainKey;
|
||||||
|
|
||||||
|
// New ratchet key from the peer: perform a DH ratchet step.
|
||||||
|
RootKey rootKey = state.RootKey!;
|
||||||
|
ECKeyPair ourEphemeral = state.SenderRatchetKeyPair!;
|
||||||
|
(RootKey receiverRoot, ChainKey receiverChainKey) = rootKey.CreateChain(theirEphemeral, ourEphemeral);
|
||||||
|
|
||||||
|
ECKeyPair ourNewEphemeral = Curve25519.GenerateKeyPair();
|
||||||
|
(RootKey senderRoot, ChainKey senderChainKey) = receiverRoot.CreateChain(theirEphemeral, ourNewEphemeral);
|
||||||
|
|
||||||
|
state.RootKey = senderRoot;
|
||||||
|
state.AddReceiverChain(theirEphemeral, receiverChainKey);
|
||||||
|
state.PreviousCounter = state.SenderChainKey!.Index == 0 ? 0 : state.SenderChainKey.Index - 1;
|
||||||
|
state.SenderRatchetKeyPair = ourNewEphemeral;
|
||||||
|
state.SenderChainKey = senderChainKey;
|
||||||
|
|
||||||
|
return receiverChainKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the message-key SEED for the given counter (advancing/caching the DR chain as needed).
|
||||||
|
// The SPQR salt is applied to the seed separately, so out-of-order messages each get their own salt.
|
||||||
|
private static byte[] GetOrCreateMessageSeed(SessionState state, byte[] theirEphemeral, ChainKey chainKey, uint counter)
|
||||||
|
{
|
||||||
|
ReceiverChain chain = state.FindReceiverChain(theirEphemeral)!;
|
||||||
|
|
||||||
|
if (chainKey.Index > counter)
|
||||||
|
{
|
||||||
|
if (chain.TryTakeMessageSeed(counter, out byte[] cached))
|
||||||
|
return cached;
|
||||||
|
throw new DuplicateMessageException($"message key for counter {counter} already used or skipped");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (counter - chainKey.Index > MaxSkip)
|
||||||
|
throw new InvalidMessageException("too many skipped messages");
|
||||||
|
|
||||||
|
ChainKey current = chainKey;
|
||||||
|
while (current.Index < counter)
|
||||||
|
{
|
||||||
|
chain.StoreMessageSeed(current.Index, current.MessageKeySeedBytes);
|
||||||
|
current = current.Next();
|
||||||
|
}
|
||||||
|
|
||||||
|
chain.ChainKey = current.Next();
|
||||||
|
return current.MessageKeySeedBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Wingnal.Protocol.Crypto;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Spqr;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The SPQR authenticator (ported from SparsePostQuantumRatchet v1.5.1 src/authenticator.rs). Keeps a
|
||||||
|
/// rolling root key + MAC key, advanced each epoch via HKDF, and MACs the chunked header/ciphertext
|
||||||
|
/// material so a man-in-the-middle can't tamper with the spread-out ML-KEM bytes. HKDF-SHA256 +
|
||||||
|
/// HMAC-SHA256; the domain-separation strings must match Signal byte-for-byte.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Authenticator
|
||||||
|
{
|
||||||
|
public const int MacSize = 32;
|
||||||
|
|
||||||
|
private static readonly byte[] ZeroSalt = new byte[32];
|
||||||
|
private static readonly byte[] UpdateInfo = "Signal_PQCKA_V1_MLKEM768:Authenticator Update"u8.ToArray();
|
||||||
|
private static readonly byte[] CiphertextLabel = "Signal_PQCKA_V1_MLKEM768:ciphertext"u8.ToArray();
|
||||||
|
private static readonly byte[] HeaderLabel = "Signal_PQCKA_V1_MLKEM768:ekheader"u8.ToArray();
|
||||||
|
|
||||||
|
private byte[] _rootKey = new byte[32];
|
||||||
|
private byte[] _macKey = new byte[32];
|
||||||
|
|
||||||
|
public Authenticator(byte[] rootKey, ulong epoch) => Update(epoch, rootKey);
|
||||||
|
|
||||||
|
private Authenticator(byte[] rootKey, byte[] macKey, bool _)
|
||||||
|
{
|
||||||
|
_rootKey = (byte[])rootKey.Clone();
|
||||||
|
_macKey = (byte[])macKey.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] RootKey => _rootKey;
|
||||||
|
public byte[] MacKey => _macKey;
|
||||||
|
|
||||||
|
/// <summary>Deep copy (used so a failed recv doesn't corrupt committed state).</summary>
|
||||||
|
public Authenticator Clone() => new(_rootKey, _macKey, true);
|
||||||
|
|
||||||
|
internal void Write(System.IO.BinaryWriter w) { w.WriteBlob(_rootKey); w.WriteBlob(_macKey); }
|
||||||
|
internal static Authenticator Read(System.IO.BinaryReader r) => new(r.ReadBlob(), r.ReadBlob(), true);
|
||||||
|
|
||||||
|
public void Update(ulong epoch, byte[] k)
|
||||||
|
{
|
||||||
|
byte[] ikm = Concat(_rootKey, k);
|
||||||
|
byte[] info = Concat(UpdateInfo, Be64(epoch));
|
||||||
|
byte[] okm = CryptoPrimitives.Hkdf(ikm, ZeroSalt, info, 64);
|
||||||
|
_rootKey = okm[..32];
|
||||||
|
_macKey = okm[32..];
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] MacCiphertext(ulong epoch, byte[] ciphertext) =>
|
||||||
|
Mac(CiphertextLabel, epoch, ciphertext);
|
||||||
|
|
||||||
|
public byte[] MacHeader(ulong epoch, byte[] header) =>
|
||||||
|
Mac(HeaderLabel, epoch, header);
|
||||||
|
|
||||||
|
public bool VerifyCiphertext(ulong epoch, byte[] ciphertext, byte[] expectedMac) =>
|
||||||
|
CryptographicOperations.FixedTimeEquals(expectedMac, MacCiphertext(epoch, ciphertext));
|
||||||
|
|
||||||
|
public bool VerifyHeader(ulong epoch, byte[] header, byte[] expectedMac) =>
|
||||||
|
CryptographicOperations.FixedTimeEquals(expectedMac, MacHeader(epoch, header));
|
||||||
|
|
||||||
|
private byte[] Mac(byte[] label, ulong epoch, byte[] data)
|
||||||
|
{
|
||||||
|
byte[] macData = Concat(label, Be64(epoch), data);
|
||||||
|
return CryptoPrimitives.HmacSha256(_macKey, macData); // already 32 bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Be64(ulong v)
|
||||||
|
{
|
||||||
|
var b = new byte[8];
|
||||||
|
for (int i = 7; i >= 0; i--) { b[i] = (byte)(v & 0xFF); v >>= 8; }
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Concat(params byte[][] parts)
|
||||||
|
{
|
||||||
|
int len = 0;
|
||||||
|
foreach (byte[] p in parts) len += p.Length;
|
||||||
|
var result = new byte[len];
|
||||||
|
int off = 0;
|
||||||
|
foreach (byte[] p in parts) { Buffer.BlockCopy(p, 0, result, off, p.Length); off += p.Length; }
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
using System.IO;
|
||||||
|
using Wingnal.Protocol.Crypto;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Spqr;
|
||||||
|
|
||||||
|
public enum Direction { A2B, B2A }
|
||||||
|
|
||||||
|
public static class DirectionExtensions
|
||||||
|
{
|
||||||
|
public static Direction Switch(this Direction d) => d == Direction.A2B ? Direction.B2A : Direction.A2B;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A shared secret for a given ratchet epoch.</summary>
|
||||||
|
public sealed record EpochSecret(ulong Epoch, byte[] Secret);
|
||||||
|
|
||||||
|
/// <summary>Bounds for the symmetric chain (out-of-order tolerance / max forward jump).</summary>
|
||||||
|
public sealed class ChainParams
|
||||||
|
{
|
||||||
|
public const uint DefaultMaxJump = 25_000;
|
||||||
|
public const uint DefaultMaxOooKeys = 2_000;
|
||||||
|
|
||||||
|
public uint MaxJump { get; init; } = DefaultMaxJump;
|
||||||
|
public uint MaxOooKeys { get; init; } = DefaultMaxOooKeys;
|
||||||
|
|
||||||
|
internal int TrimSize => (int)((long)MaxOooKeys * 11 / 10 + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SPQR symmetric key chain (ported from SparsePostQuantumRatchet v1.5.1 src/chain.rs). Per epoch and
|
||||||
|
/// direction it maintains a hash chain (HKDF) producing 32-byte keys; <see cref="SendKey"/> advances
|
||||||
|
/// it and <see cref="RecvKey"/> retrieves keys by index, tolerating out-of-order delivery via a
|
||||||
|
/// bounded key history. The A2B send chain matches the B2A receive chain (and vice versa).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Chain
|
||||||
|
{
|
||||||
|
private static readonly byte[] ZeroSalt = new byte[32];
|
||||||
|
private static readonly byte[] StartInfo = "Signal PQ Ratchet V1 Chain Start"u8.ToArray(); // two spaces
|
||||||
|
private static readonly byte[] NextInfo = "Signal PQ Ratchet V1 Chain Next"u8.ToArray();
|
||||||
|
private static readonly byte[] AddEpochInfo = "Signal PQ Ratchet V1 Chain Add Epoch"u8.ToArray();
|
||||||
|
private const int EpochsToKeepPriorToSendEpoch = 1;
|
||||||
|
|
||||||
|
private sealed class KeyHistory
|
||||||
|
{
|
||||||
|
private const int KeySize = 4 + 32;
|
||||||
|
private readonly List<byte> _data = new();
|
||||||
|
|
||||||
|
/// <summary>Raw history bytes, for state serialization.</summary>
|
||||||
|
public byte[] Data { get => _data.ToArray(); set { _data.Clear(); _data.AddRange(value); } }
|
||||||
|
|
||||||
|
public void Add(uint idx, byte[] key)
|
||||||
|
{
|
||||||
|
_data.AddRange(Be32(idx));
|
||||||
|
_data.AddRange(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear() => _data.Clear();
|
||||||
|
|
||||||
|
public void Gc(uint currentKey, ChainParams p)
|
||||||
|
{
|
||||||
|
if (_data.Count < p.TrimSize * KeySize) return;
|
||||||
|
uint horizon = currentKey - p.MaxOooKeys;
|
||||||
|
int i = 0;
|
||||||
|
while (i < _data.Count)
|
||||||
|
{
|
||||||
|
uint entryIdx = ReadBe32(i);
|
||||||
|
if (horizon > entryIdx) RemoveAt(i);
|
||||||
|
else i += KeySize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveAt(int index)
|
||||||
|
{
|
||||||
|
int newEnd = _data.Count - KeySize;
|
||||||
|
if (index + KeySize < _data.Count)
|
||||||
|
for (int k = 0; k < KeySize; k++) _data[index + k] = _data[newEnd + k];
|
||||||
|
_data.RemoveRange(newEnd, KeySize);
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] Get(uint at, uint currentCtr, ChainParams p)
|
||||||
|
{
|
||||||
|
if (at + p.MaxOooKeys < currentCtr)
|
||||||
|
throw new SpqrException($"key trimmed: {at}");
|
||||||
|
for (int i = 0; i < _data.Count; i += KeySize)
|
||||||
|
if (ReadBe32(i) == at)
|
||||||
|
{
|
||||||
|
var outp = new byte[32];
|
||||||
|
for (int k = 0; k < 32; k++) outp[k] = _data[i + 4 + k];
|
||||||
|
RemoveAt(i);
|
||||||
|
return outp;
|
||||||
|
}
|
||||||
|
throw new SpqrException($"key already requested: {at}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private uint ReadBe32(int i) =>
|
||||||
|
(uint)((_data[i] << 24) | (_data[i + 1] << 16) | (_data[i + 2] << 8) | _data[i + 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ChainEpochDirection
|
||||||
|
{
|
||||||
|
public uint Ctr;
|
||||||
|
public byte[] Next;
|
||||||
|
public readonly KeyHistory Prev = new();
|
||||||
|
|
||||||
|
public ChainEpochDirection(byte[] k) => Next = (byte[])k.Clone();
|
||||||
|
|
||||||
|
public (uint Idx, byte[] Key) NextKey()
|
||||||
|
{
|
||||||
|
Ctr += 1;
|
||||||
|
byte[] info = Concat(Be32(Ctr), NextInfo);
|
||||||
|
byte[] gen = CryptoPrimitives.Hkdf(Next, ZeroSalt, info, 64);
|
||||||
|
Next = gen[..32];
|
||||||
|
return (Ctr, gen[32..64]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] Key(uint at, ChainParams p)
|
||||||
|
{
|
||||||
|
if (at > Ctr)
|
||||||
|
{
|
||||||
|
if (at - Ctr > p.MaxJump) throw new SpqrException($"key jump {Ctr} -> {at}");
|
||||||
|
}
|
||||||
|
else if (at < Ctr)
|
||||||
|
{
|
||||||
|
return Prev.Get(at, Ctr, p);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new SpqrException($"key already requested: {at}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (at > Ctr + p.MaxOooKeys) Prev.Clear();
|
||||||
|
while (at > Ctr + 1)
|
||||||
|
{
|
||||||
|
(uint idx, byte[] k) = NextKey();
|
||||||
|
if (Ctr + p.MaxOooKeys >= at) Prev.Add(idx, k);
|
||||||
|
}
|
||||||
|
Prev.Gc(Ctr, p);
|
||||||
|
return NextKey().Key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearNext() => Next = Array.Empty<byte>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ChainEpoch
|
||||||
|
{
|
||||||
|
public required ChainEpochDirection Send;
|
||||||
|
public required ChainEpochDirection Recv;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly Direction _dir;
|
||||||
|
private ulong _currentEpoch;
|
||||||
|
private ulong _sendEpoch;
|
||||||
|
private readonly LinkedList<ChainEpoch> _links = new();
|
||||||
|
private byte[] _nextRoot;
|
||||||
|
private readonly ChainParams _params;
|
||||||
|
|
||||||
|
public Chain(byte[] initialKey, Direction dir, ChainParams parameters)
|
||||||
|
{
|
||||||
|
_dir = dir;
|
||||||
|
_params = parameters;
|
||||||
|
byte[] gen = CryptoPrimitives.Hkdf(initialKey, ZeroSalt, StartInfo, 96);
|
||||||
|
_nextRoot = gen[0..32];
|
||||||
|
_links.AddLast(new ChainEpoch
|
||||||
|
{
|
||||||
|
Send = CedForDirection(gen, dir),
|
||||||
|
Recv = CedForDirection(gen, dir.Switch()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChainEpochDirection CedForDirection(byte[] gen, Direction dir) =>
|
||||||
|
new(dir == Direction.A2B ? gen[32..64] : gen[64..96]);
|
||||||
|
|
||||||
|
public void AddEpoch(EpochSecret epochSecret)
|
||||||
|
{
|
||||||
|
if (epochSecret.Epoch != _currentEpoch + 1)
|
||||||
|
throw new SpqrException($"epoch must be {_currentEpoch + 1}, got {epochSecret.Epoch}");
|
||||||
|
byte[] gen = CryptoPrimitives.Hkdf(epochSecret.Secret, _nextRoot, AddEpochInfo, 96);
|
||||||
|
_currentEpoch = epochSecret.Epoch;
|
||||||
|
_nextRoot = gen[0..32];
|
||||||
|
_links.AddLast(new ChainEpoch
|
||||||
|
{
|
||||||
|
Send = CedForDirection(gen, _dir),
|
||||||
|
Recv = CedForDirection(gen, _dir.Switch()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private int EpochIdx(ulong epoch)
|
||||||
|
{
|
||||||
|
if (epoch > _currentEpoch) throw new SpqrException($"epoch out of range: {epoch}");
|
||||||
|
int back = (int)(_currentEpoch - epoch);
|
||||||
|
if (back >= _links.Count) throw new SpqrException($"epoch out of range: {epoch}");
|
||||||
|
return _links.Count - 1 - back;
|
||||||
|
}
|
||||||
|
|
||||||
|
public (uint Index, byte[] Key) SendKey(ulong epoch)
|
||||||
|
{
|
||||||
|
if (epoch < _sendEpoch) throw new SpqrException($"send key epoch decreased {_sendEpoch} -> {epoch}");
|
||||||
|
int epochIndex = EpochIdx(epoch);
|
||||||
|
if (_sendEpoch != epoch)
|
||||||
|
{
|
||||||
|
_sendEpoch = epoch;
|
||||||
|
while (epochIndex > EpochsToKeepPriorToSendEpoch)
|
||||||
|
{
|
||||||
|
_links.RemoveFirst();
|
||||||
|
epochIndex--;
|
||||||
|
}
|
||||||
|
int i = 0;
|
||||||
|
foreach (ChainEpoch link in _links)
|
||||||
|
{
|
||||||
|
if (i >= epochIndex) break;
|
||||||
|
link.Send.ClearNext();
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return LinkAt(epochIndex).Send.NextKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] RecvKey(ulong epoch, uint index) => LinkAt(EpochIdx(epoch)).Recv.Key(index, _params);
|
||||||
|
|
||||||
|
private ChainEpoch LinkAt(int index)
|
||||||
|
{
|
||||||
|
LinkedListNode<ChainEpoch> node = _links.First!;
|
||||||
|
for (int i = 0; i < index; i++) node = node.Next!;
|
||||||
|
return node.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Be32(uint v) => new[] { (byte)(v >> 24), (byte)(v >> 16), (byte)(v >> 8), (byte)v };
|
||||||
|
|
||||||
|
private static byte[] Concat(byte[] a, byte[] b)
|
||||||
|
{
|
||||||
|
var r = new byte[a.Length + b.Length];
|
||||||
|
Buffer.BlockCopy(a, 0, r, 0, a.Length);
|
||||||
|
Buffer.BlockCopy(b, 0, r, a.Length, b.Length);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── serialization (local-only state persistence) ──
|
||||||
|
|
||||||
|
private Chain(Direction dir, ChainParams parameters)
|
||||||
|
{
|
||||||
|
_dir = dir;
|
||||||
|
_params = parameters;
|
||||||
|
_nextRoot = Array.Empty<byte>();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Write(BinaryWriter w)
|
||||||
|
{
|
||||||
|
w.Write((int)_dir);
|
||||||
|
w.Write(_currentEpoch);
|
||||||
|
w.Write(_sendEpoch);
|
||||||
|
w.WriteBlob(_nextRoot);
|
||||||
|
w.Write(_params.MaxJump);
|
||||||
|
w.Write(_params.MaxOooKeys);
|
||||||
|
w.Write(_links.Count);
|
||||||
|
foreach (ChainEpoch link in _links) { WriteDir(w, link.Send); WriteDir(w, link.Recv); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteDir(BinaryWriter w, ChainEpochDirection d)
|
||||||
|
{
|
||||||
|
w.Write(d.Ctr);
|
||||||
|
w.WriteBlob(d.Next);
|
||||||
|
w.WriteBlob(d.Prev.Data);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Chain Read(BinaryReader r)
|
||||||
|
{
|
||||||
|
var dir = (Direction)r.ReadInt32();
|
||||||
|
ulong cur = r.ReadUInt64();
|
||||||
|
ulong send = r.ReadUInt64();
|
||||||
|
byte[] nextRoot = r.ReadBlob();
|
||||||
|
var p = new ChainParams { MaxJump = r.ReadUInt32(), MaxOooKeys = r.ReadUInt32() };
|
||||||
|
var chain = new Chain(dir, p);
|
||||||
|
chain._currentEpoch = cur;
|
||||||
|
chain._sendEpoch = send;
|
||||||
|
chain._nextRoot = nextRoot;
|
||||||
|
int n = r.ReadInt32();
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
chain._links.AddLast(new ChainEpoch { Send = ReadDir(r), Recv = ReadDir(r) });
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChainEpochDirection ReadDir(BinaryReader r)
|
||||||
|
{
|
||||||
|
uint ctr = r.ReadUInt32();
|
||||||
|
byte[] next = r.ReadBlob();
|
||||||
|
byte[] prev = r.ReadBlob();
|
||||||
|
var d = new ChainEpochDirection(next) { Ctr = ctr };
|
||||||
|
d.Prev.Data = prev;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Errors from the SPQR layer.</summary>
|
||||||
|
public sealed class SpqrException : Exception
|
||||||
|
{
|
||||||
|
public SpqrException(string message) : base(message) { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
namespace Wingnal.Protocol.Spqr;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Arithmetic in GF(2^16) with reduction polynomial 0x1100b, ported from Signal's
|
||||||
|
/// SparsePostQuantumRatchet (v1.5.1) <c>src/encoding/gf.rs</c>. This is the base field for the
|
||||||
|
/// Reed-Solomon-style erasure coding that "sparsely" spreads ML-KEM-768 keys/ciphertexts across
|
||||||
|
/// messages. Addition/subtraction are XOR; multiplication is carryless-multiply then reduce;
|
||||||
|
/// inversion is a^(2^16-2) via square-and-multiply.
|
||||||
|
/// </summary>
|
||||||
|
public readonly struct Gf16 : IEquatable<Gf16>
|
||||||
|
{
|
||||||
|
public const uint Poly = 0x1100b;
|
||||||
|
|
||||||
|
public static readonly Gf16 Zero = new(0);
|
||||||
|
public static readonly Gf16 One = new(1);
|
||||||
|
|
||||||
|
public ushort Value { get; }
|
||||||
|
|
||||||
|
public Gf16(ushort value) => Value = value;
|
||||||
|
|
||||||
|
public static Gf16 Add(Gf16 a, Gf16 b) => new((ushort)(a.Value ^ b.Value));
|
||||||
|
public static Gf16 Sub(Gf16 a, Gf16 b) => new((ushort)(a.Value ^ b.Value));
|
||||||
|
|
||||||
|
public static Gf16 Mul(Gf16 a, Gf16 b) => new(PolyReduce(PolyMul(a.Value, b.Value)));
|
||||||
|
|
||||||
|
/// <summary>a / b = a * b^(2^16-2). Dividing by zero yields zero (matches the reference loop).</summary>
|
||||||
|
public static Gf16 Div(Gf16 a, Gf16 b)
|
||||||
|
{
|
||||||
|
// out = self * other^(2+4+...+2^15) = self * other^(2^16-2) = self * inv(other).
|
||||||
|
Gf16 square = b;
|
||||||
|
Gf16 outp = a;
|
||||||
|
for (int i = 1; i < 16; i++)
|
||||||
|
{
|
||||||
|
square = Mul(square, square);
|
||||||
|
outp = Mul(outp, square);
|
||||||
|
}
|
||||||
|
return outp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Gf16 Inv(Gf16 a) => Div(One, a);
|
||||||
|
|
||||||
|
/// <summary>Carryless (polynomial) multiply of two 16-bit values into a 32-bit result.</summary>
|
||||||
|
private static uint PolyMul(ushort a, ushort b)
|
||||||
|
{
|
||||||
|
uint acc = 0;
|
||||||
|
uint me = a;
|
||||||
|
for (int shift = 0; shift < 16; shift++)
|
||||||
|
if ((b & (1 << shift)) != 0)
|
||||||
|
acc ^= me << shift;
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reduce a 32-bit carryless product modulo POLY (a 17-bit polynomial) to 16 bits.</summary>
|
||||||
|
private static ushort PolyReduce(uint v)
|
||||||
|
{
|
||||||
|
for (int bit = 31; bit >= 16; bit--)
|
||||||
|
if ((v & (1u << bit)) != 0)
|
||||||
|
v ^= Poly << (bit - 16);
|
||||||
|
return (ushort)v;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Equals(Gf16 other) => Value == other.Value;
|
||||||
|
public override bool Equals(object? obj) => obj is Gf16 g && Equals(g);
|
||||||
|
public override int GetHashCode() => Value;
|
||||||
|
public override string ToString() => $"GF16({Value})";
|
||||||
|
}
|
||||||
@@ -0,0 +1,735 @@
|
|||||||
|
using Org.BouncyCastle.Crypto.Digests;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Spqr;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure-C# FIPS-203 ML-KEM-768 with the "incremental" ek/ciphertext split used by Signal's Sparse
|
||||||
|
/// Post-Quantum Ratchet (libcrux <c>mlkem768::incremental</c>). The ring arithmetic (q=3329, NTT,
|
||||||
|
/// zetas, Montgomery/Barrett, basemul, CBD-eta2, matrix gen) is identical to round-3 Kyber-1024
|
||||||
|
/// (<see cref="Curve.Kyber1024"/>); the differences are k=3, d_u=10 / d_v=4 compression, the FIPS-203
|
||||||
|
/// hashing (keygen G(d‖k), encaps (K,r)=G(m‖H(ek)), implicit reject J(z‖c)), and NO final KDF.
|
||||||
|
///
|
||||||
|
/// Incremental layout (byte-exact with libcrux, confirmed against cryspen/libcrux):
|
||||||
|
/// keygen → hdr(64) = rho(32)‖H(ek)(32); ek/pk2(1152) = ByteEncode12(t̂); dk(2400) =
|
||||||
|
/// dkPke(1152)‖ek(1184)‖H(ek)(32)‖z(32), where ek(1184) = ByteEncode12(t̂)‖rho.
|
||||||
|
/// encaps1(hdr,m) → ct1(960) = Compress_{10}(u); es (local); ss(32) = K.
|
||||||
|
/// encaps2(ek,es) → ct2(128) = Compress_{4}(v).
|
||||||
|
/// decaps(dk,ct1,ct2) → ss(32): standard FIPS-203 decaps on ct = ct1‖ct2 (1088).
|
||||||
|
/// The encapsulation state <c>es</c> is never transmitted, so its format is our own.
|
||||||
|
/// </summary>
|
||||||
|
internal static class MlKem768
|
||||||
|
{
|
||||||
|
public const int N = 256;
|
||||||
|
public const int Q = 3329;
|
||||||
|
public const int K = 3;
|
||||||
|
public const int Eta = 2; // eta1 == eta2 == 2
|
||||||
|
public const int SymBytes = 32;
|
||||||
|
public const byte RankByte = 3; // FIPS-203 domain-separation byte k for ML-KEM-768
|
||||||
|
|
||||||
|
public const int PolyBytes = 384;
|
||||||
|
public const int PolyVecBytes = K * PolyBytes; // 1152 = ByteEncode12(vec)
|
||||||
|
private const int Du = 10;
|
||||||
|
private const int Dv = 4;
|
||||||
|
public const int PolyVecCompressedBytes = K * (N * Du / 8); // 960
|
||||||
|
public const int PolyCompressedBytes = N * Dv / 8; // 128
|
||||||
|
|
||||||
|
public const int EkBytes = PolyVecBytes + SymBytes; // 1184 (full FIPS-203 encapsulation key)
|
||||||
|
public const int Pk2Bytes = PolyVecBytes; // 1152 (incremental "encapsulation key")
|
||||||
|
public const int HeaderBytes = 2 * SymBytes; // 64
|
||||||
|
public const int CiphertextBytes = PolyVecCompressedBytes + PolyCompressedBytes; // 1088
|
||||||
|
public const int Ct1Bytes = PolyVecCompressedBytes; // 960
|
||||||
|
public const int Ct2Bytes = PolyCompressedBytes; // 128
|
||||||
|
public const int DkBytes = PolyVecBytes + EkBytes + SymBytes + SymBytes; // 2400
|
||||||
|
public const int SsBytes = 32;
|
||||||
|
|
||||||
|
private const short QINV = -3327; // q^-1 mod 2^16
|
||||||
|
|
||||||
|
private static readonly short[] Zetas =
|
||||||
|
{
|
||||||
|
-1044, -758, -359, -1517, 1493, 1422, 287, 202,
|
||||||
|
-171, 622, 1577, 182, 962, -1202, -1474, 1468,
|
||||||
|
573, -1325, 264, 383, -829, 1458, -1602, -130,
|
||||||
|
-681, 1017, 732, 608, -1542, 411, -205, -1571,
|
||||||
|
1223, 652, -552, 1015, -1293, 1491, -282, -1544,
|
||||||
|
516, -8, -320, -666, -1618, -1162, 126, 1469,
|
||||||
|
-853, -90, -271, 830, 107, -1421, -247, -951,
|
||||||
|
-398, 961, -1508, -725, 448, -1065, 677, -1275,
|
||||||
|
-1103, 430, 555, 843, -1251, 871, 1550, 105,
|
||||||
|
422, 587, 177, -235, -291, -460, 1574, 1653,
|
||||||
|
-246, 778, 1159, -147, -777, 1483, -602, 1119,
|
||||||
|
-1590, 644, -872, 349, 418, 329, -156, -75,
|
||||||
|
817, 1097, 603, 610, 1322, -1285, -1465, 384,
|
||||||
|
-1215, -136, 1218, -1335, -874, 220, -1187, -1659,
|
||||||
|
-1185, -1530, -1278, 794, -1510, -854, -870, 478,
|
||||||
|
-108, -308, 996, 991, 958, -1460, 1522, 1628,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- reductions ----
|
||||||
|
|
||||||
|
private static short MontgomeryReduce(int a)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
short t = (short)((short)a * QINV);
|
||||||
|
return (short)((a - (int)t * Q) >> 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static short BarrettReduce(short a)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
const int v = ((1 << 26) + Q / 2) / Q;
|
||||||
|
short t = (short)((v * a + (1 << 25)) >> 26);
|
||||||
|
return (short)(a - (short)(t * Q));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static short FqMul(short a, short b) => MontgomeryReduce(a * b);
|
||||||
|
|
||||||
|
// ---- NTT ----
|
||||||
|
|
||||||
|
private static void Ntt(short[] r)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
int k = 1;
|
||||||
|
for (int len = 128; len >= 2; len >>= 1)
|
||||||
|
for (int start = 0; start < 256; start += 2 * len)
|
||||||
|
{
|
||||||
|
short zeta = Zetas[k++];
|
||||||
|
for (int j = start; j < start + len; j++)
|
||||||
|
{
|
||||||
|
short t = FqMul(zeta, r[j + len]);
|
||||||
|
r[j + len] = (short)(r[j] - t);
|
||||||
|
r[j] = (short)(r[j] + t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void InvNtt(short[] r)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
const short f = 1441; // mont^2/128
|
||||||
|
int k = 127;
|
||||||
|
for (int len = 2; len <= 128; len <<= 1)
|
||||||
|
for (int start = 0; start < 256; start += 2 * len)
|
||||||
|
{
|
||||||
|
short zeta = Zetas[k--];
|
||||||
|
for (int j = start; j < start + len; j++)
|
||||||
|
{
|
||||||
|
short t = r[j];
|
||||||
|
r[j] = BarrettReduce((short)(t + r[j + len]));
|
||||||
|
r[j + len] = (short)(r[j + len] - t);
|
||||||
|
r[j + len] = FqMul(zeta, r[j + len]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int j = 0; j < 256; j++) r[j] = FqMul(r[j], f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void BaseMul(short[] r, int rOff, short[] a, int aOff, short[] b, int bOff, short zeta)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
r[rOff] = FqMul(a[aOff + 1], b[bOff + 1]);
|
||||||
|
r[rOff] = FqMul(r[rOff], zeta);
|
||||||
|
r[rOff] = (short)(r[rOff] + FqMul(a[aOff], b[bOff]));
|
||||||
|
r[rOff + 1] = FqMul(a[aOff], b[bOff + 1]);
|
||||||
|
r[rOff + 1] = (short)(r[rOff + 1] + FqMul(a[aOff + 1], b[bOff]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- hashing ----
|
||||||
|
|
||||||
|
private static byte[] Sha3_256(byte[] data, int off, int len)
|
||||||
|
{
|
||||||
|
var d = new Sha3Digest(256);
|
||||||
|
d.BlockUpdate(data, off, len);
|
||||||
|
var o = new byte[32];
|
||||||
|
d.DoFinal(o, 0);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Sha3_512(byte[] data)
|
||||||
|
{
|
||||||
|
var d = new Sha3Digest(512);
|
||||||
|
d.BlockUpdate(data, 0, data.Length);
|
||||||
|
var o = new byte[64];
|
||||||
|
d.DoFinal(o, 0);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Shake256(byte[] data, int len)
|
||||||
|
{
|
||||||
|
var d = new ShakeDigest(256);
|
||||||
|
d.BlockUpdate(data, 0, data.Length);
|
||||||
|
var o = new byte[len];
|
||||||
|
d.Output(o, 0, len);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CBD (eta = 2) ----
|
||||||
|
|
||||||
|
private static uint Load32Le(byte[] x, int off) =>
|
||||||
|
(uint)(x[off] | (x[off + 1] << 8) | (x[off + 2] << 16) | (x[off + 3] << 24));
|
||||||
|
|
||||||
|
private static void Cbd2(short[] r, byte[] buf)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
for (int i = 0; i < N / 8; i++)
|
||||||
|
{
|
||||||
|
uint t = Load32Le(buf, 4 * i);
|
||||||
|
uint d = t & 0x55555555u;
|
||||||
|
d += (t >> 1) & 0x55555555u;
|
||||||
|
for (int j = 0; j < 8; j++)
|
||||||
|
{
|
||||||
|
short a = (short)((d >> (4 * j + 0)) & 0x3);
|
||||||
|
short b = (short)((d >> (4 * j + 2)) & 0x3);
|
||||||
|
r[8 * i + j] = (short)(a - b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static short[] GetNoise(byte[] seed, byte nonce)
|
||||||
|
{
|
||||||
|
var extkey = new byte[SymBytes + 1];
|
||||||
|
Array.Copy(seed, extkey, SymBytes);
|
||||||
|
extkey[SymBytes] = nonce;
|
||||||
|
byte[] buf = Shake256(extkey, Eta * N / 4);
|
||||||
|
var r = new short[N];
|
||||||
|
Cbd2(r, buf);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- poly (de)serialization: ByteEncode12 / ByteDecode12 ----
|
||||||
|
|
||||||
|
private static void PolyToBytes(byte[] r, int rOff, short[] a)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
for (int i = 0; i < N / 2; i++)
|
||||||
|
{
|
||||||
|
ushort t0 = (ushort)(a[2 * i] + ((a[2 * i] >> 15) & Q));
|
||||||
|
ushort t1 = (ushort)(a[2 * i + 1] + ((a[2 * i + 1] >> 15) & Q));
|
||||||
|
r[rOff + 3 * i + 0] = (byte)t0;
|
||||||
|
r[rOff + 3 * i + 1] = (byte)((t0 >> 8) | (t1 << 4));
|
||||||
|
r[rOff + 3 * i + 2] = (byte)(t1 >> 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PolyFromBytes(short[] r, byte[] a, int aOff)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
for (int i = 0; i < N / 2; i++)
|
||||||
|
{
|
||||||
|
r[2 * i] = (short)(((a[aOff + 3 * i + 0] >> 0) | (a[aOff + 3 * i + 1] << 8)) & 0xFFF);
|
||||||
|
r[2 * i + 1] = (short)(((a[aOff + 3 * i + 1] >> 4) | (a[aOff + 3 * i + 2] << 4)) & 0xFFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- generic d-bit compression (LSB-first bit packing, FIPS-203 ByteEncode/Compress) ----
|
||||||
|
|
||||||
|
private static void CompressPoly(byte[] outBuf, int outOff, short[] a, int d)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
uint mask = (1u << d) - 1;
|
||||||
|
ulong acc = 0;
|
||||||
|
int bits = 0, pos = outOff;
|
||||||
|
for (int i = 0; i < N; i++)
|
||||||
|
{
|
||||||
|
int u = a[i];
|
||||||
|
u += (u >> 15) & Q;
|
||||||
|
uint t = (uint)(((((ulong)u << d) + Q / 2) / Q) & mask);
|
||||||
|
acc |= (ulong)t << bits;
|
||||||
|
bits += d;
|
||||||
|
while (bits >= 8) { outBuf[pos++] = (byte)acc; acc >>= 8; bits -= 8; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static short[] DecompressPoly(byte[] a, int aOff, int d)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
var r = new short[N];
|
||||||
|
uint mask = (1u << d) - 1;
|
||||||
|
ulong acc = 0;
|
||||||
|
int bits = 0, pos = aOff;
|
||||||
|
for (int i = 0; i < N; i++)
|
||||||
|
{
|
||||||
|
while (bits < d) { acc |= (ulong)a[pos++] << bits; bits += 8; }
|
||||||
|
uint t = (uint)(acc & mask);
|
||||||
|
acc >>= d;
|
||||||
|
bits -= d;
|
||||||
|
r[i] = (short)(((uint)t * Q + (1u << (d - 1))) >> d);
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PolyFromMsg(short[] r, byte[] msg)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
for (int i = 0; i < N / 8; i++)
|
||||||
|
for (int j = 0; j < 8; j++)
|
||||||
|
{
|
||||||
|
short mask = (short)(-(short)((msg[i] >> j) & 1));
|
||||||
|
r[8 * i + j] = (short)(mask & ((Q + 1) / 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] PolyToMsg(short[] a)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
var msg = new byte[SymBytes];
|
||||||
|
for (int i = 0; i < N / 8; i++)
|
||||||
|
{
|
||||||
|
msg[i] = 0;
|
||||||
|
for (int j = 0; j < 8; j++)
|
||||||
|
{
|
||||||
|
int t = a[8 * i + j];
|
||||||
|
t += (t >> 15) & Q;
|
||||||
|
t = (((t << 1) + Q / 2) / Q) & 1;
|
||||||
|
msg[i] |= (byte)(t << j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PolyNtt(short[] r) { Ntt(r); PolyReduce(r); }
|
||||||
|
|
||||||
|
private static void PolyBaseMulMont(short[] r, short[] a, short[] b)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
for (int i = 0; i < N / 4; i++)
|
||||||
|
{
|
||||||
|
BaseMul(r, 4 * i, a, 4 * i, b, 4 * i, Zetas[64 + i]);
|
||||||
|
BaseMul(r, 4 * i + 2, a, 4 * i + 2, b, 4 * i + 2, (short)(-Zetas[64 + i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PolyToMont(short[] r)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
const short f = (short)((1L << 32) % Q);
|
||||||
|
for (int i = 0; i < N; i++) r[i] = MontgomeryReduce(r[i] * f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PolyReduce(short[] r) { for (int i = 0; i < N; i++) r[i] = BarrettReduce(r[i]); }
|
||||||
|
private static void PolyAdd(short[] r, short[] a, short[] b) { unchecked { for (int i = 0; i < N; i++) r[i] = (short)(a[i] + b[i]); } }
|
||||||
|
private static void PolySub(short[] r, short[] a, short[] b) { unchecked { for (int i = 0; i < N; i++) r[i] = (short)(a[i] - b[i]); } }
|
||||||
|
|
||||||
|
// ---- polyvec ----
|
||||||
|
|
||||||
|
private static short[][] NewPolyVec()
|
||||||
|
{
|
||||||
|
var v = new short[K][];
|
||||||
|
for (int i = 0; i < K; i++) v[i] = new short[N];
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PolyVecToBytes(byte[] r, int rOff, short[][] a)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < K; i++) PolyToBytes(r, rOff + i * PolyBytes, a[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static short[][] PolyVecFromBytes(byte[] a, int aOff)
|
||||||
|
{
|
||||||
|
var r = NewPolyVec();
|
||||||
|
for (int i = 0; i < K; i++) PolyFromBytes(r[i], a, aOff + i * PolyBytes);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PolyVecCompress(byte[] r, int rOff, short[][] a)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < K; i++) CompressPoly(r, rOff + i * (N * Du / 8), a[i], Du);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static short[][] PolyVecDecompress(byte[] a, int aOff)
|
||||||
|
{
|
||||||
|
var r = NewPolyVec();
|
||||||
|
for (int i = 0; i < K; i++) r[i] = DecompressPoly(a, aOff + i * (N * Du / 8), Du);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PolyVecNtt(short[][] r) { for (int i = 0; i < K; i++) PolyNtt(r[i]); }
|
||||||
|
|
||||||
|
private static void PolyVecBaseMulAccMont(short[] r, short[][] a, short[][] b)
|
||||||
|
{
|
||||||
|
var t = new short[N];
|
||||||
|
PolyBaseMulMont(r, a[0], b[0]);
|
||||||
|
for (int i = 1; i < K; i++) { PolyBaseMulMont(t, a[i], b[i]); PolyAdd(r, r, t); }
|
||||||
|
PolyReduce(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PolyVecReduce(short[][] r) { for (int i = 0; i < K; i++) PolyReduce(r[i]); }
|
||||||
|
private static void PolyVecAdd(short[][] r, short[][] a, short[][] b) { for (int i = 0; i < K; i++) PolyAdd(r[i], a[i], b[i]); }
|
||||||
|
|
||||||
|
// ---- matrix generation (identical to round-3 Kyber: SHAKE128 rejection sampling) ----
|
||||||
|
|
||||||
|
private const int XofBlockBytes = 168;
|
||||||
|
private const int GenMatrixNBlocks = (12 * N / 8 * (1 << 12) / Q + XofBlockBytes) / XofBlockBytes;
|
||||||
|
|
||||||
|
private static int RejUniform(short[] r, int rOff, int len, byte[] buf, int buflen)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
int ctr = 0, pos = 0;
|
||||||
|
while (ctr < len && pos + 3 <= buflen)
|
||||||
|
{
|
||||||
|
ushort val0 = (ushort)(((buf[pos + 0] >> 0) | (buf[pos + 1] << 8)) & 0xFFF);
|
||||||
|
ushort val1 = (ushort)(((buf[pos + 1] >> 4) | (buf[pos + 2] << 4)) & 0xFFF);
|
||||||
|
pos += 3;
|
||||||
|
if (val0 < Q) r[rOff + ctr++] = (short)val0;
|
||||||
|
if (ctr < len && val1 < Q) r[rOff + ctr++] = (short)val1;
|
||||||
|
}
|
||||||
|
return ctr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static short[][][] GenMatrix(byte[] seed, bool transposed)
|
||||||
|
{
|
||||||
|
var a = new short[K][][];
|
||||||
|
for (int i = 0; i < K; i++)
|
||||||
|
{
|
||||||
|
a[i] = NewPolyVec();
|
||||||
|
for (int j = 0; j < K; j++)
|
||||||
|
{
|
||||||
|
var extseed = new byte[SymBytes + 2];
|
||||||
|
Array.Copy(seed, extseed, SymBytes);
|
||||||
|
extseed[SymBytes] = (byte)(transposed ? i : j);
|
||||||
|
extseed[SymBytes + 1] = (byte)(transposed ? j : i);
|
||||||
|
|
||||||
|
var xof = new ShakeDigest(128);
|
||||||
|
xof.BlockUpdate(extseed, 0, extseed.Length);
|
||||||
|
|
||||||
|
var buf = new byte[GenMatrixNBlocks * XofBlockBytes + 2];
|
||||||
|
xof.Output(buf, 0, GenMatrixNBlocks * XofBlockBytes);
|
||||||
|
int buflen = GenMatrixNBlocks * XofBlockBytes;
|
||||||
|
int ctr = RejUniform(a[i][j], 0, N, buf, buflen);
|
||||||
|
|
||||||
|
while (ctr < N)
|
||||||
|
{
|
||||||
|
int off = buflen % 3;
|
||||||
|
for (int k = 0; k < off; k++) buf[k] = buf[buflen - off + k];
|
||||||
|
xof.Output(buf, off, XofBlockBytes);
|
||||||
|
buflen = off + XofBlockBytes;
|
||||||
|
ctr += RejUniform(a[i][j], ctr, N - ctr, buf, buflen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- K-PKE keygen (FIPS-203) ----
|
||||||
|
|
||||||
|
private static void KPkeKeygen(byte[] d, out short[][] skpv, out short[][] pkpv, out byte[] rho)
|
||||||
|
{
|
||||||
|
var dk = new byte[SymBytes + 1];
|
||||||
|
Array.Copy(d, dk, SymBytes);
|
||||||
|
dk[SymBytes] = RankByte; // FIPS-203: (rho,sigma) = G(d || k)
|
||||||
|
byte[] buf = Sha3_512(dk);
|
||||||
|
rho = buf[..SymBytes];
|
||||||
|
var sigma = buf[SymBytes..];
|
||||||
|
|
||||||
|
// Keygen samples A[i][j] from XOF(rho, j, i) (pq-crystals gen_a, transposed:false); encrypt
|
||||||
|
// uses the transpose Aᵀ (transposed:true). Identical convention to round-3 Kyber.
|
||||||
|
short[][][] a = GenMatrix(rho, transposed: false);
|
||||||
|
|
||||||
|
skpv = NewPolyVec();
|
||||||
|
var e = NewPolyVec();
|
||||||
|
byte nonce = 0;
|
||||||
|
for (int i = 0; i < K; i++) skpv[i] = GetNoise(sigma, nonce++);
|
||||||
|
for (int i = 0; i < K; i++) e[i] = GetNoise(sigma, nonce++);
|
||||||
|
|
||||||
|
PolyVecNtt(skpv);
|
||||||
|
PolyVecNtt(e);
|
||||||
|
|
||||||
|
pkpv = NewPolyVec();
|
||||||
|
for (int i = 0; i < K; i++)
|
||||||
|
{
|
||||||
|
PolyVecBaseMulAccMont(pkpv[i], a[i], skpv);
|
||||||
|
PolyToMont(pkpv[i]);
|
||||||
|
}
|
||||||
|
PolyVecAdd(pkpv, pkpv, e);
|
||||||
|
PolyVecReduce(pkpv);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- standard FIPS-203 API (for KAT) ----
|
||||||
|
|
||||||
|
/// <summary>FIPS-203 ML-KEM.KeyGen_internal(d, z) → (ek 1184, dk 2400).</summary>
|
||||||
|
public static void KeyGen(byte[] d, byte[] z, out byte[] ek, out byte[] dk)
|
||||||
|
{
|
||||||
|
KPkeKeygen(d, out short[][] skpv, out short[][] pkpv, out byte[] rho);
|
||||||
|
|
||||||
|
ek = new byte[EkBytes];
|
||||||
|
PolyVecToBytes(ek, 0, pkpv);
|
||||||
|
Array.Copy(rho, 0, ek, PolyVecBytes, SymBytes);
|
||||||
|
|
||||||
|
dk = new byte[DkBytes];
|
||||||
|
PolyVecToBytes(dk, 0, skpv); // dkPke = ByteEncode12(s)
|
||||||
|
Array.Copy(ek, 0, dk, PolyVecBytes, EkBytes); // ek
|
||||||
|
byte[] hek = Sha3_256(ek, 0, EkBytes);
|
||||||
|
Array.Copy(hek, 0, dk, PolyVecBytes + EkBytes, SymBytes); // H(ek)
|
||||||
|
Array.Copy(z, 0, dk, PolyVecBytes + EkBytes + SymBytes, SymBytes); // z
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>K-PKE encryption split into the c1 (u, compressed du) and c2 (v, compressed dv) halves.
|
||||||
|
/// Pass <paramref name="tHat"/> = null to skip c2 (incremental encaps1).</summary>
|
||||||
|
private static void KPkeEncrypt(byte[] rho, short[][]? tHat, byte[] msg, byte[] coins,
|
||||||
|
short[][] rOut, out short[] e2Out, byte[]? ct1, byte[]? ct2)
|
||||||
|
{
|
||||||
|
short[][][] at = GenMatrix(rho, transposed: true); // Aᵀ for encrypt (see KPkeKeygen note)
|
||||||
|
|
||||||
|
var sp = NewPolyVec();
|
||||||
|
var ep = NewPolyVec();
|
||||||
|
byte nonce = 0;
|
||||||
|
for (int i = 0; i < K; i++) sp[i] = GetNoise(coins, nonce++);
|
||||||
|
for (int i = 0; i < K; i++) ep[i] = GetNoise(coins, nonce++);
|
||||||
|
short[] epp = GetNoise(coins, nonce);
|
||||||
|
|
||||||
|
PolyVecNtt(sp);
|
||||||
|
for (int i = 0; i < K; i++) rOut[i] = (short[])sp[i].Clone(); // r̂ saved for encaps2
|
||||||
|
e2Out = epp;
|
||||||
|
|
||||||
|
if (ct1 is not null)
|
||||||
|
{
|
||||||
|
var b = NewPolyVec();
|
||||||
|
for (int i = 0; i < K; i++) PolyVecBaseMulAccMont(b[i], at[i], sp);
|
||||||
|
for (int i = 0; i < K; i++) InvNtt(b[i]);
|
||||||
|
PolyVecAdd(b, b, ep);
|
||||||
|
PolyVecReduce(b);
|
||||||
|
PolyVecCompress(ct1, 0, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ct2 is not null && tHat is not null)
|
||||||
|
{
|
||||||
|
var v = new short[N];
|
||||||
|
PolyVecBaseMulAccMont(v, tHat, sp);
|
||||||
|
InvNtt(v);
|
||||||
|
var k = new short[N];
|
||||||
|
PolyFromMsg(k, msg);
|
||||||
|
PolyAdd(v, v, epp);
|
||||||
|
PolyAdd(v, v, k);
|
||||||
|
PolyReduce(v);
|
||||||
|
CompressPoly(ct2, 0, v, Dv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>FIPS-203 ML-KEM.Encaps_internal(ek, m) → (c 1088, ss 32). For KAT / standard use.</summary>
|
||||||
|
public static void Encaps(byte[] ek, byte[] m, out byte[] ct, out byte[] ss)
|
||||||
|
{
|
||||||
|
byte[] hek = Sha3_256(ek, 0, EkBytes);
|
||||||
|
byte[] g = Sha3_512(Concat(m, hek));
|
||||||
|
ss = g[..SymBytes];
|
||||||
|
byte[] coins = g[SymBytes..];
|
||||||
|
|
||||||
|
byte[] rho = ek[PolyVecBytes..EkBytes];
|
||||||
|
short[][] tHat = PolyVecFromBytes(ek, 0);
|
||||||
|
|
||||||
|
var rHat = new short[K][];
|
||||||
|
ct = new byte[CiphertextBytes];
|
||||||
|
var ct1 = new byte[Ct1Bytes];
|
||||||
|
var ct2 = new byte[Ct2Bytes];
|
||||||
|
KPkeEncrypt(rho, tHat, m, coins, rHat, out _, ct1, ct2);
|
||||||
|
Array.Copy(ct1, 0, ct, 0, Ct1Bytes);
|
||||||
|
Array.Copy(ct2, 0, ct, Ct1Bytes, Ct2Bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>FIPS-203 ML-KEM.Decaps(dk, c) → ss 32 (c = ct1‖ct2).</summary>
|
||||||
|
public static byte[] Decaps(byte[] dk, byte[] ct1, byte[] ct2)
|
||||||
|
{
|
||||||
|
var dkPke = dk[..PolyVecBytes];
|
||||||
|
var ek = new byte[EkBytes];
|
||||||
|
Array.Copy(dk, PolyVecBytes, ek, 0, EkBytes);
|
||||||
|
var hek = new byte[SymBytes];
|
||||||
|
Array.Copy(dk, PolyVecBytes + EkBytes, hek, 0, SymBytes);
|
||||||
|
var z = new byte[SymBytes];
|
||||||
|
Array.Copy(dk, PolyVecBytes + EkBytes + SymBytes, z, 0, SymBytes);
|
||||||
|
|
||||||
|
// K-PKE.Decrypt
|
||||||
|
short[][] u = PolyVecDecompress(ct1, 0);
|
||||||
|
short[] v = DecompressPoly(ct2, 0, Dv);
|
||||||
|
short[][] skpv = PolyVecFromBytes(dkPke, 0);
|
||||||
|
PolyVecNtt(u);
|
||||||
|
var mp = new short[N];
|
||||||
|
PolyVecBaseMulAccMont(mp, skpv, u);
|
||||||
|
InvNtt(mp);
|
||||||
|
PolySub(mp, v, mp);
|
||||||
|
PolyReduce(mp);
|
||||||
|
byte[] mPrime = PolyToMsg(mp);
|
||||||
|
|
||||||
|
// (K', r') = G(m' || H(ek))
|
||||||
|
byte[] g = Sha3_512(Concat(mPrime, hek));
|
||||||
|
byte[] kPrime = g[..SymBytes];
|
||||||
|
byte[] coins = g[SymBytes..];
|
||||||
|
|
||||||
|
// re-encrypt and compare
|
||||||
|
byte[] rho = ek[PolyVecBytes..EkBytes];
|
||||||
|
short[][] tHat = PolyVecFromBytes(ek, 0);
|
||||||
|
var rHat = new short[K][];
|
||||||
|
var ct1Cmp = new byte[Ct1Bytes];
|
||||||
|
var ct2Cmp = new byte[Ct2Bytes];
|
||||||
|
KPkeEncrypt(rho, tHat, mPrime, coins, rHat, out _, ct1Cmp, ct2Cmp);
|
||||||
|
|
||||||
|
int fail = Verify(ct1, ct1Cmp) | Verify(ct2, ct2Cmp);
|
||||||
|
|
||||||
|
// implicit reject: K_bar = J(z || c)
|
||||||
|
byte[] kBar = Shake256(Concat(z, Concat(ct1, ct2)), SsBytes);
|
||||||
|
|
||||||
|
var outSs = new byte[SsBytes];
|
||||||
|
CMov(outSs, kPrime, kBar, (byte)fail);
|
||||||
|
return outSs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- incremental API ----
|
||||||
|
|
||||||
|
public sealed class Keys
|
||||||
|
{
|
||||||
|
public required byte[] Header { get; init; } // 64
|
||||||
|
public required byte[] Ek { get; init; } // 1152 (pk2)
|
||||||
|
public required byte[] Dk { get; init; } // 2400
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Incremental keygen → header (rho‖H(ek)), ek/pk2 (ByteEncode12(t̂)), dk.</summary>
|
||||||
|
public static Keys Generate(byte[] d, byte[] z)
|
||||||
|
{
|
||||||
|
KeyGen(d, z, out byte[] ekFull, out byte[] dk);
|
||||||
|
var header = new byte[HeaderBytes];
|
||||||
|
Array.Copy(ekFull, PolyVecBytes, header, 0, SymBytes); // rho
|
||||||
|
byte[] hek = Sha3_256(ekFull, 0, EkBytes);
|
||||||
|
Array.Copy(hek, 0, header, SymBytes, SymBytes); // H(ek)
|
||||||
|
var pk2 = new byte[Pk2Bytes];
|
||||||
|
Array.Copy(ekFull, 0, pk2, 0, Pk2Bytes); // ByteEncode12(t̂)
|
||||||
|
return new Keys { Header = header, Ek = pk2, Dk = dk };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Validates that an encapsulation key (pk2) matches a header: H(pk2‖rho) == hdr hash.</summary>
|
||||||
|
public static bool EkMatchesHeader(byte[] pk2, byte[] header)
|
||||||
|
{
|
||||||
|
if (pk2.Length != Pk2Bytes || header.Length != HeaderBytes) return false;
|
||||||
|
var ekFull = new byte[EkBytes];
|
||||||
|
Array.Copy(pk2, 0, ekFull, 0, Pk2Bytes);
|
||||||
|
Array.Copy(header, 0, ekFull, PolyVecBytes, SymBytes); // rho from header
|
||||||
|
byte[] hek = Sha3_256(ekFull, 0, EkBytes);
|
||||||
|
return CryptographicEquals(hek, header.AsSpan(SymBytes, SymBytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>encaps1(hdr, m) → (ct1 960, es, ss 32). The shared secret is available immediately.</summary>
|
||||||
|
public static void Encaps1(byte[] header, byte[] m, out byte[] ct1, out byte[] es, out byte[] ss)
|
||||||
|
{
|
||||||
|
byte[] rho = header[..SymBytes];
|
||||||
|
byte[] hek = header[SymBytes..HeaderBytes];
|
||||||
|
byte[] g = Sha3_512(Concat(m, hek));
|
||||||
|
ss = g[..SymBytes];
|
||||||
|
byte[] coins = g[SymBytes..];
|
||||||
|
|
||||||
|
var rHat = new short[K][];
|
||||||
|
ct1 = new byte[Ct1Bytes];
|
||||||
|
KPkeEncrypt(rho, tHat: null, m, coins, rHat, out short[] e2, ct1, ct2: null);
|
||||||
|
es = SerializeState(rHat, e2, m);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>encaps2(ek/pk2, es) → ct2 128.</summary>
|
||||||
|
public static byte[] Encaps2(byte[] pk2, byte[] es)
|
||||||
|
{
|
||||||
|
DeserializeState(es, out short[][] rHat, out short[] e2, out byte[] m);
|
||||||
|
short[][] tHat = PolyVecFromBytes(pk2, 0);
|
||||||
|
|
||||||
|
var v = new short[N];
|
||||||
|
PolyVecBaseMulAccMont(v, tHat, rHat);
|
||||||
|
InvNtt(v);
|
||||||
|
var k = new short[N];
|
||||||
|
PolyFromMsg(k, m);
|
||||||
|
PolyAdd(v, v, e2);
|
||||||
|
PolyAdd(v, v, k);
|
||||||
|
PolyReduce(v);
|
||||||
|
var ct2 = new byte[Ct2Bytes];
|
||||||
|
CompressPoly(ct2, 0, v, Dv);
|
||||||
|
return ct2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>decaps(dk, ct1, ct2) → ss 32.</summary>
|
||||||
|
public static byte[] DecapsIncremental(byte[] dk, byte[] ct1, byte[] ct2) => Decaps(dk, ct1, ct2);
|
||||||
|
|
||||||
|
// ---- local encapsulation-state serialization (never transmitted; our own format) ----
|
||||||
|
// es = r̂ (K*N int16 LE) || e2 (N int16 LE) || m (32)
|
||||||
|
|
||||||
|
private static byte[] SerializeState(short[][] rHat, short[] e2, byte[] m)
|
||||||
|
{
|
||||||
|
var es = new byte[(K * N + N) * 2 + SymBytes];
|
||||||
|
int p = 0;
|
||||||
|
for (int i = 0; i < K; i++)
|
||||||
|
for (int j = 0; j < N; j++) { es[p++] = (byte)rHat[i][j]; es[p++] = (byte)(rHat[i][j] >> 8); }
|
||||||
|
for (int j = 0; j < N; j++) { es[p++] = (byte)e2[j]; es[p++] = (byte)(e2[j] >> 8); }
|
||||||
|
Array.Copy(m, 0, es, p, SymBytes);
|
||||||
|
return es;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DeserializeState(byte[] es, out short[][] rHat, out short[] e2, out byte[] m)
|
||||||
|
{
|
||||||
|
rHat = NewPolyVec();
|
||||||
|
e2 = new short[N];
|
||||||
|
int p = 0;
|
||||||
|
for (int i = 0; i < K; i++)
|
||||||
|
for (int j = 0; j < N; j++) { rHat[i][j] = (short)(es[p] | (es[p + 1] << 8)); p += 2; }
|
||||||
|
for (int j = 0; j < N; j++) { e2[j] = (short)(es[p] | (es[p + 1] << 8)); p += 2; }
|
||||||
|
m = new byte[SymBytes];
|
||||||
|
Array.Copy(es, p, m, 0, SymBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private static byte[] Concat(byte[] a, byte[] b)
|
||||||
|
{
|
||||||
|
var r = new byte[a.Length + b.Length];
|
||||||
|
Buffer.BlockCopy(a, 0, r, 0, a.Length);
|
||||||
|
Buffer.BlockCopy(b, 0, r, a.Length, b.Length);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int Verify(byte[] a, byte[] b)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
byte r = 0;
|
||||||
|
for (int i = 0; i < a.Length; i++) r |= (byte)(a[i] ^ b[i]);
|
||||||
|
return (int)((ulong)(0 - (ulong)r) >> 63);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CMov(byte[] dst, byte[] good, byte[] bad, byte fail)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
byte mask = (byte)(-(sbyte)fail); // fail==1 -> 0xFF (use bad), fail==0 -> 0x00 (use good)
|
||||||
|
for (int i = 0; i < dst.Length; i++)
|
||||||
|
dst[i] = (byte)(good[i] ^ (mask & (good[i] ^ bad[i])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CryptographicEquals(byte[] a, ReadOnlySpan<byte> b)
|
||||||
|
{
|
||||||
|
if (a.Length != b.Length) return false;
|
||||||
|
int r = 0;
|
||||||
|
for (int i = 0; i < a.Length; i++) r |= a[i] ^ b[i];
|
||||||
|
return r == 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Spqr;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Polynomial fountain/erasure code over GF(2^16), ported from Signal's SparsePostQuantumRatchet
|
||||||
|
/// v1.5.1 <c>src/encoding/polynomial.rs</c>. A message is split into <see cref="NumPolys"/>=16
|
||||||
|
/// polynomials (one per 2-byte symbol position, round-robin); chunk <c>idx</c> carries each
|
||||||
|
/// polynomial evaluated at x=idx (16 symbols = 32 bytes). The first ⌈M/16⌉ chunks are the message
|
||||||
|
/// itself (systematic); later chunks are redundancy. The decoder Lagrange-interpolates each
|
||||||
|
/// polynomial once it has enough points. Used to spread large ML-KEM-768 keys/ciphertexts across
|
||||||
|
/// many ratchet messages.
|
||||||
|
/// </summary>
|
||||||
|
public static class Polynomial
|
||||||
|
{
|
||||||
|
public const int ChunkSize = 32;
|
||||||
|
public const int NumPolys = ChunkSize / 2; // 16
|
||||||
|
|
||||||
|
/// <summary>A polynomial over GF(2^16), coefficients low-order first (coeff[0] = constant term).</summary>
|
||||||
|
public sealed class Poly
|
||||||
|
{
|
||||||
|
public Gf16[] Coefficients { get; }
|
||||||
|
public Poly(Gf16[] coefficients) => Coefficients = coefficients;
|
||||||
|
|
||||||
|
/// <summary>Evaluate f(x) via Horner's method.</summary>
|
||||||
|
public Gf16 Evaluate(Gf16 x)
|
||||||
|
{
|
||||||
|
Gf16 acc = Gf16.Zero;
|
||||||
|
for (int i = Coefficients.Length - 1; i >= 0; i--)
|
||||||
|
acc = Gf16.Add(Gf16.Mul(acc, x), Coefficients[i]);
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The unique polynomial through the given (x,y) points (distinct x). Standard Lagrange.</summary>
|
||||||
|
public static Poly Interpolate((Gf16 X, Gf16 Y)[] points)
|
||||||
|
{
|
||||||
|
int n = points.Length;
|
||||||
|
var coeffs = new Gf16[n];
|
||||||
|
for (int i = 0; i < n; i++) coeffs[i] = Gf16.Zero;
|
||||||
|
if (n == 0) return new Poly(coeffs);
|
||||||
|
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
// basis_i(x) = PRODUCT_{m != i} (x - x_m) / (x_i - x_m), scaled by y_i.
|
||||||
|
var basis = new Gf16[n];
|
||||||
|
basis[0] = Gf16.One;
|
||||||
|
int deg = 0;
|
||||||
|
Gf16 denom = Gf16.One;
|
||||||
|
for (int m = 0; m < n; m++)
|
||||||
|
{
|
||||||
|
if (m == i) continue;
|
||||||
|
// multiply basis by (x - x_m): basis = basis*x + basis*(-x_m). (-x_m == x_m in GF(2^k))
|
||||||
|
var next = new Gf16[n];
|
||||||
|
for (int k = deg; k >= 0; k--)
|
||||||
|
{
|
||||||
|
// contribute basis[k] * x -> next[k+1]
|
||||||
|
next[k + 1] = Gf16.Add(next[k + 1], basis[k]);
|
||||||
|
// contribute basis[k] * x_m -> next[k]
|
||||||
|
next[k] = Gf16.Add(next[k], Gf16.Mul(basis[k], points[m].X));
|
||||||
|
}
|
||||||
|
basis = next;
|
||||||
|
deg++;
|
||||||
|
denom = Gf16.Mul(denom, Gf16.Sub(points[i].X, points[m].X));
|
||||||
|
}
|
||||||
|
Gf16 scale = Gf16.Mul(points[i].Y, Gf16.Inv(denom));
|
||||||
|
for (int k = 0; k < n; k++)
|
||||||
|
coeffs[k] = Gf16.Add(coeffs[k], Gf16.Mul(basis[k], scale));
|
||||||
|
}
|
||||||
|
return new Poly(coeffs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Encodes a message (even length) into an unbounded stream of 32-byte indexed chunks.</summary>
|
||||||
|
public sealed class Encoder
|
||||||
|
{
|
||||||
|
private readonly List<Gf16>[] _data = new List<Gf16>[NumPolys];
|
||||||
|
private readonly Poly?[] _polys = new Poly?[NumPolys];
|
||||||
|
private uint _nextIdx;
|
||||||
|
|
||||||
|
public Encoder(byte[] message)
|
||||||
|
{
|
||||||
|
if (message.Length % 2 != 0)
|
||||||
|
throw new ArgumentException("message length must be even", nameof(message));
|
||||||
|
for (int j = 0; j < NumPolys; j++) _data[j] = new List<Gf16>();
|
||||||
|
for (int i = 0; i < message.Length / 2; i++)
|
||||||
|
{
|
||||||
|
ushort v = (ushort)((message[2 * i] << 8) | message[2 * i + 1]);
|
||||||
|
_data[i % NumPolys].Add(new Gf16(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public (ushort Index, byte[] Data) NextChunk()
|
||||||
|
{
|
||||||
|
ushort idx = (ushort)_nextIdx;
|
||||||
|
_nextIdx++;
|
||||||
|
return ChunkAt(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
public (ushort Index, byte[] Data) ChunkAt(ushort idx)
|
||||||
|
{
|
||||||
|
var data = new byte[ChunkSize];
|
||||||
|
for (int j = 0; j < NumPolys; j++)
|
||||||
|
{
|
||||||
|
Gf16 v = PointAt(j, idx);
|
||||||
|
data[2 * j] = (byte)(v.Value >> 8);
|
||||||
|
data[2 * j + 1] = (byte)v.Value;
|
||||||
|
}
|
||||||
|
return (idx, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Gf16 PointAt(int poly, int idx)
|
||||||
|
{
|
||||||
|
List<Gf16> pts = _data[poly];
|
||||||
|
if (idx < pts.Count)
|
||||||
|
return pts[idx]; // systematic: original data value
|
||||||
|
|
||||||
|
// Redundancy point: interpolate (cached) and evaluate.
|
||||||
|
Poly p = _polys[poly] ??= BuildPoly(pts);
|
||||||
|
return p.Evaluate(new Gf16((ushort)idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Poly BuildPoly(List<Gf16> values)
|
||||||
|
{
|
||||||
|
var points = new (Gf16, Gf16)[values.Count];
|
||||||
|
for (int x = 0; x < values.Count; x++)
|
||||||
|
points[x] = (new Gf16((ushort)x), values[x]);
|
||||||
|
return Poly.Interpolate(points);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Encoder() { for (int j = 0; j < NumPolys; j++) _data[j] = new List<Gf16>(); }
|
||||||
|
|
||||||
|
internal void Write(BinaryWriter w)
|
||||||
|
{
|
||||||
|
w.Write(_nextIdx);
|
||||||
|
for (int j = 0; j < NumPolys; j++)
|
||||||
|
{
|
||||||
|
w.Write(_data[j].Count);
|
||||||
|
foreach (Gf16 g in _data[j]) w.Write(g.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Encoder Read(BinaryReader r)
|
||||||
|
{
|
||||||
|
var e = new Encoder();
|
||||||
|
e._nextIdx = r.ReadUInt32();
|
||||||
|
for (int j = 0; j < NumPolys; j++)
|
||||||
|
{
|
||||||
|
int c = r.ReadInt32();
|
||||||
|
for (int k = 0; k < c; k++) e._data[j].Add(new Gf16(r.ReadUInt16()));
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Collects chunks until it can reconstruct the original <paramref name="lenBytes"/>-byte message.</summary>
|
||||||
|
public sealed class Decoder
|
||||||
|
{
|
||||||
|
private readonly int _lenBytes;
|
||||||
|
private readonly int _symbolCount; // M = lenBytes/2 GF16 symbols
|
||||||
|
private readonly int[] _pointsPerPoly = new int[NumPolys];
|
||||||
|
private readonly Dictionary<ushort, Gf16[]> _chunks = new(); // idx -> 16 symbols
|
||||||
|
|
||||||
|
public Decoder(int lenBytes)
|
||||||
|
{
|
||||||
|
if (lenBytes % 2 != 0) throw new ArgumentException("length must be even", nameof(lenBytes));
|
||||||
|
_lenBytes = lenBytes;
|
||||||
|
_symbolCount = lenBytes / 2;
|
||||||
|
for (int i = 0; i < _symbolCount; i++)
|
||||||
|
_pointsPerPoly[i % NumPolys]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddChunk(ushort index, byte[] data)
|
||||||
|
{
|
||||||
|
if (data.Length != ChunkSize) throw new ArgumentException("chunk must be 32 bytes", nameof(data));
|
||||||
|
var symbols = new Gf16[NumPolys];
|
||||||
|
for (int j = 0; j < NumPolys; j++)
|
||||||
|
symbols[j] = new Gf16((ushort)((data[2 * j] << 8) | data[2 * j + 1]));
|
||||||
|
_chunks[index] = symbols;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanReconstruct()
|
||||||
|
{
|
||||||
|
for (int j = 0; j < NumPolys; j++)
|
||||||
|
if (_chunks.Count < _pointsPerPoly[j])
|
||||||
|
return false;
|
||||||
|
// Every poly needs pointsPerPoly[j] distinct x; we have _chunks.Count distinct indices,
|
||||||
|
// and max points-per-poly <= _chunks.Count is the binding constraint.
|
||||||
|
return _chunks.Count >= MaxPoints();
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[]? DecodedMessage()
|
||||||
|
{
|
||||||
|
if (!CanReconstruct()) return null;
|
||||||
|
|
||||||
|
// Sort received indices for determinism; use the first N for each poly.
|
||||||
|
ushort[] indices = _chunks.Keys.OrderBy(k => k).ToArray();
|
||||||
|
var msg = new byte[_lenBytes];
|
||||||
|
|
||||||
|
for (int j = 0; j < NumPolys; j++)
|
||||||
|
{
|
||||||
|
int need = _pointsPerPoly[j];
|
||||||
|
if (need == 0) continue;
|
||||||
|
var points = new (Gf16, Gf16)[need];
|
||||||
|
for (int k = 0; k < need; k++)
|
||||||
|
{
|
||||||
|
ushort idx = indices[k];
|
||||||
|
points[k] = (new Gf16(idx), _chunks[idx][j]);
|
||||||
|
}
|
||||||
|
Poly p = Poly.Interpolate(points);
|
||||||
|
for (int x = 0; x < need; x++)
|
||||||
|
{
|
||||||
|
int pos = x * NumPolys + j; // message symbol position
|
||||||
|
if (pos >= _symbolCount) break;
|
||||||
|
Gf16 v = x < need ? p.Evaluate(new Gf16((ushort)x)) : Gf16.Zero;
|
||||||
|
msg[2 * pos] = (byte)(v.Value >> 8);
|
||||||
|
msg[2 * pos + 1] = (byte)v.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int MaxPoints()
|
||||||
|
{
|
||||||
|
int max = 0;
|
||||||
|
for (int j = 0; j < NumPolys; j++) max = Math.Max(max, _pointsPerPoly[j]);
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Write(BinaryWriter w)
|
||||||
|
{
|
||||||
|
w.Write(_lenBytes);
|
||||||
|
w.Write(_chunks.Count);
|
||||||
|
foreach (KeyValuePair<ushort, Gf16[]> kv in _chunks)
|
||||||
|
{
|
||||||
|
w.Write(kv.Key);
|
||||||
|
foreach (Gf16 g in kv.Value) w.Write(g.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Decoder Read(BinaryReader r)
|
||||||
|
{
|
||||||
|
var d = new Decoder(r.ReadInt32());
|
||||||
|
int n = r.ReadInt32();
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
ushort idx = r.ReadUInt16();
|
||||||
|
var syms = new Gf16[NumPolys];
|
||||||
|
for (int j = 0; j < NumPolys; j++) syms[j] = new Gf16(r.ReadUInt16());
|
||||||
|
d._chunks[idx] = syms;
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
using System.IO;
|
||||||
|
using Enc = Wingnal.Protocol.Spqr.Polynomial.Encoder;
|
||||||
|
using Dec = Wingnal.Protocol.Spqr.Polynomial.Decoder;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Spqr;
|
||||||
|
|
||||||
|
/// <summary>A 32-byte fountain-code chunk with its index.</summary>
|
||||||
|
public sealed class SpqrChunk
|
||||||
|
{
|
||||||
|
public ushort Index { get; }
|
||||||
|
public byte[] Data { get; } // 32
|
||||||
|
public SpqrChunk(ushort index, byte[] data) { Index = index; Data = data; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SpqrMsgKind { None, Hdr, Ek, EkCt1Ack, Ct1Ack, Ct1, Ct2 }
|
||||||
|
|
||||||
|
/// <summary>The SPQR per-message payload (one of the V1Msg inner_msg variants).</summary>
|
||||||
|
public sealed class SpqrPayload
|
||||||
|
{
|
||||||
|
public SpqrMsgKind Kind { get; }
|
||||||
|
public SpqrChunk? Chunk { get; }
|
||||||
|
public bool Ack { get; }
|
||||||
|
|
||||||
|
private SpqrPayload(SpqrMsgKind kind, SpqrChunk? chunk, bool ack) { Kind = kind; Chunk = chunk; Ack = ack; }
|
||||||
|
|
||||||
|
public static readonly SpqrPayload None = new(SpqrMsgKind.None, null, false);
|
||||||
|
public static SpqrPayload Hdr(SpqrChunk c) => new(SpqrMsgKind.Hdr, c, false);
|
||||||
|
public static SpqrPayload Ek(SpqrChunk c) => new(SpqrMsgKind.Ek, c, false);
|
||||||
|
public static SpqrPayload EkCt1Ack(SpqrChunk c) => new(SpqrMsgKind.EkCt1Ack, c, false);
|
||||||
|
public static SpqrPayload Ct1Ack(bool ack) => new(SpqrMsgKind.Ct1Ack, null, ack);
|
||||||
|
public static SpqrPayload Ct1(SpqrChunk c) => new(SpqrMsgKind.Ct1, c, false);
|
||||||
|
public static SpqrPayload Ct2(SpqrChunk c) => new(SpqrMsgKind.Ct2, c, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An SCKA message: an epoch plus a payload.</summary>
|
||||||
|
public sealed class SpqrMessage
|
||||||
|
{
|
||||||
|
public ulong Epoch { get; }
|
||||||
|
public SpqrPayload Payload { get; }
|
||||||
|
public SpqrMessage(ulong epoch, SpqrPayload payload) { Epoch = epoch; Payload = payload; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The SPQR v1 chunked SCKA state machine (ported from SparsePostQuantumRatchet v1.5.1
|
||||||
|
/// src/v1/chunked/{states,send_ek,send_ct}.rs). It wraps the unchunked crypto states with polynomial
|
||||||
|
/// encoders/decoders so the large ML-KEM-768 header/ek/ct blobs are spread across many messages.
|
||||||
|
/// <see cref="Send"/> emits the next chunk (and possibly a new EpochSecret); <see cref="Recv"/> ingests
|
||||||
|
/// a peer chunk and advances state. Header/ct chunks carry an authenticator MAC appended to the blob.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SckaStates
|
||||||
|
{
|
||||||
|
private const int HeaderSize = MlKem768.HeaderBytes; // 64
|
||||||
|
private const int MacSize = Authenticator.MacSize; // 32
|
||||||
|
private const int Ct1Size = MlKem768.Ct1Bytes; // 960
|
||||||
|
private const int Ct2Size = MlKem768.Ct2Bytes; // 128
|
||||||
|
private const int EkSize = MlKem768.Pk2Bytes; // 1152
|
||||||
|
|
||||||
|
public sealed class SendResult
|
||||||
|
{
|
||||||
|
public required SpqrMessage Msg { get; init; }
|
||||||
|
public EpochSecret? Key { get; init; }
|
||||||
|
public required SckaStates State { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RecvResult
|
||||||
|
{
|
||||||
|
public EpochSecret? Key { get; init; }
|
||||||
|
public required SckaStates State { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exactly one of these is non-null (mirrors the Rust States enum).
|
||||||
|
private readonly object _inner;
|
||||||
|
|
||||||
|
private SckaStates(object inner) => _inner = inner;
|
||||||
|
|
||||||
|
public static SckaStates InitA(byte[] authKey) =>
|
||||||
|
new(new CKeysUnsampled(UcKeysUnsampled.New(authKey)));
|
||||||
|
|
||||||
|
public static SckaStates InitB(byte[] authKey) =>
|
||||||
|
new(new CNoHeaderReceived(UcNoHeaderReceived.New(authKey),
|
||||||
|
new Dec(HeaderSize + MacSize)));
|
||||||
|
|
||||||
|
private static ulong EpochOf(object s) => s switch
|
||||||
|
{
|
||||||
|
CKeysUnsampled x => x.Uc.Epoch,
|
||||||
|
CKeysSampled x => x.Uc.Epoch,
|
||||||
|
CHeaderSent x => x.Uc.Epoch,
|
||||||
|
CCt1Received x => x.Uc.Epoch,
|
||||||
|
CEkSentCt1Received x => x.Uc.Epoch,
|
||||||
|
CNoHeaderReceived x => x.Uc.Epoch,
|
||||||
|
CHeaderReceived x => x.Uc.Epoch,
|
||||||
|
CCt1Sampled x => x.Uc.Epoch,
|
||||||
|
CEkReceivedCt1Sampled x => x.Uc.Epoch,
|
||||||
|
CCt1Acknowledged x => x.Uc.Epoch,
|
||||||
|
CCt2Sampled x => x.Uc.Epoch,
|
||||||
|
_ => throw new SpqrException("unknown state"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ───────────────────────── send ─────────────────────────
|
||||||
|
|
||||||
|
public SendResult Send()
|
||||||
|
{
|
||||||
|
switch (_inner)
|
||||||
|
{
|
||||||
|
// send_ek
|
||||||
|
case CKeysUnsampled s:
|
||||||
|
{
|
||||||
|
ulong epoch = s.Uc.Epoch;
|
||||||
|
(UcHeaderSent uc, byte[] hdr, byte[] mac) = s.Uc.SendHeader();
|
||||||
|
var enc = new Enc(Cat(hdr, mac));
|
||||||
|
SpqrChunk chunk = Next(enc);
|
||||||
|
return new SendResult
|
||||||
|
{
|
||||||
|
Msg = new SpqrMessage(epoch, SpqrPayload.Hdr(chunk)),
|
||||||
|
State = new SckaStates(new CKeysSampled(uc, enc)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case CKeysSampled s:
|
||||||
|
{
|
||||||
|
SpqrChunk chunk = Next(s.SendingHdr);
|
||||||
|
return new SendResult
|
||||||
|
{
|
||||||
|
Msg = new SpqrMessage(s.Uc.Epoch, SpqrPayload.Hdr(chunk)),
|
||||||
|
State = this,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case CHeaderSent s:
|
||||||
|
{
|
||||||
|
SpqrChunk chunk = Next(s.SendingEk);
|
||||||
|
return new SendResult
|
||||||
|
{
|
||||||
|
Msg = new SpqrMessage(s.Uc.Epoch, SpqrPayload.Ek(chunk)),
|
||||||
|
State = this,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case CCt1Received s:
|
||||||
|
{
|
||||||
|
SpqrChunk chunk = Next(s.SendingEk);
|
||||||
|
return new SendResult
|
||||||
|
{
|
||||||
|
Msg = new SpqrMessage(s.Uc.Epoch, SpqrPayload.EkCt1Ack(chunk)),
|
||||||
|
State = this,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case CEkSentCt1Received s:
|
||||||
|
{
|
||||||
|
return new SendResult
|
||||||
|
{
|
||||||
|
Msg = new SpqrMessage(s.Uc.Epoch, SpqrPayload.Ct1Ack(true)),
|
||||||
|
State = this,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// send_ct
|
||||||
|
case CNoHeaderReceived s:
|
||||||
|
{
|
||||||
|
return new SendResult
|
||||||
|
{
|
||||||
|
Msg = new SpqrMessage(s.Uc.Epoch, SpqrPayload.None),
|
||||||
|
State = this,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case CHeaderReceived s:
|
||||||
|
{
|
||||||
|
ulong epoch = s.Uc.Epoch;
|
||||||
|
(UcCt1Sent uc, byte[] ct1, EpochSecret secret) = s.Uc.SendCt1();
|
||||||
|
var enc = new Enc(ct1);
|
||||||
|
SpqrChunk chunk = Next(enc);
|
||||||
|
return new SendResult
|
||||||
|
{
|
||||||
|
Msg = new SpqrMessage(epoch, SpqrPayload.Ct1(chunk)),
|
||||||
|
Key = secret,
|
||||||
|
State = new SckaStates(new CCt1Sampled(uc, enc, s.ReceivingEk)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case CCt1Sampled s:
|
||||||
|
{
|
||||||
|
SpqrChunk chunk = Next(s.SendingCt1);
|
||||||
|
return new SendResult
|
||||||
|
{
|
||||||
|
Msg = new SpqrMessage(s.Uc.Epoch, SpqrPayload.Ct1(chunk)),
|
||||||
|
State = this,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case CEkReceivedCt1Sampled s:
|
||||||
|
{
|
||||||
|
SpqrChunk chunk = Next(s.SendingCt1);
|
||||||
|
return new SendResult
|
||||||
|
{
|
||||||
|
Msg = new SpqrMessage(s.Uc.Epoch, SpqrPayload.Ct1(chunk)),
|
||||||
|
State = this,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case CCt1Acknowledged s:
|
||||||
|
{
|
||||||
|
return new SendResult
|
||||||
|
{
|
||||||
|
Msg = new SpqrMessage(s.Uc.Epoch, SpqrPayload.None),
|
||||||
|
State = this,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case CCt2Sampled s:
|
||||||
|
{
|
||||||
|
SpqrChunk chunk = Next(s.SendingCt2);
|
||||||
|
return new SendResult
|
||||||
|
{
|
||||||
|
Msg = new SpqrMessage(s.Uc.Epoch, SpqrPayload.Ct2(chunk)),
|
||||||
|
State = this,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default: throw new SpqrException("unknown state");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────── recv ─────────────────────────
|
||||||
|
|
||||||
|
public RecvResult Recv(SpqrMessage msg)
|
||||||
|
{
|
||||||
|
EpochSecret? key = null;
|
||||||
|
object newState;
|
||||||
|
ulong epoch = EpochOf(_inner);
|
||||||
|
|
||||||
|
switch (_inner)
|
||||||
|
{
|
||||||
|
// send_ek
|
||||||
|
case CKeysUnsampled s:
|
||||||
|
RequireNotGreater(msg.Epoch, epoch);
|
||||||
|
newState = s; // Less or Equal: stay
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CKeysSampled s:
|
||||||
|
RequireNotGreater(msg.Epoch, epoch);
|
||||||
|
if (msg.Epoch == epoch && msg.Payload.Kind == SpqrMsgKind.Ct1)
|
||||||
|
{
|
||||||
|
UcEkSent uc; byte[] ek;
|
||||||
|
(uc, ek) = s.Uc.SendEk();
|
||||||
|
var ct1Dec = new Dec(Ct1Size);
|
||||||
|
ct1Dec.AddChunk(msg.Payload.Chunk!.Index, msg.Payload.Chunk.Data);
|
||||||
|
newState = new CHeaderSent(uc, new Enc(ek), ct1Dec);
|
||||||
|
}
|
||||||
|
else newState = s;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CHeaderSent s:
|
||||||
|
RequireNotGreater(msg.Epoch, epoch);
|
||||||
|
if (msg.Epoch == epoch && msg.Payload.Kind == SpqrMsgKind.Ct1)
|
||||||
|
{
|
||||||
|
s.ReceivingCt1.AddChunk(msg.Payload.Chunk!.Index, msg.Payload.Chunk.Data);
|
||||||
|
byte[]? decoded = s.ReceivingCt1.DecodedMessage();
|
||||||
|
if (decoded is not null)
|
||||||
|
{
|
||||||
|
UcEkSentCt1Received uc = s.Uc.RecvCt1(msg.Epoch, decoded);
|
||||||
|
newState = new CCt1Received(uc, s.SendingEk);
|
||||||
|
}
|
||||||
|
else newState = s;
|
||||||
|
}
|
||||||
|
else newState = s;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CCt1Received s:
|
||||||
|
RequireNotGreater(msg.Epoch, epoch);
|
||||||
|
if (msg.Epoch == epoch && msg.Payload.Kind == SpqrMsgKind.Ct2)
|
||||||
|
{
|
||||||
|
var ct2Dec = new Dec(Ct2Size + MacSize);
|
||||||
|
ct2Dec.AddChunk(msg.Payload.Chunk!.Index, msg.Payload.Chunk.Data);
|
||||||
|
newState = new CEkSentCt1Received(s.Uc, ct2Dec);
|
||||||
|
}
|
||||||
|
else newState = s;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CEkSentCt1Received s:
|
||||||
|
RequireNotGreater(msg.Epoch, epoch);
|
||||||
|
if (msg.Epoch == epoch && msg.Payload.Kind == SpqrMsgKind.Ct2)
|
||||||
|
{
|
||||||
|
s.ReceivingCt2.AddChunk(msg.Payload.Chunk!.Index, msg.Payload.Chunk.Data);
|
||||||
|
byte[]? decoded = s.ReceivingCt2.DecodedMessage();
|
||||||
|
if (decoded is not null)
|
||||||
|
{
|
||||||
|
byte[] ct2 = decoded[..Ct2Size];
|
||||||
|
byte[] mac = decoded[Ct2Size..];
|
||||||
|
(UcNoHeaderReceived uc, EpochSecret sec) = s.Uc.RecvCt2(ct2, mac);
|
||||||
|
key = sec;
|
||||||
|
newState = new CNoHeaderReceived(uc, new Dec(HeaderSize + MacSize));
|
||||||
|
}
|
||||||
|
else newState = s;
|
||||||
|
}
|
||||||
|
else newState = s;
|
||||||
|
break;
|
||||||
|
|
||||||
|
// send_ct
|
||||||
|
case CNoHeaderReceived s:
|
||||||
|
RequireNotGreater(msg.Epoch, epoch);
|
||||||
|
if (msg.Epoch == epoch && msg.Payload.Kind == SpqrMsgKind.Hdr)
|
||||||
|
{
|
||||||
|
s.ReceivingHdr.AddChunk(msg.Payload.Chunk!.Index, msg.Payload.Chunk.Data);
|
||||||
|
byte[]? decoded = s.ReceivingHdr.DecodedMessage();
|
||||||
|
if (decoded is not null)
|
||||||
|
{
|
||||||
|
byte[] hdr = decoded[..HeaderSize];
|
||||||
|
byte[] mac = decoded[HeaderSize..];
|
||||||
|
UcHeaderReceived uc = s.Uc.RecvHeader(msg.Epoch, hdr, mac);
|
||||||
|
newState = new CHeaderReceived(uc, new Dec(EkSize));
|
||||||
|
}
|
||||||
|
else newState = s;
|
||||||
|
}
|
||||||
|
else newState = s;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CHeaderReceived s:
|
||||||
|
RequireNotGreater(msg.Epoch, epoch);
|
||||||
|
newState = s; // no recv transition; we only send_ct1 from here
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CCt1Sampled s:
|
||||||
|
RequireNotGreater(msg.Epoch, epoch);
|
||||||
|
if (msg.Epoch == epoch)
|
||||||
|
{
|
||||||
|
SpqrChunk? chunk = null; bool ack = false;
|
||||||
|
if (msg.Payload.Kind == SpqrMsgKind.Ek) { chunk = msg.Payload.Chunk; ack = false; }
|
||||||
|
else if (msg.Payload.Kind == SpqrMsgKind.EkCt1Ack) { chunk = msg.Payload.Chunk; ack = true; }
|
||||||
|
if (chunk is not null)
|
||||||
|
{
|
||||||
|
s.ReceivingEk.AddChunk(chunk.Index, chunk.Data);
|
||||||
|
byte[]? decoded = s.ReceivingEk.DecodedMessage();
|
||||||
|
if (decoded is not null)
|
||||||
|
{
|
||||||
|
UcCt1SentEkReceived uc = s.Uc.RecvEk(msg.Epoch, decoded);
|
||||||
|
if (ack)
|
||||||
|
{
|
||||||
|
(UcCt2Sent uc2, byte[] ct2, byte[] mac) = uc.SendCt2();
|
||||||
|
newState = new CCt2Sampled(uc2, new Enc(Cat(ct2, mac)));
|
||||||
|
}
|
||||||
|
else newState = new CEkReceivedCt1Sampled(uc, s.SendingCt1);
|
||||||
|
}
|
||||||
|
else if (ack)
|
||||||
|
newState = new CCt1Acknowledged(s.Uc, s.ReceivingEk);
|
||||||
|
else newState = s;
|
||||||
|
}
|
||||||
|
else newState = s;
|
||||||
|
}
|
||||||
|
else newState = s;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CEkReceivedCt1Sampled s:
|
||||||
|
RequireNotGreater(msg.Epoch, epoch);
|
||||||
|
if (msg.Epoch == epoch &&
|
||||||
|
((msg.Payload.Kind == SpqrMsgKind.Ct1Ack && msg.Payload.Ack) ||
|
||||||
|
msg.Payload.Kind == SpqrMsgKind.EkCt1Ack))
|
||||||
|
{
|
||||||
|
(UcCt2Sent uc2, byte[] ct2, byte[] mac) = s.Uc.SendCt2();
|
||||||
|
newState = new CCt2Sampled(uc2, new Enc(Cat(ct2, mac)));
|
||||||
|
}
|
||||||
|
else newState = s;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CCt1Acknowledged s:
|
||||||
|
RequireNotGreater(msg.Epoch, epoch);
|
||||||
|
if (msg.Epoch == epoch)
|
||||||
|
{
|
||||||
|
SpqrChunk? chunk = msg.Payload.Kind is SpqrMsgKind.Ek or SpqrMsgKind.EkCt1Ack
|
||||||
|
? msg.Payload.Chunk : null;
|
||||||
|
if (chunk is not null)
|
||||||
|
{
|
||||||
|
s.ReceivingEk.AddChunk(chunk.Index, chunk.Data);
|
||||||
|
byte[]? decoded = s.ReceivingEk.DecodedMessage();
|
||||||
|
if (decoded is not null)
|
||||||
|
{
|
||||||
|
UcCt1SentEkReceived uc = s.Uc.RecvEk(msg.Epoch, decoded);
|
||||||
|
(UcCt2Sent uc2, byte[] ct2, byte[] mac) = uc.SendCt2();
|
||||||
|
newState = new CCt2Sampled(uc2, new Enc(Cat(ct2, mac)));
|
||||||
|
}
|
||||||
|
else newState = s;
|
||||||
|
}
|
||||||
|
else newState = s;
|
||||||
|
}
|
||||||
|
else newState = s;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CCt2Sampled s:
|
||||||
|
if (msg.Epoch > epoch)
|
||||||
|
{
|
||||||
|
if (msg.Epoch == epoch + 1)
|
||||||
|
{
|
||||||
|
UcKeysUnsampled uc = s.Uc.RecvNextEpoch(msg.Epoch);
|
||||||
|
newState = new CKeysUnsampled(uc);
|
||||||
|
}
|
||||||
|
else throw new SpqrException($"epoch out of range: {msg.Epoch}");
|
||||||
|
}
|
||||||
|
else newState = s; // Less or Equal: stay
|
||||||
|
break;
|
||||||
|
|
||||||
|
default: throw new SpqrException("unknown state");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RecvResult { Key = key, State = newState == _inner ? this : new SckaStates(newState) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RequireNotGreater(ulong msgEpoch, ulong stateEpoch)
|
||||||
|
{
|
||||||
|
if (msgEpoch > stateEpoch) throw new SpqrException($"epoch out of range: {msgEpoch}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SpqrChunk Next(Enc enc)
|
||||||
|
{
|
||||||
|
(ushort idx, byte[] data) = enc.NextChunk();
|
||||||
|
return new SpqrChunk(idx, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Cat(byte[] a, byte[] b)
|
||||||
|
{
|
||||||
|
var r = new byte[a.Length + b.Length];
|
||||||
|
Buffer.BlockCopy(a, 0, r, 0, a.Length);
|
||||||
|
Buffer.BlockCopy(b, 0, r, a.Length, b.Length);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── serialization (local-only state persistence) ──
|
||||||
|
|
||||||
|
internal void Write(BinaryWriter w)
|
||||||
|
{
|
||||||
|
switch (_inner)
|
||||||
|
{
|
||||||
|
case CKeysUnsampled s: w.Write((byte)1); WKeysUnsampled(w, s.Uc); break;
|
||||||
|
case CKeysSampled s: w.Write((byte)2); WHeaderSent(w, s.Uc); s.SendingHdr.Write(w); break;
|
||||||
|
case CHeaderSent s: w.Write((byte)3); WEkSent(w, s.Uc); s.SendingEk.Write(w); s.ReceivingCt1.Write(w); break;
|
||||||
|
case CCt1Received s: w.Write((byte)4); WEkSentCt1Received(w, s.Uc); s.SendingEk.Write(w); break;
|
||||||
|
case CEkSentCt1Received s: w.Write((byte)5); WEkSentCt1Received(w, s.Uc); s.ReceivingCt2.Write(w); break;
|
||||||
|
case CNoHeaderReceived s: w.Write((byte)6); WNoHeaderReceived(w, s.Uc); s.ReceivingHdr.Write(w); break;
|
||||||
|
case CHeaderReceived s: w.Write((byte)7); WHeaderReceived(w, s.Uc); s.ReceivingEk.Write(w); break;
|
||||||
|
case CCt1Sampled s: w.Write((byte)8); WCt1Sent(w, s.Uc); s.SendingCt1.Write(w); s.ReceivingEk.Write(w); break;
|
||||||
|
case CEkReceivedCt1Sampled s: w.Write((byte)9); WCt1SentEkReceived(w, s.Uc); s.SendingCt1.Write(w); break;
|
||||||
|
case CCt1Acknowledged s: w.Write((byte)10); WCt1Sent(w, s.Uc); s.ReceivingEk.Write(w); break;
|
||||||
|
case CCt2Sampled s: w.Write((byte)11); WCt2Sent(w, s.Uc); s.SendingCt2.Write(w); break;
|
||||||
|
default: throw new SpqrException("unknown state");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static SckaStates Read(BinaryReader r)
|
||||||
|
{
|
||||||
|
byte tag = r.ReadByte();
|
||||||
|
object inner = tag switch
|
||||||
|
{
|
||||||
|
1 => new CKeysUnsampled(RKeysUnsampled(r)),
|
||||||
|
2 => new CKeysSampled(RHeaderSent(r), Enc.Read(r)),
|
||||||
|
3 => new CHeaderSent(REkSent(r), Enc.Read(r), Dec.Read(r)),
|
||||||
|
4 => new CCt1Received(REkSentCt1Received(r), Enc.Read(r)),
|
||||||
|
5 => new CEkSentCt1Received(REkSentCt1Received(r), Dec.Read(r)),
|
||||||
|
6 => new CNoHeaderReceived(RNoHeaderReceived(r), Dec.Read(r)),
|
||||||
|
7 => new CHeaderReceived(RHeaderReceived(r), Dec.Read(r)),
|
||||||
|
8 => new CCt1Sampled(RCt1Sent(r), Enc.Read(r), Dec.Read(r)),
|
||||||
|
9 => new CEkReceivedCt1Sampled(RCt1SentEkReceived(r), Enc.Read(r)),
|
||||||
|
10 => new CCt1Acknowledged(RCt1Sent(r), Dec.Read(r)),
|
||||||
|
11 => new CCt2Sampled(RCt2Sent(r), Enc.Read(r)),
|
||||||
|
_ => throw new SpqrException("unknown state tag"),
|
||||||
|
};
|
||||||
|
return new SckaStates(inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unchunked-state field writers/readers (fields are public; Authenticator round-trips itself).
|
||||||
|
private static void WKeysUnsampled(BinaryWriter w, UcKeysUnsampled u) { w.Write(u.Epoch); u.Auth.Write(w); }
|
||||||
|
private static UcKeysUnsampled RKeysUnsampled(BinaryReader r) => new(r.ReadUInt64(), Authenticator.Read(r));
|
||||||
|
|
||||||
|
private static void WHeaderSent(BinaryWriter w, UcHeaderSent u) { w.Write(u.Epoch); u.Auth.Write(w); w.WriteBlob(u.Ek); w.WriteBlob(u.Dk); }
|
||||||
|
private static UcHeaderSent RHeaderSent(BinaryReader r) => new(r.ReadUInt64(), Authenticator.Read(r), r.ReadBlob(), r.ReadBlob());
|
||||||
|
|
||||||
|
private static void WEkSent(BinaryWriter w, UcEkSent u) { w.Write(u.Epoch); u.Auth.Write(w); w.WriteBlob(u.Dk); }
|
||||||
|
private static UcEkSent REkSent(BinaryReader r) => new(r.ReadUInt64(), Authenticator.Read(r), r.ReadBlob());
|
||||||
|
|
||||||
|
private static void WEkSentCt1Received(BinaryWriter w, UcEkSentCt1Received u) { w.Write(u.Epoch); u.Auth.Write(w); w.WriteBlob(u.Dk); w.WriteBlob(u.Ct1); }
|
||||||
|
private static UcEkSentCt1Received REkSentCt1Received(BinaryReader r) => new(r.ReadUInt64(), Authenticator.Read(r), r.ReadBlob(), r.ReadBlob());
|
||||||
|
|
||||||
|
private static void WNoHeaderReceived(BinaryWriter w, UcNoHeaderReceived u) { w.Write(u.Epoch); u.Auth.Write(w); }
|
||||||
|
private static UcNoHeaderReceived RNoHeaderReceived(BinaryReader r) => new(r.ReadUInt64(), Authenticator.Read(r));
|
||||||
|
|
||||||
|
private static void WHeaderReceived(BinaryWriter w, UcHeaderReceived u) { w.Write(u.Epoch); u.Auth.Write(w); w.WriteBlob(u.Hdr); }
|
||||||
|
private static UcHeaderReceived RHeaderReceived(BinaryReader r) => new(r.ReadUInt64(), Authenticator.Read(r), r.ReadBlob());
|
||||||
|
|
||||||
|
private static void WCt1Sent(BinaryWriter w, UcCt1Sent u) { w.Write(u.Epoch); u.Auth.Write(w); w.WriteBlob(u.Hdr); w.WriteBlob(u.Es); w.WriteBlob(u.Ct1); }
|
||||||
|
private static UcCt1Sent RCt1Sent(BinaryReader r) => new(r.ReadUInt64(), Authenticator.Read(r), r.ReadBlob(), r.ReadBlob(), r.ReadBlob());
|
||||||
|
|
||||||
|
private static void WCt1SentEkReceived(BinaryWriter w, UcCt1SentEkReceived u) { w.Write(u.Epoch); u.Auth.Write(w); w.WriteBlob(u.Es); w.WriteBlob(u.Ek); w.WriteBlob(u.Ct1); }
|
||||||
|
private static UcCt1SentEkReceived RCt1SentEkReceived(BinaryReader r) => new(r.ReadUInt64(), Authenticator.Read(r), r.ReadBlob(), r.ReadBlob(), r.ReadBlob());
|
||||||
|
|
||||||
|
private static void WCt2Sent(BinaryWriter w, UcCt2Sent u) { w.Write(u.Epoch); u.Auth.Write(w); }
|
||||||
|
private static UcCt2Sent RCt2Sent(BinaryReader r) => new(r.ReadUInt64(), Authenticator.Read(r));
|
||||||
|
|
||||||
|
// ── chunked state holders (mirror proto V1State.Chunked.*) ──
|
||||||
|
private sealed class CKeysUnsampled { public UcKeysUnsampled Uc; public CKeysUnsampled(UcKeysUnsampled uc) => Uc = uc; }
|
||||||
|
private sealed class CKeysSampled { public UcHeaderSent Uc; public Enc SendingHdr; public CKeysSampled(UcHeaderSent uc, Enc h) { Uc = uc; SendingHdr = h; } }
|
||||||
|
private sealed class CHeaderSent { public UcEkSent Uc; public Enc SendingEk; public Dec ReceivingCt1; public CHeaderSent(UcEkSent uc, Enc e, Dec d) { Uc = uc; SendingEk = e; ReceivingCt1 = d; } }
|
||||||
|
private sealed class CCt1Received { public UcEkSentCt1Received Uc; public Enc SendingEk; public CCt1Received(UcEkSentCt1Received uc, Enc e) { Uc = uc; SendingEk = e; } }
|
||||||
|
private sealed class CEkSentCt1Received { public UcEkSentCt1Received Uc; public Dec ReceivingCt2; public CEkSentCt1Received(UcEkSentCt1Received uc, Dec d) { Uc = uc; ReceivingCt2 = d; } }
|
||||||
|
|
||||||
|
private sealed class CNoHeaderReceived { public UcNoHeaderReceived Uc; public Dec ReceivingHdr; public CNoHeaderReceived(UcNoHeaderReceived uc, Dec d) { Uc = uc; ReceivingHdr = d; } }
|
||||||
|
private sealed class CHeaderReceived { public UcHeaderReceived Uc; public Dec ReceivingEk; public CHeaderReceived(UcHeaderReceived uc, Dec d) { Uc = uc; ReceivingEk = d; } }
|
||||||
|
private sealed class CCt1Sampled { public UcCt1Sent Uc; public Enc SendingCt1; public Dec ReceivingEk; public CCt1Sampled(UcCt1Sent uc, Enc e, Dec d) { Uc = uc; SendingCt1 = e; ReceivingEk = d; } }
|
||||||
|
private sealed class CEkReceivedCt1Sampled { public UcCt1SentEkReceived Uc; public Enc SendingCt1; public CEkReceivedCt1Sampled(UcCt1SentEkReceived uc, Enc e) { Uc = uc; SendingCt1 = e; } }
|
||||||
|
private sealed class CCt1Acknowledged { public UcCt1Sent Uc; public Dec ReceivingEk; public CCt1Acknowledged(UcCt1Sent uc, Dec d) { Uc = uc; ReceivingEk = d; } }
|
||||||
|
private sealed class CCt2Sampled { public UcCt2Sent Uc; public Enc SendingCt2; public CCt2Sampled(UcCt2Sent uc, Enc e) { Uc = uc; SendingCt2 = e; } }
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Wingnal.Protocol.Crypto;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Spqr;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The "unchunked" SPQR v1 SCKA crypto states (ported from SparsePostQuantumRatchet v1.5.1
|
||||||
|
/// src/v1/unchunked/{send_ek,send_ct}.rs). These hold the raw ML-KEM-768 material + Authenticator and
|
||||||
|
/// perform the actual KEM operations; the chunked layer (<see cref="SckaChunked"/>) spreads the large
|
||||||
|
/// header/ek/ct byte blobs across many ratchet messages via the polynomial fountain code.
|
||||||
|
///
|
||||||
|
/// Two role tracks alternate per epoch:
|
||||||
|
/// send_ek: KeysUnsampled → HeaderSent → EkSent → EkSentCt1Received → (switch to send_ct, epoch+1)
|
||||||
|
/// send_ct: NoHeaderReceived → HeaderReceived → Ct1Sent → Ct1SentEkReceived → Ct2Sent → (switch, epoch+1)
|
||||||
|
/// </summary>
|
||||||
|
internal static class SckaKdf
|
||||||
|
{
|
||||||
|
private static readonly byte[] ZeroSalt = new byte[32];
|
||||||
|
private static readonly byte[] SckaKeyLabel = "Signal_PQCKA_V1_MLKEM768:SCKA Key"u8.ToArray();
|
||||||
|
|
||||||
|
/// <summary>HKDF(salt=0^32, ikm=ss, info="…SCKA Key"‖BE64(epoch), 32) — turns a raw ML-KEM shared
|
||||||
|
/// secret into the per-epoch secret mixed into the symmetric Chain.</summary>
|
||||||
|
public static byte[] DeriveEpochSecret(byte[] ss, ulong epoch)
|
||||||
|
{
|
||||||
|
var info = new byte[SckaKeyLabel.Length + 8];
|
||||||
|
Buffer.BlockCopy(SckaKeyLabel, 0, info, 0, SckaKeyLabel.Length);
|
||||||
|
for (int i = 7; i >= 0; i--) { info[SckaKeyLabel.Length + i] = (byte)(epoch & 0xFF); epoch >>= 8; }
|
||||||
|
return CryptoPrimitives.Hkdf(ss, ZeroSalt, info, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] Random32()
|
||||||
|
{
|
||||||
|
var b = new byte[32];
|
||||||
|
RandomNumberGenerator.Fill(b);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────── send_ek track ─────────────────────────
|
||||||
|
|
||||||
|
internal sealed class UcKeysUnsampled
|
||||||
|
{
|
||||||
|
public ulong Epoch;
|
||||||
|
public Authenticator Auth;
|
||||||
|
|
||||||
|
public UcKeysUnsampled(ulong epoch, Authenticator auth) { Epoch = epoch; Auth = auth; }
|
||||||
|
public static UcKeysUnsampled New(byte[] authKey) => new(1, new Authenticator(authKey, 1));
|
||||||
|
|
||||||
|
/// <summary>Generates a fresh ML-KEM keypair, MACs its header, advances to HeaderSent.</summary>
|
||||||
|
public (UcHeaderSent State, byte[] Hdr, byte[] Mac) SendHeader()
|
||||||
|
{
|
||||||
|
MlKem768.Keys keys = MlKem768.Generate(SckaKdf.Random32(), SckaKdf.Random32());
|
||||||
|
byte[] mac = Auth.MacHeader(Epoch, keys.Header);
|
||||||
|
return (new UcHeaderSent(Epoch, Auth, keys.Ek, keys.Dk), keys.Header, mac);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class UcHeaderSent
|
||||||
|
{
|
||||||
|
public ulong Epoch;
|
||||||
|
public Authenticator Auth;
|
||||||
|
public byte[] Ek; // 1152
|
||||||
|
public byte[] Dk; // 2400
|
||||||
|
|
||||||
|
public UcHeaderSent(ulong epoch, Authenticator auth, byte[] ek, byte[] dk)
|
||||||
|
{ Epoch = epoch; Auth = auth; Ek = ek; Dk = dk; }
|
||||||
|
|
||||||
|
public (UcEkSent State, byte[] Ek) SendEk() => (new UcEkSent(Epoch, Auth, Dk), Ek);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class UcEkSent
|
||||||
|
{
|
||||||
|
public ulong Epoch;
|
||||||
|
public Authenticator Auth;
|
||||||
|
public byte[] Dk;
|
||||||
|
|
||||||
|
public UcEkSent(ulong epoch, Authenticator auth, byte[] dk) { Epoch = epoch; Auth = auth; Dk = dk; }
|
||||||
|
|
||||||
|
public UcEkSentCt1Received RecvCt1(ulong epoch, byte[] ct1)
|
||||||
|
{
|
||||||
|
if (epoch != Epoch) throw new SpqrException("epoch mismatch");
|
||||||
|
return new UcEkSentCt1Received(Epoch, Auth, Dk, ct1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class UcEkSentCt1Received
|
||||||
|
{
|
||||||
|
public ulong Epoch;
|
||||||
|
public Authenticator Auth;
|
||||||
|
public byte[] Dk;
|
||||||
|
public byte[] Ct1; // 960
|
||||||
|
|
||||||
|
public UcEkSentCt1Received(ulong epoch, Authenticator auth, byte[] dk, byte[] ct1)
|
||||||
|
{ Epoch = epoch; Auth = auth; Dk = dk; Ct1 = ct1; }
|
||||||
|
|
||||||
|
/// <summary>Decapsulates ct1‖ct2, derives + mixes the epoch secret, verifies the ciphertext MAC,
|
||||||
|
/// and switches into the send_ct track for the next epoch.</summary>
|
||||||
|
public (UcNoHeaderReceived State, EpochSecret Secret) RecvCt2(byte[] ct2, byte[] mac)
|
||||||
|
{
|
||||||
|
byte[] ss = MlKem768.DecapsIncremental(Dk, Ct1, ct2);
|
||||||
|
byte[] secret = SckaKdf.DeriveEpochSecret(ss, Epoch);
|
||||||
|
Auth.Update(Epoch, secret);
|
||||||
|
var full = new byte[Ct1.Length + ct2.Length];
|
||||||
|
Buffer.BlockCopy(Ct1, 0, full, 0, Ct1.Length);
|
||||||
|
Buffer.BlockCopy(ct2, 0, full, Ct1.Length, ct2.Length);
|
||||||
|
if (!Auth.VerifyCiphertext(Epoch, full, mac))
|
||||||
|
throw new SpqrException("ciphertext MAC verification failed");
|
||||||
|
return (new UcNoHeaderReceived(Epoch + 1, Auth), new EpochSecret(Epoch, secret));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────── send_ct track ─────────────────────────
|
||||||
|
|
||||||
|
internal sealed class UcNoHeaderReceived
|
||||||
|
{
|
||||||
|
public ulong Epoch;
|
||||||
|
public Authenticator Auth;
|
||||||
|
|
||||||
|
public UcNoHeaderReceived(ulong epoch, Authenticator auth) { Epoch = epoch; Auth = auth; }
|
||||||
|
public static UcNoHeaderReceived New(byte[] authKey) => new(1, new Authenticator(authKey, 1));
|
||||||
|
|
||||||
|
public UcHeaderReceived RecvHeader(ulong epoch, byte[] hdr, byte[] mac)
|
||||||
|
{
|
||||||
|
if (epoch != Epoch) throw new SpqrException("epoch mismatch");
|
||||||
|
if (!Auth.VerifyHeader(Epoch, hdr, mac)) throw new SpqrException("header MAC verification failed");
|
||||||
|
return new UcHeaderReceived(Epoch, Auth, hdr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class UcHeaderReceived
|
||||||
|
{
|
||||||
|
public ulong Epoch;
|
||||||
|
public Authenticator Auth;
|
||||||
|
public byte[] Hdr; // 64
|
||||||
|
|
||||||
|
public UcHeaderReceived(ulong epoch, Authenticator auth, byte[] hdr) { Epoch = epoch; Auth = auth; Hdr = hdr; }
|
||||||
|
|
||||||
|
/// <summary>encaps1 against the received header, derives + mixes the epoch secret.</summary>
|
||||||
|
public (UcCt1Sent State, byte[] Ct1, EpochSecret Secret) SendCt1()
|
||||||
|
{
|
||||||
|
MlKem768.Encaps1(Hdr, SckaKdf.Random32(), out byte[] ct1, out byte[] es, out byte[] ss);
|
||||||
|
byte[] secret = SckaKdf.DeriveEpochSecret(ss, Epoch);
|
||||||
|
Auth.Update(Epoch, secret);
|
||||||
|
return (new UcCt1Sent(Epoch, Auth, Hdr, es, ct1), ct1, new EpochSecret(Epoch, secret));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class UcCt1Sent
|
||||||
|
{
|
||||||
|
public ulong Epoch;
|
||||||
|
public Authenticator Auth;
|
||||||
|
public byte[] Hdr; // 64
|
||||||
|
public byte[] Es; // encaps state (local)
|
||||||
|
public byte[] Ct1; // 960
|
||||||
|
|
||||||
|
public UcCt1Sent(ulong epoch, Authenticator auth, byte[] hdr, byte[] es, byte[] ct1)
|
||||||
|
{ Epoch = epoch; Auth = auth; Hdr = hdr; Es = es; Ct1 = ct1; }
|
||||||
|
|
||||||
|
public UcCt1SentEkReceived RecvEk(ulong epoch, byte[] ek)
|
||||||
|
{
|
||||||
|
if (epoch != Epoch) throw new SpqrException("epoch mismatch");
|
||||||
|
if (!MlKem768.EkMatchesHeader(ek, Hdr)) throw new SpqrException("erroneous data received");
|
||||||
|
return new UcCt1SentEkReceived(Epoch, Auth, Es, ek, Ct1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class UcCt1SentEkReceived
|
||||||
|
{
|
||||||
|
public ulong Epoch;
|
||||||
|
public Authenticator Auth;
|
||||||
|
public byte[] Es;
|
||||||
|
public byte[] Ek; // 1152
|
||||||
|
public byte[] Ct1; // 960
|
||||||
|
|
||||||
|
public UcCt1SentEkReceived(ulong epoch, Authenticator auth, byte[] es, byte[] ek, byte[] ct1)
|
||||||
|
{ Epoch = epoch; Auth = auth; Es = es; Ek = ek; Ct1 = ct1; }
|
||||||
|
|
||||||
|
/// <summary>encaps2 to produce ct2, then MAC ct1‖ct2.</summary>
|
||||||
|
public (UcCt2Sent State, byte[] Ct2, byte[] Mac) SendCt2()
|
||||||
|
{
|
||||||
|
byte[] ct2 = MlKem768.Encaps2(Ek, Es);
|
||||||
|
var full = new byte[Ct1.Length + ct2.Length];
|
||||||
|
Buffer.BlockCopy(Ct1, 0, full, 0, Ct1.Length);
|
||||||
|
Buffer.BlockCopy(ct2, 0, full, Ct1.Length, ct2.Length);
|
||||||
|
byte[] mac = Auth.MacCiphertext(Epoch, full);
|
||||||
|
return (new UcCt2Sent(Epoch, Auth), ct2, mac);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class UcCt2Sent
|
||||||
|
{
|
||||||
|
public ulong Epoch;
|
||||||
|
public Authenticator Auth;
|
||||||
|
|
||||||
|
public UcCt2Sent(ulong epoch, Authenticator auth) { Epoch = epoch; Auth = auth; }
|
||||||
|
|
||||||
|
public UcKeysUnsampled RecvNextEpoch(ulong nextEpoch)
|
||||||
|
{
|
||||||
|
if (nextEpoch != Epoch + 1) throw new SpqrException("epoch must advance by one");
|
||||||
|
return new UcKeysUnsampled(Epoch + 1, Auth);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Spqr;
|
||||||
|
|
||||||
|
/// <summary>Length-prefixed binary helpers for the SPQR/session state serialization. The format is
|
||||||
|
/// local-only (never sent to a peer) — the ML-KEM Braid spec leaves state serialization
|
||||||
|
/// implementation-defined — so this compact custom encoding is sufficient.</summary>
|
||||||
|
internal static class Bin
|
||||||
|
{
|
||||||
|
public static void WriteBlob(this BinaryWriter w, byte[] b)
|
||||||
|
{
|
||||||
|
w.Write(b.Length);
|
||||||
|
w.Write(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] ReadBlob(this BinaryReader r)
|
||||||
|
{
|
||||||
|
int n = r.ReadInt32();
|
||||||
|
return r.ReadBytes(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.Spqr;
|
||||||
|
|
||||||
|
public enum SpqrVersion { V0 = 0, V1 = 1 }
|
||||||
|
|
||||||
|
/// <summary>Parameters for <see cref="SpqrRatchet.InitialState"/> (mirrors lib.rs Params).</summary>
|
||||||
|
public sealed class SpqrParams
|
||||||
|
{
|
||||||
|
public required Direction Direction { get; init; }
|
||||||
|
public required SpqrVersion Version { get; init; }
|
||||||
|
public required SpqrVersion MinVersion { get; init; }
|
||||||
|
public required byte[] AuthKey { get; init; }
|
||||||
|
public ChainParams ChainParams { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Top-level SPQR ratchet API (ported from SparsePostQuantumRatchet v1.5.1 src/lib.rs). Glues the
|
||||||
|
/// chunked SCKA state machine to the symmetric <see cref="Chain"/>: <see cref="Send"/> emits the wire
|
||||||
|
/// <c>pq_ratchet</c> bytes plus an optional 32-byte message key (the HKDF salt libsignal mixes into the
|
||||||
|
/// per-message WhisperMessageKeys), and <see cref="Recv"/> consumes peer bytes and returns the matching
|
||||||
|
/// salt. State is kept in-memory (object form); proto serialization for durable storage is layered on
|
||||||
|
/// separately. Only V1 is implemented (libsignal mandates V1/min-V1 on both ends).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SpqrRatchet
|
||||||
|
{
|
||||||
|
private SckaStates? _inner; // null ⇒ V0 (disabled)
|
||||||
|
private Chain? _chain;
|
||||||
|
|
||||||
|
// Version-negotiation block (present until the first recv); needed to lazily build the Chain.
|
||||||
|
private bool _hasVn;
|
||||||
|
private byte[]? _vnAuthKey;
|
||||||
|
private Direction _vnDirection;
|
||||||
|
private SpqrVersion _vnMinVersion;
|
||||||
|
private ChainParams _vnChainParams = new();
|
||||||
|
|
||||||
|
private SpqrRatchet() { }
|
||||||
|
|
||||||
|
public SpqrVersion CurrentVersion => _inner is null ? SpqrVersion.V0 : SpqrVersion.V1;
|
||||||
|
|
||||||
|
public static SpqrRatchet InitialState(SpqrParams p)
|
||||||
|
{
|
||||||
|
var r = new SpqrRatchet();
|
||||||
|
if (p.Version == SpqrVersion.V0) return r; // empty/disabled
|
||||||
|
|
||||||
|
r._inner = p.Direction == Direction.A2B
|
||||||
|
? SckaStates.InitA(p.AuthKey)
|
||||||
|
: SckaStates.InitB(p.AuthKey);
|
||||||
|
r._hasVn = true;
|
||||||
|
r._vnAuthKey = p.AuthKey;
|
||||||
|
r._vnDirection = p.Direction;
|
||||||
|
r._vnMinVersion = p.MinVersion;
|
||||||
|
r._vnChainParams = p.ChainParams;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SendOutput
|
||||||
|
{
|
||||||
|
public required byte[] Message { get; init; } // wire pq_ratchet bytes (empty for V0)
|
||||||
|
public byte[]? Key { get; init; } // 32-byte HKDF salt, or null
|
||||||
|
}
|
||||||
|
|
||||||
|
public SendOutput Send()
|
||||||
|
{
|
||||||
|
if (_inner is null) return new SendOutput { Message = Array.Empty<byte>(), Key = null };
|
||||||
|
|
||||||
|
SckaStates.SendResult sr = _inner.Send();
|
||||||
|
|
||||||
|
Chain? chain;
|
||||||
|
if (_chain is not null) chain = _chain;
|
||||||
|
else if (_hasVn) chain = _vnMinVersion > SpqrVersion.V0 ? new Chain(_vnAuthKey!, _vnDirection, _vnChainParams) : null;
|
||||||
|
else throw new SpqrException("chain not available");
|
||||||
|
|
||||||
|
uint index; byte[] msgKey;
|
||||||
|
if (chain is null) { index = 0; msgKey = Array.Empty<byte>(); }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (sr.Key is not null) chain.AddEpoch(sr.Key);
|
||||||
|
(index, msgKey) = chain.SendKey(sr.Msg.Epoch - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] wire = SerializeMessage(sr.Msg, index);
|
||||||
|
_inner = sr.State;
|
||||||
|
if (chain is not null) _chain = chain; // version_negotiation unchanged on send
|
||||||
|
return new SendOutput { Message = wire, Key = msgKey.Length == 0 ? null : msgKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Process a peer's pq_ratchet bytes; returns the 32-byte message-key salt (or null).</summary>
|
||||||
|
public byte[]? Recv(byte[] message)
|
||||||
|
{
|
||||||
|
if (_inner is null) return null; // V0
|
||||||
|
|
||||||
|
// Version negotiation: libsignal uses V1/min-V1 both ways, so msg version (1) == our version (1).
|
||||||
|
SpqrVersion? msgVer = MsgVersion(message);
|
||||||
|
if (msgVer is null) return null; // unsupported higher version: ignore
|
||||||
|
if (msgVer.Value < SpqrVersion.V1)
|
||||||
|
throw new SpqrException("SPQR version downgrade not supported");
|
||||||
|
|
||||||
|
(SpqrMessage scka, uint index) = DeserializeMessage(message);
|
||||||
|
SckaStates.RecvResult rr = _inner.Recv(scka);
|
||||||
|
|
||||||
|
ulong msgKeyEpoch = scka.Epoch - 1;
|
||||||
|
Chain chain = _chain ?? (_hasVn
|
||||||
|
? new Chain(_vnAuthKey!, _vnDirection, _vnChainParams)
|
||||||
|
: throw new SpqrException("chain not available"));
|
||||||
|
if (rr.Key is not null) chain.AddEpoch(rr.Key);
|
||||||
|
byte[] msgKey = msgKeyEpoch == 0 && index == 0
|
||||||
|
? Array.Empty<byte>()
|
||||||
|
: chain.RecvKey(msgKeyEpoch, index);
|
||||||
|
|
||||||
|
_inner = rr.State;
|
||||||
|
_chain = chain;
|
||||||
|
_hasVn = false; // receiving clears version negotiation
|
||||||
|
return msgKey.Length == 0 ? null : msgKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── state serialization (local-only persistence) ──
|
||||||
|
|
||||||
|
public byte[] Serialize()
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
using var w = new BinaryWriter(ms);
|
||||||
|
if (_inner is null)
|
||||||
|
{
|
||||||
|
w.Write((byte)0); // V0 / disabled
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
w.Write((byte)1);
|
||||||
|
_inner.Write(w);
|
||||||
|
w.Write(_chain is not null);
|
||||||
|
_chain?.Write(w);
|
||||||
|
w.Write(_hasVn);
|
||||||
|
if (_hasVn)
|
||||||
|
{
|
||||||
|
w.WriteBlob(_vnAuthKey!);
|
||||||
|
w.Write((int)_vnDirection);
|
||||||
|
w.Write((int)_vnMinVersion);
|
||||||
|
w.Write(_vnChainParams.MaxJump);
|
||||||
|
w.Write(_vnChainParams.MaxOooKeys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Flush();
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SpqrRatchet Deserialize(byte[] bytes)
|
||||||
|
{
|
||||||
|
var r = new SpqrRatchet();
|
||||||
|
using var ms = new MemoryStream(bytes);
|
||||||
|
using var rd = new BinaryReader(ms);
|
||||||
|
if (rd.ReadByte() == 0) return r; // V0
|
||||||
|
r._inner = SckaStates.Read(rd);
|
||||||
|
if (rd.ReadBoolean()) r._chain = Chain.Read(rd);
|
||||||
|
r._hasVn = rd.ReadBoolean();
|
||||||
|
if (r._hasVn)
|
||||||
|
{
|
||||||
|
r._vnAuthKey = rd.ReadBlob();
|
||||||
|
r._vnDirection = (Direction)rd.ReadInt32();
|
||||||
|
r._vnMinVersion = (SpqrVersion)rd.ReadInt32();
|
||||||
|
r._vnChainParams = new ChainParams { MaxJump = rd.ReadUInt32(), MaxOooKeys = rd.ReadUInt32() };
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SpqrVersion? MsgVersion(byte[] msg)
|
||||||
|
{
|
||||||
|
if (msg.Length == 0) return SpqrVersion.V0;
|
||||||
|
return msg[0] switch { 0 => SpqrVersion.V0, 1 => SpqrVersion.V1, _ => null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── wire message format (see v1/chunked/states/serialize.rs) ──
|
||||||
|
// [version=1] [varint epoch] [varint index] [type:1] [optional: varint chunkIndex || 32B data]
|
||||||
|
private enum MsgType : byte { None = 0, Hdr = 1, Ek = 2, EkCt1Ack = 3, Ct1Ack = 4, Ct1 = 5, Ct2 = 6 }
|
||||||
|
|
||||||
|
private static byte[] SerializeMessage(SpqrMessage msg, uint index)
|
||||||
|
{
|
||||||
|
var o = new List<byte>(40) { (byte)SpqrVersion.V1 };
|
||||||
|
EncodeVarint(msg.Epoch, o);
|
||||||
|
EncodeVarint(index, o);
|
||||||
|
MsgType type = msg.Payload.Kind switch
|
||||||
|
{
|
||||||
|
SpqrMsgKind.None => MsgType.None,
|
||||||
|
SpqrMsgKind.Hdr => MsgType.Hdr,
|
||||||
|
SpqrMsgKind.Ek => MsgType.Ek,
|
||||||
|
SpqrMsgKind.EkCt1Ack => MsgType.EkCt1Ack,
|
||||||
|
SpqrMsgKind.Ct1Ack => MsgType.Ct1Ack,
|
||||||
|
SpqrMsgKind.Ct1 => MsgType.Ct1,
|
||||||
|
SpqrMsgKind.Ct2 => MsgType.Ct2,
|
||||||
|
_ => throw new SpqrException("bad payload"),
|
||||||
|
};
|
||||||
|
o.Add((byte)type);
|
||||||
|
if (msg.Payload.Chunk is { } chunk)
|
||||||
|
{
|
||||||
|
EncodeVarint(chunk.Index, o);
|
||||||
|
o.AddRange(chunk.Data);
|
||||||
|
}
|
||||||
|
return o.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (SpqrMessage Msg, uint Index) DeserializeMessage(byte[] from)
|
||||||
|
{
|
||||||
|
if (from.Length == 0 || from[0] != (byte)SpqrVersion.V1) throw new SpqrException("message decode failed");
|
||||||
|
int at = 1;
|
||||||
|
ulong epoch = DecodeVarint(from, ref at);
|
||||||
|
if (epoch == 0) throw new SpqrException("message decode failed");
|
||||||
|
ulong indexU = DecodeVarint(from, ref at);
|
||||||
|
if (indexU > uint.MaxValue) throw new SpqrException("message decode failed");
|
||||||
|
if (at >= from.Length) throw new SpqrException("message decode failed");
|
||||||
|
var type = (MsgType)from[at];
|
||||||
|
at++;
|
||||||
|
SpqrPayload payload = type switch
|
||||||
|
{
|
||||||
|
MsgType.None => SpqrPayload.None,
|
||||||
|
MsgType.Ct1Ack => SpqrPayload.Ct1Ack(true),
|
||||||
|
MsgType.Hdr => SpqrPayload.Hdr(DecodeChunk(from, ref at)),
|
||||||
|
MsgType.Ek => SpqrPayload.Ek(DecodeChunk(from, ref at)),
|
||||||
|
MsgType.EkCt1Ack => SpqrPayload.EkCt1Ack(DecodeChunk(from, ref at)),
|
||||||
|
MsgType.Ct1 => SpqrPayload.Ct1(DecodeChunk(from, ref at)),
|
||||||
|
MsgType.Ct2 => SpqrPayload.Ct2(DecodeChunk(from, ref at)),
|
||||||
|
_ => throw new SpqrException("message decode failed"),
|
||||||
|
};
|
||||||
|
return (new SpqrMessage(epoch, payload), (uint)indexU);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SpqrChunk DecodeChunk(byte[] from, ref int at)
|
||||||
|
{
|
||||||
|
ulong index = DecodeVarint(from, ref at);
|
||||||
|
int start = at;
|
||||||
|
at += 32;
|
||||||
|
if (at > from.Length || index > 65535) throw new SpqrException("message decode failed");
|
||||||
|
return new SpqrChunk((ushort)index, from[start..at]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EncodeVarint(ulong a, List<byte> into)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
byte b = (byte)(a & 0x7F);
|
||||||
|
if (a < 0x80) { into.Add(b); break; }
|
||||||
|
into.Add((byte)(0x80 | b));
|
||||||
|
a >>= 7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ulong DecodeVarint(byte[] from, ref int at)
|
||||||
|
{
|
||||||
|
ulong outv = 0;
|
||||||
|
int start = at;
|
||||||
|
if (start >= from.Length) throw new SpqrException("message decode failed");
|
||||||
|
int max = Math.Min(10, from.Length - start);
|
||||||
|
int i = 0;
|
||||||
|
bool done = false;
|
||||||
|
while (i < max && !done)
|
||||||
|
{
|
||||||
|
byte b = from[start + i];
|
||||||
|
outv |= ((ulong)b & 0x7F) << (7 * i);
|
||||||
|
i++;
|
||||||
|
done = (b & 0x80) == 0;
|
||||||
|
}
|
||||||
|
if (!done) throw new SpqrException("message decode failed");
|
||||||
|
at += i;
|
||||||
|
return outv;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
using Wingnal.Protocol.Curve;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.State;
|
||||||
|
|
||||||
|
/// <summary>Identifies a remote party + device, e.g. ("+15551234567" or an ACI uuid, deviceId).</summary>
|
||||||
|
public readonly record struct SignalProtocolAddress(string Name, uint DeviceId);
|
||||||
|
|
||||||
|
/// <summary>A public identity key (long-term Curve25519 key used for X3DH and signatures).</summary>
|
||||||
|
public sealed class IdentityKey
|
||||||
|
{
|
||||||
|
/// <summary>Raw 32-byte Montgomery public key.</summary>
|
||||||
|
public byte[] PublicKey { get; }
|
||||||
|
|
||||||
|
public IdentityKey(byte[] publicKey) => PublicKey = publicKey;
|
||||||
|
|
||||||
|
/// <summary>33-byte DjbECPublicKey serialization (0x05 || u).</summary>
|
||||||
|
public byte[] Serialize() => Curve25519.EncodePoint(PublicKey);
|
||||||
|
|
||||||
|
public static IdentityKey Decode(ReadOnlySpan<byte> serialized) => new(Curve25519.DecodePoint(serialized));
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class IdentityKeyPair
|
||||||
|
{
|
||||||
|
public IdentityKey PublicKey { get; }
|
||||||
|
public byte[] PrivateKey { get; } // raw 32
|
||||||
|
|
||||||
|
public IdentityKeyPair(IdentityKey publicKey, byte[] privateKey)
|
||||||
|
{
|
||||||
|
PublicKey = publicKey;
|
||||||
|
PrivateKey = privateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IdentityKeyPair Generate()
|
||||||
|
{
|
||||||
|
ECKeyPair kp = Curve25519.GenerateKeyPair();
|
||||||
|
return new IdentityKeyPair(new IdentityKey(kp.PublicKey), kp.PrivateKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PreKeyRecord
|
||||||
|
{
|
||||||
|
public uint Id { get; }
|
||||||
|
public ECKeyPair KeyPair { get; }
|
||||||
|
|
||||||
|
public PreKeyRecord(uint id, ECKeyPair keyPair)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
KeyPair = keyPair;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PreKeyRecord Generate(uint id) => new(id, Curve25519.GenerateKeyPair());
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SignedPreKeyRecord
|
||||||
|
{
|
||||||
|
public uint Id { get; }
|
||||||
|
public ECKeyPair KeyPair { get; }
|
||||||
|
public byte[] Signature { get; }
|
||||||
|
public long Timestamp { get; }
|
||||||
|
|
||||||
|
public SignedPreKeyRecord(uint id, ECKeyPair keyPair, byte[] signature, long timestamp)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
KeyPair = keyPair;
|
||||||
|
Signature = signature;
|
||||||
|
Timestamp = timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class KyberPreKeyRecord
|
||||||
|
{
|
||||||
|
public uint Id { get; }
|
||||||
|
public KyberKeyPair KeyPair { get; }
|
||||||
|
public byte[] Signature { get; }
|
||||||
|
public long Timestamp { get; }
|
||||||
|
|
||||||
|
public KyberPreKeyRecord(uint id, KyberKeyPair keyPair, byte[] signature, long timestamp)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
KeyPair = keyPair;
|
||||||
|
Signature = signature;
|
||||||
|
Timestamp = timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The bundle of public keys an initiator fetches for a recipient device (GET /v2/keys), used to
|
||||||
|
/// build an outgoing X3DH/PQXDH session. The one-time prekey is optional; the Kyber prekey is
|
||||||
|
/// present for PQXDH.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PreKeyBundle
|
||||||
|
{
|
||||||
|
public uint RegistrationId { get; }
|
||||||
|
public uint DeviceId { get; }
|
||||||
|
public uint? PreKeyId { get; }
|
||||||
|
public byte[]? PreKeyPublic { get; } // raw 32
|
||||||
|
public uint SignedPreKeyId { get; }
|
||||||
|
public byte[] SignedPreKeyPublic { get; } // raw 32
|
||||||
|
public byte[] SignedPreKeySignature { get; }
|
||||||
|
public IdentityKey IdentityKey { get; }
|
||||||
|
public uint? KyberPreKeyId { get; }
|
||||||
|
public byte[]? KyberPreKeyPublic { get; } // ML-KEM-1024 encoded
|
||||||
|
public byte[]? KyberPreKeySignature { get; }
|
||||||
|
|
||||||
|
public PreKeyBundle(uint registrationId, uint deviceId, uint? preKeyId, byte[]? preKeyPublic,
|
||||||
|
uint signedPreKeyId, byte[] signedPreKeyPublic, byte[] signedPreKeySignature, IdentityKey identityKey,
|
||||||
|
uint? kyberPreKeyId = null, byte[]? kyberPreKeyPublic = null, byte[]? kyberPreKeySignature = null)
|
||||||
|
{
|
||||||
|
RegistrationId = registrationId;
|
||||||
|
DeviceId = deviceId;
|
||||||
|
PreKeyId = preKeyId;
|
||||||
|
PreKeyPublic = preKeyPublic;
|
||||||
|
SignedPreKeyId = signedPreKeyId;
|
||||||
|
SignedPreKeyPublic = signedPreKeyPublic;
|
||||||
|
SignedPreKeySignature = signedPreKeySignature;
|
||||||
|
IdentityKey = identityKey;
|
||||||
|
KyberPreKeyId = kyberPreKeyId;
|
||||||
|
KyberPreKeyPublic = kyberPreKeyPublic;
|
||||||
|
KyberPreKeySignature = kyberPreKeySignature;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.State;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wraps the current <see cref="SessionState"/> plus a bounded list of archived previous states.
|
||||||
|
/// Archiving (on re-keying / new session setup) lets the decryptor still process in-flight messages
|
||||||
|
/// encrypted under the prior session.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SessionRecord
|
||||||
|
{
|
||||||
|
private const int MaxArchivedStates = 40;
|
||||||
|
private readonly LinkedList<SessionState> _previousStates = new();
|
||||||
|
|
||||||
|
public SessionState State { get; private set; }
|
||||||
|
|
||||||
|
public SessionRecord() => State = new SessionState();
|
||||||
|
|
||||||
|
public SessionRecord(SessionState state) => State = state;
|
||||||
|
|
||||||
|
public IEnumerable<SessionState> PreviousStates => _previousStates;
|
||||||
|
|
||||||
|
/// <summary>Moves the current state into the archive and starts a fresh one.</summary>
|
||||||
|
public void ArchiveCurrentState()
|
||||||
|
{
|
||||||
|
if (!State.IsInitialized) return;
|
||||||
|
_previousStates.AddFirst(State);
|
||||||
|
while (_previousStates.Count > MaxArchivedStates)
|
||||||
|
_previousStates.RemoveLast();
|
||||||
|
State = new SessionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serializes the current + archived states (durable session persistence).</summary>
|
||||||
|
public byte[] Serialize()
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
using var w = new BinaryWriter(ms);
|
||||||
|
State.Write(w);
|
||||||
|
w.Write(_previousStates.Count);
|
||||||
|
foreach (SessionState prev in _previousStates) prev.Write(w);
|
||||||
|
w.Flush();
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SessionRecord Deserialize(byte[] bytes)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream(bytes);
|
||||||
|
using var r = new BinaryReader(ms);
|
||||||
|
var record = new SessionRecord(SessionState.Read(r));
|
||||||
|
int n = r.ReadInt32();
|
||||||
|
for (int i = 0; i < n; i++) record._previousStates.AddLast(SessionState.Read(r));
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
using System.IO;
|
||||||
|
using Wingnal.Protocol.Curve;
|
||||||
|
using Wingnal.Protocol.Ratchet;
|
||||||
|
using Wingnal.Protocol.Spqr;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.State;
|
||||||
|
|
||||||
|
/// <summary>The sender's unacknowledged X3DH/PQXDH setup data, replayed in each outgoing message
|
||||||
|
/// until the peer's first reply confirms the session (then cleared).</summary>
|
||||||
|
public sealed class PendingPreKey
|
||||||
|
{
|
||||||
|
public uint? PreKeyId { get; }
|
||||||
|
public uint SignedPreKeyId { get; }
|
||||||
|
public uint? KyberPreKeyId { get; }
|
||||||
|
public byte[]? KyberCiphertext { get; }
|
||||||
|
public byte[] BaseKey { get; } // raw 32
|
||||||
|
|
||||||
|
public PendingPreKey(uint? preKeyId, uint signedPreKeyId, uint? kyberPreKeyId, byte[]? kyberCiphertext, byte[] baseKey)
|
||||||
|
{
|
||||||
|
PreKeyId = preKeyId;
|
||||||
|
SignedPreKeyId = signedPreKeyId;
|
||||||
|
KyberPreKeyId = kyberPreKeyId;
|
||||||
|
KyberCiphertext = kyberCiphertext;
|
||||||
|
BaseKey = baseKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One receiving chain, keyed by the peer's ratchet public key, plus a bounded cache of
|
||||||
|
/// skipped message keys (out-of-order / dropped messages).</summary>
|
||||||
|
public sealed class ReceiverChain
|
||||||
|
{
|
||||||
|
private const int MaxMessageKeys = 2000;
|
||||||
|
|
||||||
|
public byte[] RatchetKey { get; } // raw 32, the peer's ratchet public key
|
||||||
|
public ChainKey ChainKey { get; set; }
|
||||||
|
// Skipped/out-of-order messages cache the message-key SEED (not the final keys), so the SPQR
|
||||||
|
// per-message salt can be applied when the out-of-order message arrives. Counter -> seed.
|
||||||
|
private readonly Dictionary<uint, byte[]> _messageSeeds = new();
|
||||||
|
private readonly Queue<uint> _order = new();
|
||||||
|
|
||||||
|
public ReceiverChain(byte[] ratchetKey, ChainKey chainKey)
|
||||||
|
{
|
||||||
|
RatchetKey = ratchetKey;
|
||||||
|
ChainKey = chainKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryTakeMessageSeed(uint counter, out byte[] seed)
|
||||||
|
{
|
||||||
|
if (_messageSeeds.Remove(counter, out byte[]? found))
|
||||||
|
{
|
||||||
|
seed = found;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
seed = default!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StoreMessageSeed(uint counter, byte[] seed)
|
||||||
|
{
|
||||||
|
_messageSeeds[counter] = seed;
|
||||||
|
_order.Enqueue(counter);
|
||||||
|
while (_order.Count > MaxMessageKeys)
|
||||||
|
_messageSeeds.Remove(_order.Dequeue());
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Write(BinaryWriter w)
|
||||||
|
{
|
||||||
|
w.WriteBlob(RatchetKey);
|
||||||
|
w.WriteBlob(ChainKey.Key);
|
||||||
|
w.Write(ChainKey.Index);
|
||||||
|
w.Write(_messageSeeds.Count);
|
||||||
|
foreach (KeyValuePair<uint, byte[]> kv in _messageSeeds) { w.Write(kv.Key); w.WriteBlob(kv.Value); }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static ReceiverChain Read(BinaryReader r)
|
||||||
|
{
|
||||||
|
byte[] ratchetKey = r.ReadBlob();
|
||||||
|
var chainKey = new ChainKey(r.ReadBlob(), r.ReadUInt32());
|
||||||
|
var chain = new ReceiverChain(ratchetKey, chainKey);
|
||||||
|
int n = r.ReadInt32();
|
||||||
|
for (int i = 0; i < n; i++) chain.StoreMessageSeed(r.ReadUInt32(), r.ReadBlob());
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mutable Double Ratchet session state: root key, current sending chain, recent receiving chains,
|
||||||
|
/// identities, and (for an initiator) the pending prekey. Mirrors libsignal's SessionState.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SessionState
|
||||||
|
{
|
||||||
|
private const int MaxReceiverChains = 5;
|
||||||
|
|
||||||
|
public int SessionVersion { get; set; }
|
||||||
|
public IdentityKey? LocalIdentity { get; set; }
|
||||||
|
public IdentityKey? RemoteIdentity { get; set; }
|
||||||
|
public uint LocalRegistrationId { get; set; }
|
||||||
|
public uint RemoteRegistrationId { get; set; }
|
||||||
|
|
||||||
|
public RootKey? RootKey { get; set; }
|
||||||
|
public ECKeyPair? SenderRatchetKeyPair { get; set; }
|
||||||
|
public ChainKey? SenderChainKey { get; set; }
|
||||||
|
public uint PreviousCounter { get; set; }
|
||||||
|
|
||||||
|
public PendingPreKey? PendingPreKey { get; set; }
|
||||||
|
public byte[]? AliceBaseKey { get; set; } // responder-side dedupe of repeated prekey messages
|
||||||
|
|
||||||
|
/// <summary>The 32-byte SPQR auth_key (3rd HKDF slice from PQXDH); null for classic X3DH sessions.</summary>
|
||||||
|
public byte[]? SpqrAuthKey { get; set; }
|
||||||
|
/// <summary>The live Sparse Post-Quantum Ratchet (in-memory); null = SPQR disabled (classic session).</summary>
|
||||||
|
public SpqrRatchet? Spqr { get; set; }
|
||||||
|
|
||||||
|
private readonly LinkedList<ReceiverChain> _receiverChains = new();
|
||||||
|
|
||||||
|
public bool HasSenderChain => SenderRatchetKeyPair is not null && SenderChainKey is not null;
|
||||||
|
public bool IsInitialized => RootKey is not null;
|
||||||
|
|
||||||
|
public ReceiverChain? FindReceiverChain(byte[] ratchetKey)
|
||||||
|
{
|
||||||
|
foreach (ReceiverChain chain in _receiverChains)
|
||||||
|
if (chain.RatchetKey.AsSpan().SequenceEqual(ratchetKey))
|
||||||
|
return chain;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddReceiverChain(byte[] ratchetKey, ChainKey chainKey)
|
||||||
|
{
|
||||||
|
_receiverChains.AddFirst(new ReceiverChain(ratchetKey, chainKey));
|
||||||
|
while (_receiverChains.Count > MaxReceiverChains)
|
||||||
|
_receiverChains.RemoveLast();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── serialization (durable session persistence) ──
|
||||||
|
|
||||||
|
internal void Write(BinaryWriter w)
|
||||||
|
{
|
||||||
|
w.Write(SessionVersion);
|
||||||
|
WriteId(w, LocalIdentity);
|
||||||
|
WriteId(w, RemoteIdentity);
|
||||||
|
w.Write(LocalRegistrationId);
|
||||||
|
w.Write(RemoteRegistrationId);
|
||||||
|
w.Write(RootKey is not null); if (RootKey is not null) w.WriteBlob(RootKey.Key);
|
||||||
|
w.Write(SenderRatchetKeyPair is not null);
|
||||||
|
if (SenderRatchetKeyPair is not null) { w.WriteBlob(SenderRatchetKeyPair.PrivateKey); w.WriteBlob(SenderRatchetKeyPair.PublicKey); }
|
||||||
|
w.Write(SenderChainKey is not null);
|
||||||
|
if (SenderChainKey is not null) { w.WriteBlob(SenderChainKey.Key); w.Write(SenderChainKey.Index); }
|
||||||
|
w.Write(PreviousCounter);
|
||||||
|
w.Write(PendingPreKey is not null); if (PendingPreKey is { } pp) WritePending(w, pp);
|
||||||
|
w.Write(AliceBaseKey is not null); if (AliceBaseKey is not null) w.WriteBlob(AliceBaseKey);
|
||||||
|
w.Write(SpqrAuthKey is not null); if (SpqrAuthKey is not null) w.WriteBlob(SpqrAuthKey);
|
||||||
|
w.Write(Spqr is not null); if (Spqr is not null) w.WriteBlob(Spqr.Serialize());
|
||||||
|
w.Write(_receiverChains.Count);
|
||||||
|
foreach (ReceiverChain c in _receiverChains) c.Write(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static SessionState Read(BinaryReader r)
|
||||||
|
{
|
||||||
|
var s = new SessionState
|
||||||
|
{
|
||||||
|
SessionVersion = r.ReadInt32(),
|
||||||
|
LocalIdentity = ReadId(r),
|
||||||
|
RemoteIdentity = ReadId(r),
|
||||||
|
LocalRegistrationId = r.ReadUInt32(),
|
||||||
|
RemoteRegistrationId = r.ReadUInt32(),
|
||||||
|
};
|
||||||
|
if (r.ReadBoolean()) s.RootKey = new RootKey(r.ReadBlob());
|
||||||
|
if (r.ReadBoolean()) s.SenderRatchetKeyPair = new ECKeyPair(r.ReadBlob(), r.ReadBlob());
|
||||||
|
if (r.ReadBoolean()) s.SenderChainKey = new ChainKey(r.ReadBlob(), r.ReadUInt32());
|
||||||
|
s.PreviousCounter = r.ReadUInt32();
|
||||||
|
if (r.ReadBoolean()) s.PendingPreKey = ReadPending(r);
|
||||||
|
if (r.ReadBoolean()) s.AliceBaseKey = r.ReadBlob();
|
||||||
|
if (r.ReadBoolean()) s.SpqrAuthKey = r.ReadBlob();
|
||||||
|
if (r.ReadBoolean()) s.Spqr = SpqrRatchet.Deserialize(r.ReadBlob());
|
||||||
|
int n = r.ReadInt32();
|
||||||
|
for (int i = 0; i < n; i++) s._receiverChains.AddLast(ReceiverChain.Read(r));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteId(BinaryWriter w, IdentityKey? id) { w.Write(id is not null); if (id is not null) w.WriteBlob(id.PublicKey); }
|
||||||
|
private static IdentityKey? ReadId(BinaryReader r) => r.ReadBoolean() ? new IdentityKey(r.ReadBlob()) : null;
|
||||||
|
|
||||||
|
private static void WritePending(BinaryWriter w, PendingPreKey p)
|
||||||
|
{
|
||||||
|
w.Write(p.PreKeyId.HasValue); if (p.PreKeyId.HasValue) w.Write(p.PreKeyId.Value);
|
||||||
|
w.Write(p.SignedPreKeyId);
|
||||||
|
w.Write(p.KyberPreKeyId.HasValue); if (p.KyberPreKeyId.HasValue) w.Write(p.KyberPreKeyId.Value);
|
||||||
|
w.Write(p.KyberCiphertext is not null); if (p.KyberCiphertext is not null) w.WriteBlob(p.KyberCiphertext);
|
||||||
|
w.WriteBlob(p.BaseKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PendingPreKey ReadPending(BinaryReader r)
|
||||||
|
{
|
||||||
|
uint? preKeyId = r.ReadBoolean() ? r.ReadUInt32() : null;
|
||||||
|
uint signedId = r.ReadUInt32();
|
||||||
|
uint? kyberId = r.ReadBoolean() ? r.ReadUInt32() : null;
|
||||||
|
byte[]? kyberCt = r.ReadBoolean() ? r.ReadBlob() : null;
|
||||||
|
byte[] baseKey = r.ReadBlob();
|
||||||
|
return new PendingPreKey(preKeyId, signedId, kyberId, kyberCt, baseKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
namespace Wingnal.Protocol.State;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Store contracts mirroring libsignal's. Implementations are in-memory (tests) or SQLite-backed
|
||||||
|
/// (app, added later). Kept synchronous to match libsignal's reference semantics.
|
||||||
|
/// </summary>
|
||||||
|
public interface IIdentityKeyStore
|
||||||
|
{
|
||||||
|
IdentityKeyPair GetIdentityKeyPair();
|
||||||
|
uint GetLocalRegistrationId();
|
||||||
|
|
||||||
|
/// <summary>Stores a remote identity. Returns true if it replaced a different existing key.</summary>
|
||||||
|
bool SaveIdentity(SignalProtocolAddress address, IdentityKey identity);
|
||||||
|
|
||||||
|
/// <summary>Trust-on-first-use: an identity is trusted if we have none stored for the address yet,
|
||||||
|
/// or the presented one matches what we stored. A DIFFERENT key for a known address is untrusted
|
||||||
|
/// (until the user verifies + approves it, which overwrites the stored key via SaveIdentity).</summary>
|
||||||
|
bool IsTrustedIdentity(SignalProtocolAddress address, IdentityKey identity);
|
||||||
|
|
||||||
|
IdentityKey? GetIdentity(SignalProtocolAddress address);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IPreKeyStore
|
||||||
|
{
|
||||||
|
PreKeyRecord LoadPreKey(uint preKeyId);
|
||||||
|
void StorePreKey(uint preKeyId, PreKeyRecord record);
|
||||||
|
bool ContainsPreKey(uint preKeyId);
|
||||||
|
void RemovePreKey(uint preKeyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ISignedPreKeyStore
|
||||||
|
{
|
||||||
|
SignedPreKeyRecord LoadSignedPreKey(uint signedPreKeyId);
|
||||||
|
void StoreSignedPreKey(uint signedPreKeyId, SignedPreKeyRecord record);
|
||||||
|
bool ContainsSignedPreKey(uint signedPreKeyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IKyberPreKeyStore
|
||||||
|
{
|
||||||
|
KyberPreKeyRecord LoadKyberPreKey(uint kyberPreKeyId);
|
||||||
|
void StoreKyberPreKey(uint kyberPreKeyId, KyberPreKeyRecord record);
|
||||||
|
bool ContainsKyberPreKey(uint kyberPreKeyId);
|
||||||
|
void MarkKyberPreKeyUsed(uint kyberPreKeyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ISessionStore
|
||||||
|
{
|
||||||
|
SessionRecord LoadSession(SignalProtocolAddress address);
|
||||||
|
bool ContainsSession(SignalProtocolAddress address);
|
||||||
|
void StoreSession(SignalProtocolAddress address, SessionRecord record);
|
||||||
|
void DeleteSession(SignalProtocolAddress address);
|
||||||
|
|
||||||
|
/// <summary>Device ids of <paramref name="name"/> that already have a session (for active-session
|
||||||
|
/// reuse on send, so we avoid re-fetching a prekey bundle every time).</summary>
|
||||||
|
IReadOnlyList<uint> GetSubDeviceSessions(string name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The full protocol store an application provides to the session layer.</summary>
|
||||||
|
public interface ISignalProtocolStore
|
||||||
|
: IIdentityKeyStore, IPreKeyStore, ISignedPreKeyStore, IKyberPreKeyStore, ISessionStore
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>Wingnal.Protocol</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="BouncyCastle.Cryptography" Version="2.5.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="Wingnal.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
using Wingnal.Protocol.ZkGroup.Curve;
|
||||||
|
using Wingnal.Protocol.ZkGroup.ZkCredential;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.ZkGroup;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Port of zkgroup's <c>AuthCredentialWithPniZkc</c> — the authentication credential a client receives from
|
||||||
|
/// the chat server and presents (anonymously) to the storage service to act on a group. The presentation
|
||||||
|
/// proves possession of a valid credential over (aci, pni, redemptionTime) while only revealing the aci/pni
|
||||||
|
/// encrypted under the group's UID key. Built on the generic zkcredential issuance/presentation system.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AuthCredentialWithPni
|
||||||
|
{
|
||||||
|
public const int PresentationVersion4 = 3;
|
||||||
|
private static readonly byte[] Label = Encoding.ASCII.GetBytes("20240222_Signal_AuthCredentialZkc");
|
||||||
|
|
||||||
|
public Credential Credential { get; }
|
||||||
|
public UidStruct Aci { get; }
|
||||||
|
public UidStruct Pni { get; }
|
||||||
|
public ulong RedemptionTime { get; }
|
||||||
|
|
||||||
|
private AuthCredentialWithPni(Credential credential, UidStruct aci, UidStruct pni, ulong redemptionTime)
|
||||||
|
{
|
||||||
|
Credential = credential; Aci = aci; Pni = pni; RedemptionTime = redemptionTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
private const byte VersionZkc = 3; // AuthCredentialWithPniVersion::Zkc
|
||||||
|
|
||||||
|
// ── server side (offline tests): issue ──
|
||||||
|
|
||||||
|
public static IssuanceProof Issue(ServiceId aci, ServiceId pni, ulong redemptionTime,
|
||||||
|
CredentialKeyPair credentialKey, byte[] randomness)
|
||||||
|
{
|
||||||
|
return new IssuanceProofBuilder(Label)
|
||||||
|
.AddAttribute(UidStruct.FromServiceId(aci).AsPoints())
|
||||||
|
.AddAttribute(UidStruct.FromServiceId(pni).AsPoints())
|
||||||
|
.AddPublicAttributeU64(redemptionTime)
|
||||||
|
.Issue(credentialKey, randomness);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The serialized AuthCredentialWithPniResponse the chat server returns: version(3) ‖ IssuanceProof.</summary>
|
||||||
|
public static byte[] IssueResponse(ServiceId aci, ServiceId pni, ulong redemptionTime,
|
||||||
|
CredentialKeyPair credentialKey, byte[] randomness)
|
||||||
|
{
|
||||||
|
var b = new List<byte> { VersionZkc };
|
||||||
|
b.AddRange(Issue(aci, pni, redemptionTime, credentialKey, randomness).Serialize());
|
||||||
|
return b.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── client side: receive a credential ──
|
||||||
|
|
||||||
|
/// <summary>Receives the chat server's serialized AuthCredentialWithPniResponse using Signal's published
|
||||||
|
/// credential public key (<see cref="ServerPublicParams.Production"/>).</summary>
|
||||||
|
public static AuthCredentialWithPni ReceiveResponse(byte[] responseBytes, ServiceId aci, ServiceId pni,
|
||||||
|
ulong redemptionTime, CredentialPublicKey? credentialPublicKey = null)
|
||||||
|
{
|
||||||
|
if (responseBytes.Length < 1 || responseBytes[0] != VersionZkc)
|
||||||
|
throw new ZkGroupVerificationException("bad AuthCredentialWithPniResponse version");
|
||||||
|
IssuanceProof proof = IssuanceProof.Deserialize(responseBytes.AsSpan(1));
|
||||||
|
return Receive(proof, aci, pni, redemptionTime,
|
||||||
|
credentialPublicKey ?? ServerPublicParams.Production.GenericCredentialPublicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AuthCredentialWithPni Receive(IssuanceProof proof, ServiceId aci, ServiceId pni,
|
||||||
|
ulong redemptionTime, CredentialPublicKey credentialPublicKey)
|
||||||
|
{
|
||||||
|
if (redemptionTime % 86400 != 0)
|
||||||
|
throw new ZkGroupVerificationException("redemption time not day-aligned");
|
||||||
|
UidStruct aciStruct = UidStruct.FromServiceId(aci);
|
||||||
|
UidStruct pniStruct = UidStruct.FromServiceId(pni);
|
||||||
|
Credential credential = new IssuanceProofBuilder(Label)
|
||||||
|
.AddAttribute(aciStruct.AsPoints())
|
||||||
|
.AddAttribute(pniStruct.AsPoints())
|
||||||
|
.AddPublicAttributeU64(redemptionTime)
|
||||||
|
.Verify(credentialPublicKey, proof);
|
||||||
|
return new AuthCredentialWithPni(credential, aciStruct, pniStruct, redemptionTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── client side: build the presentation for a group ──
|
||||||
|
|
||||||
|
public byte[] Present(CredentialPublicKey credentialPublicKey, GroupSecretParams group, byte[] randomness)
|
||||||
|
{
|
||||||
|
EncryptionKeyContext uidKey = UidKeyContext(group);
|
||||||
|
PresentationProof proof = new PresentationProofBuilder(Label)
|
||||||
|
.AddAttribute(Aci.AsPoints(), uidKey)
|
||||||
|
.AddAttribute(Pni.AsPoints(), uidKey)
|
||||||
|
.Present(credentialPublicKey, Credential, randomness);
|
||||||
|
|
||||||
|
AttributeCiphertext aciCt = UidEncryption.Encrypt(group.UidKeyPair, Aci);
|
||||||
|
AttributeCiphertext pniCt = UidEncryption.Encrypt(group.UidKeyPair, Pni);
|
||||||
|
|
||||||
|
var b = new List<byte> { PresentationVersion4 };
|
||||||
|
b.AddRange(proof.Serialize());
|
||||||
|
b.AddRange(aciCt.Serialize()); // 64 bytes (no reserved byte at this layer)
|
||||||
|
b.AddRange(pniCt.Serialize()); // 64 bytes
|
||||||
|
Span<byte> rt = stackalloc byte[8];
|
||||||
|
BinaryPrimitives.WriteUInt64LittleEndian(rt, RedemptionTime);
|
||||||
|
b.AddRange(rt.ToArray());
|
||||||
|
return b.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static EncryptionKeyContext UidKeyContext(GroupSecretParams group) => new()
|
||||||
|
{
|
||||||
|
Id = UidEncryption.DomainId,
|
||||||
|
Ga1 = UidEncryption.SystemParams.Ga1,
|
||||||
|
Ga2 = UidEncryption.SystemParams.Ga2,
|
||||||
|
A1 = group.UidKeyPair.A1,
|
||||||
|
A2 = group.UidKeyPair.A2,
|
||||||
|
PublicKeyA = group.UidKeyPair.PublicKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── verifying-server side (offline tests) ──
|
||||||
|
|
||||||
|
/// <summary>Verifies a serialized presentation against the server's credential key and the group's
|
||||||
|
/// public UID key. Returns the embedded (aci, pni) ciphertexts on success.</summary>
|
||||||
|
public static (UuidCiphertext aci, UuidCiphertext pni) VerifyPresentation(
|
||||||
|
byte[] presentation, CredentialKeyPair credentialKey, Ristretto255 groupUidPublicKey, ulong redemptionTime)
|
||||||
|
{
|
||||||
|
int o = 0;
|
||||||
|
if (presentation.Length < 1 || presentation[0] != PresentationVersion4)
|
||||||
|
throw new ZkGroupVerificationException("bad presentation version");
|
||||||
|
o = 1;
|
||||||
|
var proof = new PresentationProof
|
||||||
|
{
|
||||||
|
Cx0 = ReadPoint(presentation, ref o),
|
||||||
|
Cx1 = ReadPoint(presentation, ref o),
|
||||||
|
Cv = ReadPoint(presentation, ref o),
|
||||||
|
};
|
||||||
|
ulong cyLen = BinaryPrimitives.ReadUInt64LittleEndian(presentation.AsSpan(o, 8)); o += 8;
|
||||||
|
var cy = new Ristretto255[cyLen];
|
||||||
|
for (ulong i = 0; i < cyLen; i++) cy[i] = ReadPoint(presentation, ref o);
|
||||||
|
proof.Cy = cy;
|
||||||
|
ulong proofLen = BinaryPrimitives.ReadUInt64LittleEndian(presentation.AsSpan(o, 8)); o += 8;
|
||||||
|
proof.PokshoProof = presentation.AsSpan(o, (int)proofLen).ToArray(); o += (int)proofLen;
|
||||||
|
|
||||||
|
var aciCt = AttributeCiphertext.Deserialize(presentation.AsSpan(o, 64)); o += 64;
|
||||||
|
var pniCt = AttributeCiphertext.Deserialize(presentation.AsSpan(o, 64)); o += 64;
|
||||||
|
ulong embeddedRedemption = BinaryPrimitives.ReadUInt64LittleEndian(presentation.AsSpan(o, 8)); o += 8;
|
||||||
|
if (embeddedRedemption != redemptionTime)
|
||||||
|
throw new ZkGroupVerificationException("redemption time mismatch");
|
||||||
|
|
||||||
|
var pubKey = new EncryptionKeyContext
|
||||||
|
{
|
||||||
|
Id = UidEncryption.DomainId,
|
||||||
|
Ga1 = UidEncryption.SystemParams.Ga1,
|
||||||
|
Ga2 = UidEncryption.SystemParams.Ga2,
|
||||||
|
PublicKeyA = groupUidPublicKey,
|
||||||
|
};
|
||||||
|
bool ok = new PresentationProofVerifier(Label)
|
||||||
|
.AddAttribute(aciCt.AsPoints(), pubKey)
|
||||||
|
.AddAttribute(pniCt.AsPoints(), pubKey)
|
||||||
|
.AddPublicAttributeU64(redemptionTime)
|
||||||
|
.Verify(credentialKey, proof);
|
||||||
|
if (!ok) throw new ZkGroupVerificationException("presentation proof did not verify");
|
||||||
|
return (new UuidCiphertext(aciCt), new UuidCiphertext(pniCt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Ristretto255 ReadPoint(byte[] b, ref int o)
|
||||||
|
{
|
||||||
|
Ristretto255 p = Ristretto255.Decode(b.AsSpan(o, 32)) ?? throw new ZkGroupVerificationException("bad point");
|
||||||
|
o += 32;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Wingnal.Protocol.ZkGroup.ZkCredential;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.ZkGroup;
|
||||||
|
|
||||||
|
/// <summary>A group member's service id, encrypted under the group's UID key. Wire form is a reserved 0x00
|
||||||
|
/// byte followed by the 64-byte <see cref="AttributeCiphertext"/> (65 bytes total), matching zkgroup.</summary>
|
||||||
|
public readonly struct UuidCiphertext
|
||||||
|
{
|
||||||
|
public readonly AttributeCiphertext Ciphertext;
|
||||||
|
public UuidCiphertext(AttributeCiphertext ct) => Ciphertext = ct;
|
||||||
|
|
||||||
|
public byte[] Serialize()
|
||||||
|
{
|
||||||
|
var b = new byte[65];
|
||||||
|
Array.Copy(Ciphertext.Serialize(), 0, b, 1, 64); // b[0] = reserved 0x00
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static UuidCiphertext Deserialize(ReadOnlySpan<byte> bytes65)
|
||||||
|
{
|
||||||
|
if (bytes65.Length != 65 || bytes65[0] != 0) throw new ArgumentException("bad UuidCiphertext");
|
||||||
|
return new UuidCiphertext(AttributeCiphertext.Deserialize(bytes65[1..]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A group member's profile key, encrypted under the group's profile-key key (reserved 0x00 ‖ 64).</summary>
|
||||||
|
public readonly struct ProfileKeyCiphertext
|
||||||
|
{
|
||||||
|
public readonly AttributeCiphertext Ciphertext;
|
||||||
|
public ProfileKeyCiphertext(AttributeCiphertext ct) => Ciphertext = ct;
|
||||||
|
|
||||||
|
public byte[] Serialize()
|
||||||
|
{
|
||||||
|
var b = new byte[65];
|
||||||
|
Array.Copy(Ciphertext.Serialize(), 0, b, 1, 64);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProfileKeyCiphertext Deserialize(ReadOnlySpan<byte> bytes65)
|
||||||
|
{
|
||||||
|
if (bytes65.Length != 65 || bytes65[0] != 0) throw new ArgumentException("bad ProfileKeyCiphertext");
|
||||||
|
return new ProfileKeyCiphertext(AttributeCiphertext.Deserialize(bytes65[1..]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using Org.BouncyCastle.Math.EC.Rfc7748;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.ZkGroup.Curve;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A field element of GF(2²⁵⁵−19), wrapping BouncyCastle's vetted constant-time <see cref="X25519Field"/>
|
||||||
|
/// representation (10 limbs) with value-semantics helpers (each op returns a fresh element, so BC's
|
||||||
|
/// not-alias-safe Mul/Sqr are always called with distinct outputs). Every returned element is carried, so
|
||||||
|
/// it is safe to feed straight into another Mul/Sqr. This is the base field for the Ristretto255 group
|
||||||
|
/// hand-port (zkgroup) — see <see cref="Ristretto255"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class Fe
|
||||||
|
{
|
||||||
|
internal readonly int[] L; // 10-limb X25519Field representation
|
||||||
|
|
||||||
|
private Fe(int[] l) => L = l;
|
||||||
|
|
||||||
|
public static Fe Zero() { var f = new Fe(X25519Field.Create()); X25519Field.Zero(f.L); return f; }
|
||||||
|
public static Fe One() { var f = new Fe(X25519Field.Create()); X25519Field.One(f.L); return f; }
|
||||||
|
|
||||||
|
/// <summary>Decodes 32 little-endian bytes, masking bit 255 (dalek <c>FieldElement::from_bytes</c>
|
||||||
|
/// semantics). The value is reduced mod p on the next Normalize/Encode.</summary>
|
||||||
|
public static Fe Decode(ReadOnlySpan<byte> bytes32)
|
||||||
|
{
|
||||||
|
Span<byte> b = stackalloc byte[32];
|
||||||
|
bytes32[..32].CopyTo(b);
|
||||||
|
b[31] &= 0x7f;
|
||||||
|
var f = new Fe(X25519Field.Create());
|
||||||
|
X25519Field.Decode(b.ToArray(), 0, f.L);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Canonical 32-byte little-endian encoding (reduced mod p; bit 255 = 0).</summary>
|
||||||
|
public byte[] Encode()
|
||||||
|
{
|
||||||
|
int[] t = (int[])L.Clone();
|
||||||
|
X25519Field.Normalize(t);
|
||||||
|
var b = new byte[32];
|
||||||
|
X25519Field.Encode(t, b, 0);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Fe Clone() => new((int[])L.Clone());
|
||||||
|
|
||||||
|
public static Fe Add(Fe a, Fe b) { var r = new Fe(X25519Field.Create()); X25519Field.Add(a.L, b.L, r.L); X25519Field.Carry(r.L); return r; }
|
||||||
|
public static Fe Sub(Fe a, Fe b) { var r = new Fe(X25519Field.Create()); X25519Field.Sub(a.L, b.L, r.L); X25519Field.Carry(r.L); return r; }
|
||||||
|
public static Fe Mul(Fe a, Fe b) { var r = new Fe(X25519Field.Create()); X25519Field.Mul(a.L, b.L, r.L); return r; }
|
||||||
|
public static Fe Sqr(Fe a) { var r = new Fe(X25519Field.Create()); X25519Field.Sqr(a.L, r.L); return r; }
|
||||||
|
public static Fe Inv(Fe a) { var r = new Fe(X25519Field.Create()); X25519Field.Inv(a.L, r.L); return r; }
|
||||||
|
|
||||||
|
public static Fe Neg(Fe a) { var r = a.Clone(); X25519Field.CNegate(1, r.L); X25519Field.Carry(r.L); return r; }
|
||||||
|
|
||||||
|
private static Fe SqrN(Fe x, int n) { Fe r = x; for (int i = 0; i < n; i++) r = Sqr(r); return r; }
|
||||||
|
|
||||||
|
/// <summary>x^((p−5)/8) = x^(2²⁵²−3), the inverse-fourth-root exponent used by sqrt_ratio. ref10's
|
||||||
|
/// pow22523 addition chain (BC's equivalent <c>X25519Field.PowPm5d8</c> is internal).</summary>
|
||||||
|
public static Fe PowP58(Fe z)
|
||||||
|
{
|
||||||
|
Fe t0 = Sqr(z); // z^2
|
||||||
|
Fe t1 = Sqr(Sqr(t0)); // z^8
|
||||||
|
t1 = Mul(z, t1); // z^9
|
||||||
|
t0 = Mul(t0, t1); // z^11
|
||||||
|
t0 = Sqr(t0); // z^22
|
||||||
|
t0 = Mul(t1, t0); // z^(2^5-1)
|
||||||
|
t1 = SqrN(t0, 5); t0 = Mul(t1, t0); // 2^10-1
|
||||||
|
t1 = SqrN(t0, 10); t1 = Mul(t1, t0); // 2^20-1
|
||||||
|
Fe t2 = SqrN(t1, 20); t1 = Mul(t2, t1); // 2^40-1
|
||||||
|
t1 = SqrN(t1, 10); t0 = Mul(t1, t0); // 2^50-1
|
||||||
|
t1 = SqrN(t0, 50); t1 = Mul(t1, t0); // 2^100-1
|
||||||
|
t2 = SqrN(t1, 100); t1 = Mul(t2, t1); // 2^200-1
|
||||||
|
t1 = SqrN(t1, 50); t0 = Mul(t1, t0); // 2^250-1
|
||||||
|
t0 = Sqr(Sqr(t0)); // 2^252-4
|
||||||
|
return Mul(t0, z); // 2^252-3
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>cond ? b : a (constant-time select).</summary>
|
||||||
|
public static Fe Select(Fe a, Fe b, bool cond)
|
||||||
|
{
|
||||||
|
var r = a.Clone();
|
||||||
|
X25519Field.CMov(cond ? -1 : 0, b.L, 0, r.L, 0);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsNegative() => (Encode()[0] & 1) == 1;
|
||||||
|
public bool IsZero() => Equals(Zero());
|
||||||
|
public bool ConstantTimeEquals(Fe other) => Encode().AsSpan().SequenceEqual(other.Encode());
|
||||||
|
public bool Equals(Fe other) => ConstantTimeEquals(other);
|
||||||
|
|
||||||
|
/// <summary>|x| = (x is negative) ? −x : x.</summary>
|
||||||
|
public Fe Abs() => IsNegative() ? Neg(this) : Clone();
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.ZkGroup.Curve;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The "Lizard" encoding from the curve25519-dalek-<b>signal</b> fork (NOT RFC 9496, NOT upstream dalek):
|
||||||
|
/// reversibly maps 16 bytes (a raw UUID) to a Ristretto255 point and back. zkgroup uses it to put a
|
||||||
|
/// member's ACI/PNI inside a homomorphically-encryptable group element (see <c>UidStruct.M2</c>).
|
||||||
|
///
|
||||||
|
/// Encode: <c>fe = SHA-256(data) with bytes[8..24] overwritten by data, low bit and top two bits cleared;
|
||||||
|
/// point = ElligatorRistrettoFlavor(fe)</c>. Decode inverts Elligator (up to 8 candidate field elements via
|
||||||
|
/// the Jacobi quartic) and keeps the unique one whose embedded bytes re-hash to itself.
|
||||||
|
///
|
||||||
|
/// Validated against the dalek-signal lizard test vectors (encode) + round-trip (decode).
|
||||||
|
/// NOT constant-time (data-dependent branching in decode) — acceptable for client-side group decryption.
|
||||||
|
/// </summary>
|
||||||
|
public static class Lizard
|
||||||
|
{
|
||||||
|
/// <summary>Encodes 16 bytes to a Ristretto255 point.</summary>
|
||||||
|
public static Ristretto255 Encode(ReadOnlySpan<byte> data16)
|
||||||
|
{
|
||||||
|
if (data16.Length != 16) throw new ArgumentException("Lizard.Encode expects 16 bytes");
|
||||||
|
Span<byte> feBytes = stackalloc byte[32];
|
||||||
|
SHA256.HashData(data16, feBytes);
|
||||||
|
data16.CopyTo(feBytes[8..24]);
|
||||||
|
feBytes[0] &= 254; // make positive — Elligator on r and -r is the same
|
||||||
|
feBytes[31] &= 63; // < 2²⁵⁴
|
||||||
|
return Ristretto255.FromSingleElligatorBytes(feBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Recovers the 16 bytes from a Lizard-encoded point, or null if it isn't a valid encoding.</summary>
|
||||||
|
public static byte[]? Decode(Ristretto255 p)
|
||||||
|
{
|
||||||
|
(byte mask, Fe[] fes) = p.ElligatorInverse();
|
||||||
|
byte[]? result = null;
|
||||||
|
int found = 0;
|
||||||
|
Span<byte> recomputed = stackalloc byte[32];
|
||||||
|
for (int j = 0; j < 8; j++)
|
||||||
|
{
|
||||||
|
if (((mask >> j) & 1) == 0) continue;
|
||||||
|
byte[] buf = fes[j].Encode(); // 32-byte canonical encoding
|
||||||
|
SHA256.HashData(buf.AsSpan(8, 16), recomputed);
|
||||||
|
buf.AsSpan(8, 16).CopyTo(recomputed[8..24]);
|
||||||
|
recomputed[0] &= 254;
|
||||||
|
recomputed[31] &= 63;
|
||||||
|
if (!recomputed.SequenceEqual(buf)) continue;
|
||||||
|
result = buf[8..24];
|
||||||
|
found++;
|
||||||
|
}
|
||||||
|
return found == 1 ? result : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
namespace Wingnal.Protocol.ZkGroup.Curve;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hand-port of the Ristretto255 prime-order group (RFC 9496) on top of <see cref="Fe"/> / BouncyCastle's
|
||||||
|
/// edwards25519 base field — BouncyCastle 2.5.1 has no Ristretto, and zkgroup is built entirely on this
|
||||||
|
/// group. Provides point add / scalar-mul, the canonical 32-byte encode/decode, and the one-way map
|
||||||
|
/// <see cref="FromUniformBytes"/> (Elligator) used for hash-to-group. Correctness is gated by the RFC 9496
|
||||||
|
/// Appendix-A test vectors (multiples of the generator, invalid-encoding rejection, hash-to-group).
|
||||||
|
///
|
||||||
|
/// NOTE: scalar multiplication here is NOT constant-time (double-and-add with a data-dependent add). For
|
||||||
|
/// the client-side zkgroup proofs this is acceptable for a first correct version; harden later. See
|
||||||
|
/// docs/GROUPS.md / SHORTCUTS.md.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Ristretto255
|
||||||
|
{
|
||||||
|
// ── field constants (computed from definitions; SQRT_M1 hardcoded + self-checked in tests) ──
|
||||||
|
|
||||||
|
/// <summary>sqrt(-1) mod p, COMPUTED (not transcribed): p ≡ 5 (mod 8) ⇒ 2 is a non-residue, so
|
||||||
|
/// 2^((p-1)/4) is a square root of -1, and (p-1)/4 = 2·(p-5)/8 + 1 ⇒ SQRT_M1 = 2·(2^((p-5)/8))².
|
||||||
|
/// Self-checked SQRT_M1² == -1 by the Phase B vector test.</summary>
|
||||||
|
internal static readonly Fe SqrtM1 = BuildSqrtM1();
|
||||||
|
|
||||||
|
private static Fe BuildSqrtM1()
|
||||||
|
{
|
||||||
|
Fe two = Fe.Add(Fe.One(), Fe.One());
|
||||||
|
return Fe.Mul(Fe.Sqr(Fe.PowP58(two)), two);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static readonly Fe D = BuildD(); // edwards25519 d = -121665/121666
|
||||||
|
private static readonly Fe D2 = Fe.Add(D, D); // 2d, for the addition formula
|
||||||
|
private static readonly Fe OneMinusDSq = Fe.Sub(Fe.One(), Fe.Sqr(D)); // 1 - d²
|
||||||
|
private static readonly Fe DMinusOneSq = Fe.Sqr(Fe.Sub(D, Fe.One())); // (d - 1)²
|
||||||
|
// a = -1, so a - d = a*d - 1 = -1 - d.
|
||||||
|
private static readonly Fe AMinusD = Fe.Sub(Fe.Neg(Fe.One()), D); // -1 - d
|
||||||
|
// 1/sqrt(-1-d): the abs (even) root — matches dalek INVSQRT_A_MINUS_D (even).
|
||||||
|
internal static readonly Fe InvSqrtAMinusD = SqrtRatioM1(Fe.One(), AMinusD).root;
|
||||||
|
// sqrt(-1-d): dalek SQRT_AD_MINUS_ONE is the odd root, so negate the abs (even) root.
|
||||||
|
private static readonly Fe SqrtADMinusOne = Fe.Neg(SqrtRatioM1(AMinusD, Fe.One()).root);
|
||||||
|
|
||||||
|
// ── Lizard constants (computed from the definitions in the dalek-signal lizard_constants test) ──
|
||||||
|
// SQRT_ID = sqrt(i·d) (abs root); DP1_OVER_DM1 = (d+1)/(d-1);
|
||||||
|
// MDOUBLE_INVSQRT_A_MINUS_D = -2/sqrt(a-d); MIDOUBLE = that·i; MINVSQRT_ONE_PLUS_D = -1/sqrt(1+d).
|
||||||
|
private static readonly Fe SqrtId = SqrtRatioM1(Fe.Mul(SqrtM1, D), Fe.One()).root;
|
||||||
|
private static readonly Fe Dp1OverDm1 = Fe.Mul(Fe.Add(D, Fe.One()), Fe.Inv(Fe.Sub(D, Fe.One())));
|
||||||
|
private static readonly Fe MDoubleInvSqrtAMinusD = Fe.Neg(Fe.Add(InvSqrtAMinusD, InvSqrtAMinusD));
|
||||||
|
private static readonly Fe MiDoubleInvSqrtAMinusD = Fe.Mul(MDoubleInvSqrtAMinusD, SqrtM1);
|
||||||
|
private static readonly Fe MInvSqrtOnePlusD = Fe.Neg(SqrtRatioM1(Fe.One(), Fe.Add(D, Fe.One())).root);
|
||||||
|
|
||||||
|
private static Fe BuildD()
|
||||||
|
{
|
||||||
|
// d = -121665/121666 (computed, not transcribed).
|
||||||
|
var num = new byte[32]; num[0] = 0x41; num[1] = 0xDB; num[2] = 0x01; // 121665 = 0x1DB41
|
||||||
|
var den = new byte[32]; den[0] = 0x42; den[1] = 0xDB; den[2] = 0x01; // 121666 = 0x1DB42
|
||||||
|
return Fe.Neg(Fe.Mul(Fe.Decode(num), Fe.Inv(Fe.Decode(den))));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── point (extended twisted-Edwards coordinates X:Y:Z:T) ──
|
||||||
|
|
||||||
|
private readonly Fe _x, _y, _z, _t;
|
||||||
|
|
||||||
|
private Ristretto255(Fe x, Fe y, Fe z, Fe t) { _x = x; _y = y; _z = z; _t = t; }
|
||||||
|
|
||||||
|
/// <summary>The identity element.</summary>
|
||||||
|
public static Ristretto255 Identity => new(Fe.Zero(), Fe.One(), Fe.One(), Fe.Zero());
|
||||||
|
|
||||||
|
/// <summary>The Ristretto255 generator (canonical encoding e2f2ae0a…).</summary>
|
||||||
|
public static Ristretto255 BasePoint => Decode(
|
||||||
|
Convert.FromHexString("e2f2ae0a6abc4e71a884a961c500515f58e30b6aa582dd8db6a65945e08d2d76"))!;
|
||||||
|
|
||||||
|
/// <summary>Group addition (the complete a=-1 twisted-Edwards formula; also valid for doubling).</summary>
|
||||||
|
public static Ristretto255 Add(Ristretto255 p, Ristretto255 q)
|
||||||
|
{
|
||||||
|
Fe a = Fe.Mul(Fe.Sub(p._y, p._x), Fe.Sub(q._y, q._x)); // (Y1-X1)(Y2-X2)
|
||||||
|
Fe b = Fe.Mul(Fe.Add(p._y, p._x), Fe.Add(q._y, q._x)); // (Y1+X1)(Y2+X2)
|
||||||
|
Fe c = Fe.Mul(Fe.Mul(p._t, q._t), D2); // 2d·T1·T2
|
||||||
|
Fe d = Fe.Add(Fe.Mul(p._z, q._z), Fe.Mul(p._z, q._z)); // 2·Z1·Z2
|
||||||
|
Fe e = Fe.Sub(b, a), f = Fe.Sub(d, c), g = Fe.Add(d, c), h = Fe.Add(b, a);
|
||||||
|
return new Ristretto255(Fe.Mul(e, f), Fe.Mul(g, h), Fe.Mul(f, g), Fe.Mul(e, h));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Group negation: −(X:Y:Z:T) = (−X:Y:Z:−T).</summary>
|
||||||
|
public static Ristretto255 Negate(Ristretto255 p) => new(Fe.Neg(p._x), p._y, p._z, Fe.Neg(p._t));
|
||||||
|
|
||||||
|
/// <summary>scalar·this.</summary>
|
||||||
|
public Ristretto255 Multiply(Scalar25519 s) => Multiply(s.ToBytes());
|
||||||
|
|
||||||
|
/// <summary>scalar·this (double-and-add, MSB first; scalar is 32-byte little-endian).</summary>
|
||||||
|
public Ristretto255 Multiply(ReadOnlySpan<byte> scalarLe)
|
||||||
|
{
|
||||||
|
Ristretto255 r = Identity;
|
||||||
|
for (int i = 255; i >= 0; i--)
|
||||||
|
{
|
||||||
|
r = Add(r, r);
|
||||||
|
if (((scalarLe[i >> 3] >> (i & 7)) & 1) == 1) r = Add(r, this);
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ristretto equality: two representatives are equal iff X1·Y2 == Y1·X2 and Y1·Y2 == X1·X2
|
||||||
|
/// (RFC 9496 §4.3.6). Cheaper + sign-robust vs comparing encodings.</summary>
|
||||||
|
public bool ConstantTimeEquals(Ristretto255 q)
|
||||||
|
{
|
||||||
|
bool a = Fe.Mul(_x, q._y).ConstantTimeEquals(Fe.Mul(_y, q._x));
|
||||||
|
bool b = Fe.Mul(_y, q._y).ConstantTimeEquals(Fe.Mul(_x, q._x));
|
||||||
|
return a || b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── encode / decode (RFC 9496 §4.3.1–4.3.2) ──
|
||||||
|
|
||||||
|
public byte[] Encode()
|
||||||
|
{
|
||||||
|
Fe u1 = Fe.Mul(Fe.Add(_z, _y), Fe.Sub(_z, _y)); // (Z+Y)(Z-Y)
|
||||||
|
Fe u2 = Fe.Mul(_x, _y);
|
||||||
|
(_, Fe invsqrt) = SqrtRatioM1(Fe.One(), Fe.Mul(u1, Fe.Sqr(u2)));
|
||||||
|
Fe den1 = Fe.Mul(invsqrt, u1);
|
||||||
|
Fe den2 = Fe.Mul(invsqrt, u2);
|
||||||
|
Fe zInv = Fe.Mul(Fe.Mul(den1, den2), _t);
|
||||||
|
Fe ix = Fe.Mul(_x, SqrtM1);
|
||||||
|
Fe iy = Fe.Mul(_y, SqrtM1);
|
||||||
|
Fe enchantedDenominator = Fe.Mul(den1, InvSqrtAMinusD);
|
||||||
|
bool rotate = Fe.Mul(_t, zInv).IsNegative();
|
||||||
|
Fe x = Fe.Select(_x, iy, rotate);
|
||||||
|
Fe y = Fe.Select(_y, ix, rotate);
|
||||||
|
Fe denInv = Fe.Select(den2, enchantedDenominator, rotate);
|
||||||
|
y = Fe.Select(y, Fe.Neg(y), Fe.Mul(x, zInv).IsNegative());
|
||||||
|
Fe s = Fe.Mul(denInv, Fe.Sub(_z, y)).Abs();
|
||||||
|
return s.Encode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Ristretto255? Decode(ReadOnlySpan<byte> bytes32)
|
||||||
|
{
|
||||||
|
if (bytes32.Length != 32) return null;
|
||||||
|
Fe s = Fe.Decode(bytes32);
|
||||||
|
// s must be the canonical encoding of a non-negative field element.
|
||||||
|
if (!s.Encode().AsSpan().SequenceEqual(bytes32) || s.IsNegative()) return null;
|
||||||
|
|
||||||
|
Fe ss = Fe.Sqr(s);
|
||||||
|
Fe u1 = Fe.Sub(Fe.One(), ss); // 1 - s²
|
||||||
|
Fe u2 = Fe.Add(Fe.One(), ss); // 1 + s²
|
||||||
|
Fe u2Sqr = Fe.Sqr(u2);
|
||||||
|
Fe v = Fe.Sub(Fe.Neg(Fe.Mul(D, Fe.Sqr(u1))), u2Sqr); // -(d·u1²) - u2²
|
||||||
|
(bool wasSquare, Fe invsqrt) = SqrtRatioM1(Fe.One(), Fe.Mul(v, u2Sqr));
|
||||||
|
Fe denX = Fe.Mul(invsqrt, u2);
|
||||||
|
Fe denY = Fe.Mul(Fe.Mul(invsqrt, denX), v);
|
||||||
|
Fe x = Fe.Mul(Fe.Add(s, s), denX).Abs(); // |2·s·den_x|
|
||||||
|
Fe y = Fe.Mul(u1, denY);
|
||||||
|
Fe t = Fe.Mul(x, y);
|
||||||
|
if (!wasSquare || t.IsNegative() || y.IsZero()) return null;
|
||||||
|
return new Ristretto255(x, y, Fe.One(), t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── hash-to-group (RFC 9496 §4.3.4) ──
|
||||||
|
|
||||||
|
/// <summary>Maps 64 uniformly-random bytes to a group element (two Elligator maps + add).</summary>
|
||||||
|
public static Ristretto255 FromUniformBytes(ReadOnlySpan<byte> bytes64)
|
||||||
|
{
|
||||||
|
Ristretto255 p1 = ElligatorRistrettoFlavor(Fe.Decode(bytes64[..32]));
|
||||||
|
Ristretto255 p2 = ElligatorRistrettoFlavor(Fe.Decode(bytes64[32..64]));
|
||||||
|
return Add(p1, p2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Maps a single 32-byte field element to a group element (one Elligator map). This is
|
||||||
|
/// dalek-signal's <c>from_uniform_bytes_single_elligator</c> / zkgroup's <c>get_point_single_elligator</c>,
|
||||||
|
/// and the encode half of Lizard.</summary>
|
||||||
|
public static Ristretto255 FromSingleElligatorBytes(ReadOnlySpan<byte> bytes32) =>
|
||||||
|
ElligatorRistrettoFlavor(Fe.Decode(bytes32));
|
||||||
|
|
||||||
|
/// <summary>The Ristretto-flavored Elligator2 map (RFC 9496 §4.3.4 MAP). Public so Lizard can reuse it.</summary>
|
||||||
|
internal static Ristretto255 ElligatorRistrettoFlavor(Fe t)
|
||||||
|
{
|
||||||
|
Fe r = Fe.Mul(SqrtM1, Fe.Sqr(t));
|
||||||
|
Fe u = Fe.Mul(Fe.Add(r, Fe.One()), OneMinusDSq);
|
||||||
|
Fe c = Fe.Neg(Fe.One());
|
||||||
|
Fe v = Fe.Mul(Fe.Sub(c, Fe.Mul(r, D)), Fe.Add(r, D));
|
||||||
|
(bool wasSquare, Fe s) = SqrtRatioM1(u, v);
|
||||||
|
Fe sPrime = Fe.Neg(Fe.Mul(s, t).Abs());
|
||||||
|
s = Fe.Select(sPrime, s, wasSquare);
|
||||||
|
c = Fe.Select(r, c, wasSquare);
|
||||||
|
Fe n = Fe.Sub(Fe.Mul(Fe.Mul(c, Fe.Sub(r, Fe.One())), DMinusOneSq), v);
|
||||||
|
Fe w0 = Fe.Add(Fe.Mul(s, v), Fe.Mul(s, v)); // 2·s·v
|
||||||
|
Fe w1 = Fe.Mul(n, SqrtADMinusOne);
|
||||||
|
Fe w2 = Fe.Sub(Fe.One(), Fe.Sqr(s));
|
||||||
|
Fe w3 = Fe.Add(Fe.One(), Fe.Sqr(s));
|
||||||
|
return new Ristretto255(Fe.Mul(w0, w3), Fe.Mul(w2, w1), Fe.Mul(w1, w3), Fe.Mul(w0, w2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── sqrt_ratio_i (RFC 9496 §4.3) : returns (wasSquare, |sqrt(u/v)|) ──
|
||||||
|
|
||||||
|
internal static (bool wasSquare, Fe root) SqrtRatioM1(Fe u, Fe v)
|
||||||
|
{
|
||||||
|
Fe v3 = Fe.Mul(Fe.Sqr(v), v);
|
||||||
|
Fe v7 = Fe.Mul(Fe.Sqr(v3), v);
|
||||||
|
Fe r = Fe.Mul(Fe.Mul(u, v3), Fe.PowP58(Fe.Mul(u, v7)));
|
||||||
|
Fe check = Fe.Mul(v, Fe.Sqr(r));
|
||||||
|
Fe uNeg = Fe.Neg(u);
|
||||||
|
bool correct = check.ConstantTimeEquals(u);
|
||||||
|
bool flipped = check.ConstantTimeEquals(uNeg);
|
||||||
|
bool flippedI = check.ConstantTimeEquals(Fe.Mul(uNeg, SqrtM1));
|
||||||
|
Fe rPrime = Fe.Mul(SqrtM1, r);
|
||||||
|
r = Fe.Select(r, rPrime, flipped || flippedI);
|
||||||
|
return (correct || flipped, r.Abs());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Elligator inverse (for Lizard decode) — port of the dalek-signal lizard fork ──
|
||||||
|
|
||||||
|
private readonly struct JacobiPoint
|
||||||
|
{
|
||||||
|
public readonly Fe S, T;
|
||||||
|
public JacobiPoint(Fe s, Fe t) { S = s; T = t; }
|
||||||
|
public JacobiPoint Dual() => new(Fe.Neg(S), Fe.Neg(T));
|
||||||
|
|
||||||
|
/// <summary>Computes the field element that Elligator2 maps to this Jacobi-quartic point, if any.</summary>
|
||||||
|
public (bool ok, Fe fe) ElligatorInv()
|
||||||
|
{
|
||||||
|
Fe outFe = Fe.Zero();
|
||||||
|
bool sIsZero = S.IsZero();
|
||||||
|
bool tEqualsOne = T.ConstantTimeEquals(Fe.One());
|
||||||
|
outFe = Fe.Select(outFe, SqrtId, tEqualsOne);
|
||||||
|
bool ret = sIsZero;
|
||||||
|
bool done = sIsZero;
|
||||||
|
|
||||||
|
Fe a = Fe.Mul(Fe.Add(T, Fe.One()), Dp1OverDm1);
|
||||||
|
Fe a2 = Fe.Sqr(a);
|
||||||
|
Fe s2 = Fe.Sqr(S);
|
||||||
|
Fe s4 = Fe.Sqr(s2);
|
||||||
|
Fe invSqY = Fe.Mul(Fe.Sub(s4, a2), SqrtM1);
|
||||||
|
(bool sq, Fe y) = SqrtRatioM1(Fe.One(), invSqY); // invsqrt
|
||||||
|
ret = ret || sq;
|
||||||
|
done = done || !sq;
|
||||||
|
|
||||||
|
Fe pms2 = Fe.Select(s2, Fe.Neg(s2), S.IsNegative()); // sign(s)·s²
|
||||||
|
Fe x = Fe.Mul(Fe.Add(a, pms2), y);
|
||||||
|
x = Fe.Select(x, Fe.Neg(x), x.IsNegative()); // |x|
|
||||||
|
outFe = Fe.Select(outFe, x, !done);
|
||||||
|
return (ret, outFe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Computes the (at most 8) positive field elements f with this == ElligatorRistrettoFlavor(f),
|
||||||
|
/// plus a bitmask of which slots are set. Assumes this is even. Port of dalek-signal's
|
||||||
|
/// <c>elligator_ristretto_flavor_inverse</c>.</summary>
|
||||||
|
internal (byte mask, Fe[] fes) ElligatorInverse()
|
||||||
|
{
|
||||||
|
JacobiPoint[] jcs = ToJacobiQuarticRistretto();
|
||||||
|
var fes = new Fe[8];
|
||||||
|
for (int i = 0; i < 8; i++) fes[i] = Fe.One();
|
||||||
|
byte mask = 0;
|
||||||
|
for (int i = 0; i < 4; i++)
|
||||||
|
{
|
||||||
|
(bool ok0, Fe fe0) = jcs[i].ElligatorInv();
|
||||||
|
fes[2 * i] = fe0;
|
||||||
|
if (ok0) mask |= (byte)(1 << (2 * i));
|
||||||
|
(bool ok1, Fe fe1) = jcs[i].Dual().ElligatorInv();
|
||||||
|
fes[2 * i + 1] = fe1;
|
||||||
|
if (ok1) mask |= (byte)(1 << (2 * i + 1));
|
||||||
|
}
|
||||||
|
return (mask, fes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JacobiPoint[] ToJacobiQuarticRistretto()
|
||||||
|
{
|
||||||
|
Fe x2 = Fe.Sqr(_x), y2 = Fe.Sqr(_y), y4 = Fe.Sqr(y2), z2 = Fe.Sqr(_z);
|
||||||
|
Fe zMinY = Fe.Sub(_z, _y), zPlY = Fe.Add(_z, _y);
|
||||||
|
Fe z2MinY2 = Fe.Sub(z2, y2);
|
||||||
|
|
||||||
|
// gamma = 1/sqrt(Y⁴·X²·(Z²−Y²))
|
||||||
|
(_, Fe gamma) = SqrtRatioM1(Fe.One(), Fe.Mul(Fe.Mul(y4, x2), z2MinY2));
|
||||||
|
Fe den = Fe.Mul(gamma, y2);
|
||||||
|
Fe sOverX = Fe.Mul(den, zMinY);
|
||||||
|
Fe spOverXp = Fe.Mul(den, zPlY);
|
||||||
|
Fe s0 = Fe.Mul(sOverX, _x);
|
||||||
|
Fe s1 = Fe.Mul(Fe.Neg(spOverXp), _x);
|
||||||
|
Fe tmp = Fe.Mul(MDoubleInvSqrtAMinusD, _z);
|
||||||
|
Fe t0 = Fe.Mul(tmp, sOverX);
|
||||||
|
Fe t1 = Fe.Mul(tmp, spOverXp);
|
||||||
|
|
||||||
|
// den = -1/sqrt(1+d)·(Y²−Z²)·gamma (substitution (X,Y,Z) -> (Y,X,iZ))
|
||||||
|
Fe den2 = Fe.Mul(Fe.Mul(Fe.Neg(z2MinY2), MInvSqrtOnePlusD), gamma);
|
||||||
|
Fe iz = Fe.Mul(SqrtM1, _z);
|
||||||
|
Fe izMinX = Fe.Sub(iz, _x), izPlX = Fe.Add(iz, _x);
|
||||||
|
Fe sOverY = Fe.Mul(den2, izMinX);
|
||||||
|
Fe spOverYp = Fe.Mul(den2, izPlX);
|
||||||
|
Fe s2 = Fe.Mul(sOverY, _y);
|
||||||
|
Fe s3 = Fe.Mul(Fe.Neg(spOverYp), _y);
|
||||||
|
Fe tmp2 = Fe.Mul(MDoubleInvSqrtAMinusD, iz);
|
||||||
|
Fe t2 = Fe.Mul(tmp2, sOverY);
|
||||||
|
Fe t3 = Fe.Mul(tmp2, spOverYp);
|
||||||
|
|
||||||
|
// Special case X=0 or Y=0 (then sᵢ=tᵢ=0): return fixed coset points.
|
||||||
|
bool xy0 = _x.IsZero() || _y.IsZero();
|
||||||
|
t0 = Fe.Select(t0, Fe.One(), xy0);
|
||||||
|
t1 = Fe.Select(t1, Fe.One(), xy0);
|
||||||
|
t2 = Fe.Select(t2, MiDoubleInvSqrtAMinusD, xy0);
|
||||||
|
t3 = Fe.Select(t3, MiDoubleInvSqrtAMinusD, xy0);
|
||||||
|
s2 = Fe.Select(s2, Fe.One(), xy0);
|
||||||
|
s3 = Fe.Select(s3, Fe.Neg(Fe.One()), xy0);
|
||||||
|
|
||||||
|
return new[]
|
||||||
|
{
|
||||||
|
new JacobiPoint(s0, t0), new JacobiPoint(s1, t1),
|
||||||
|
new JacobiPoint(s2, t2), new JacobiPoint(s3, t3),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.ZkGroup.Curve;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An integer modulo ℓ = 2²⁵² + 27742317777372353535851937790883648493 (the order of the Ristretto255 /
|
||||||
|
/// edwards25519 prime-order group). Backs zkgroup's scalar arithmetic. Implemented on
|
||||||
|
/// <see cref="BigInteger"/> for a clear first-correct version — NOT constant-time; harden with the ref10
|
||||||
|
/// <c>sc_*</c> routines later (see docs/GROUPS.md / SHORTCUTS.md). Canonical wire form is 32 little-endian
|
||||||
|
/// bytes.
|
||||||
|
/// </summary>
|
||||||
|
public readonly struct Scalar25519 : IEquatable<Scalar25519>
|
||||||
|
{
|
||||||
|
/// <summary>The group order ℓ.</summary>
|
||||||
|
public static readonly BigInteger L =
|
||||||
|
BigInteger.Pow(2, 252) + BigInteger.Parse("27742317777372353535851937790883648493");
|
||||||
|
|
||||||
|
private readonly BigInteger _v; // always reduced into [0, L)
|
||||||
|
|
||||||
|
private Scalar25519(BigInteger v)
|
||||||
|
{
|
||||||
|
BigInteger m = v % L;
|
||||||
|
_v = m.Sign < 0 ? m + L : m;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Scalar25519 Zero => new(BigInteger.Zero);
|
||||||
|
public static Scalar25519 One => new(BigInteger.One);
|
||||||
|
|
||||||
|
/// <summary>Reduces a 32-byte little-endian value mod ℓ.</summary>
|
||||||
|
public static Scalar25519 FromBytesModOrder(ReadOnlySpan<byte> le32) =>
|
||||||
|
new(new BigInteger(le32, isUnsigned: true, isBigEndian: false));
|
||||||
|
|
||||||
|
/// <summary>Reduces a 64-byte little-endian value mod ℓ (uniform hash → scalar).</summary>
|
||||||
|
public static Scalar25519 FromBytesModOrderWide(ReadOnlySpan<byte> le64) =>
|
||||||
|
new(new BigInteger(le64, isUnsigned: true, isBigEndian: false));
|
||||||
|
|
||||||
|
public static Scalar25519 FromBigInteger(BigInteger v) => new(v);
|
||||||
|
|
||||||
|
/// <summary>Parses a 32-byte little-endian scalar, returning null if it is not canonical (≥ ℓ).</summary>
|
||||||
|
public static Scalar25519? FromCanonicalBytes(ReadOnlySpan<byte> le32)
|
||||||
|
{
|
||||||
|
if (le32.Length != 32) return null;
|
||||||
|
var v = new BigInteger(le32, isUnsigned: true, isBigEndian: false);
|
||||||
|
return v >= L ? null : new Scalar25519(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>32-byte little-endian canonical encoding.</summary>
|
||||||
|
public byte[] ToBytes()
|
||||||
|
{
|
||||||
|
byte[] raw = _v.ToByteArray(isUnsigned: true, isBigEndian: false);
|
||||||
|
var result = new byte[32];
|
||||||
|
Array.Copy(raw, result, Math.Min(raw.Length, 32));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigInteger ToBigInteger() => _v;
|
||||||
|
|
||||||
|
public static Scalar25519 Add(Scalar25519 a, Scalar25519 b) => new(a._v + b._v);
|
||||||
|
public static Scalar25519 Sub(Scalar25519 a, Scalar25519 b) => new(a._v - b._v);
|
||||||
|
public static Scalar25519 Mul(Scalar25519 a, Scalar25519 b) => new(a._v * b._v);
|
||||||
|
public static Scalar25519 Negate(Scalar25519 a) => new(-a._v);
|
||||||
|
|
||||||
|
/// <summary>Multiplicative inverse mod ℓ (ℓ is prime, so via Fermat: a^(ℓ-2)).</summary>
|
||||||
|
public Scalar25519 Invert() => new(BigInteger.ModPow(_v, L - 2, L));
|
||||||
|
|
||||||
|
public bool Equals(Scalar25519 other) => _v == other._v;
|
||||||
|
public override bool Equals(object? obj) => obj is Scalar25519 s && Equals(s);
|
||||||
|
public override int GetHashCode() => _v.GetHashCode();
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Text;
|
||||||
|
using Org.BouncyCastle.Crypto.Engines;
|
||||||
|
using Org.BouncyCastle.Crypto.Modes;
|
||||||
|
using Org.BouncyCastle.Crypto.Parameters;
|
||||||
|
using Wingnal.Protocol.ZkGroup.Curve;
|
||||||
|
using Wingnal.Protocol.ZkGroup.Poksho;
|
||||||
|
using Wingnal.Protocol.ZkGroup.ZkCredential;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.ZkGroup;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// zkgroup's <c>GroupSecretParams</c> derived from the 32-byte group master key (which arrives in the
|
||||||
|
/// <c>GroupContextV2</c> of group messages and via Storage Service). Provides the 32-byte group identifier
|
||||||
|
/// (used to key a group conversation) and AES-256-GCM-SIV encryption of the group's title/avatar/etc.
|
||||||
|
/// "blobs" under the derived blob key. Byte-exact with libsignal zkgroup.
|
||||||
|
///
|
||||||
|
/// NOTE: the member-hiding ciphertext + credential layer (UuidCiphertext, AuthCredentialWithPni, …) is
|
||||||
|
/// NOT here yet — it requires porting the zkcredential crate + the dalek-fork "Lizard" 16-byte→point
|
||||||
|
/// encoding (see docs/GROUPS.md Phase D remainder). This type covers group-id derivation (enough for
|
||||||
|
/// receiving group messages) and blob decryption.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GroupSecretParams
|
||||||
|
{
|
||||||
|
private const int MasterKeyLen = 32;
|
||||||
|
|
||||||
|
public byte[] MasterKey { get; }
|
||||||
|
public byte[] GroupIdentifier { get; } // 32 bytes
|
||||||
|
private readonly byte[] _blobKey; // 32-byte AES key
|
||||||
|
|
||||||
|
/// <summary>The group's UID verifiable-encryption key pair (encrypts/decrypts member ACIs/PNIs).</summary>
|
||||||
|
public AttributeKeyPair UidKeyPair { get; }
|
||||||
|
|
||||||
|
/// <summary>The group's profile-key verifiable-encryption key pair.</summary>
|
||||||
|
public AttributeKeyPair ProfileKeyKeyPair { get; }
|
||||||
|
|
||||||
|
private GroupSecretParams(byte[] masterKey, byte[] groupId, byte[] blobKey,
|
||||||
|
AttributeKeyPair uidKeyPair, AttributeKeyPair profileKeyKeyPair)
|
||||||
|
{
|
||||||
|
MasterKey = masterKey;
|
||||||
|
GroupIdentifier = groupId;
|
||||||
|
_blobKey = blobKey;
|
||||||
|
UidKeyPair = uidKeyPair;
|
||||||
|
ProfileKeyKeyPair = profileKeyKeyPair;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GroupSecretParams Generate(byte[] randomness)
|
||||||
|
{
|
||||||
|
var sho = new ShoHmacSha256(Ascii("Signal_ZKGroup_20200424_Random_GroupSecretParams_Generate"));
|
||||||
|
sho.AbsorbAndRatchet(randomness);
|
||||||
|
return DeriveFromMasterKey(sho.SqueezeAndRatchet(MasterKeyLen));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GroupSecretParams DeriveFromMasterKey(byte[] masterKey)
|
||||||
|
{
|
||||||
|
if (masterKey.Length != MasterKeyLen) throw new ArgumentException("master key must be 32 bytes");
|
||||||
|
var sho = new ShoHmacSha256(
|
||||||
|
Ascii("Signal_ZKGroup_20200424_GroupMasterKey_GroupSecretParams_DeriveFromMasterKey"));
|
||||||
|
sho.AbsorbAndRatchet(masterKey);
|
||||||
|
byte[] groupId = sho.SqueezeAndRatchet(32);
|
||||||
|
byte[] blobKey = sho.SqueezeAndRatchet(32);
|
||||||
|
// The SAME sho continues into both encryption key pairs (order: uid then profile-key).
|
||||||
|
AttributeKeyPair uidKeyPair = UidEncryption.DeriveKeyPair(sho);
|
||||||
|
AttributeKeyPair profileKeyKeyPair = ProfileKeyEncryption.DeriveKeyPair(sho);
|
||||||
|
return new GroupSecretParams((byte[])masterKey.Clone(), groupId, blobKey, uidKeyPair, profileKeyKeyPair);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── public params + member-ciphertext helpers (the visible part of the group) ──
|
||||||
|
|
||||||
|
/// <summary>The group's public params: group id + the two encryption public keys (97 bytes serialized).</summary>
|
||||||
|
public byte[] PublicParamsSerialized()
|
||||||
|
{
|
||||||
|
var b = new byte[97];
|
||||||
|
b[0] = 0; // reserved
|
||||||
|
Array.Copy(GroupIdentifier, 0, b, 1, 32);
|
||||||
|
Array.Copy(UidKeyPair.PublicKey.Encode(), 0, b, 33, 32);
|
||||||
|
Array.Copy(ProfileKeyKeyPair.PublicKey.Encode(), 0, b, 65, 32);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UuidCiphertext EncryptServiceId(ServiceId serviceId) =>
|
||||||
|
new(UidEncryption.Encrypt(UidKeyPair, UidStruct.FromServiceId(serviceId)));
|
||||||
|
|
||||||
|
public ServiceId DecryptServiceId(UuidCiphertext ciphertext) =>
|
||||||
|
UidEncryption.Decrypt(UidKeyPair, ciphertext.Ciphertext);
|
||||||
|
|
||||||
|
public ProfileKeyCiphertext EncryptProfileKey(byte[] profileKey32, byte[] aciUuid16) =>
|
||||||
|
new(ProfileKeyEncryption.Encrypt(ProfileKeyKeyPair, ProfileKeyStruct.New(profileKey32, aciUuid16)));
|
||||||
|
|
||||||
|
public byte[] DecryptProfileKey(ProfileKeyCiphertext ciphertext, byte[] aciUuid16) =>
|
||||||
|
ProfileKeyEncryption.Decrypt(ProfileKeyKeyPair, ciphertext.Ciphertext, aciUuid16);
|
||||||
|
|
||||||
|
// ── blob encryption (AES-256-GCM-SIV; RFC 8452) ──
|
||||||
|
|
||||||
|
public byte[] EncryptBlobWithPadding(byte[] randomness, byte[] plaintext, uint paddingLen)
|
||||||
|
{
|
||||||
|
var padded = new byte[4 + plaintext.Length + (int)paddingLen];
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(padded, paddingLen);
|
||||||
|
Array.Copy(plaintext, 0, padded, 4, plaintext.Length);
|
||||||
|
return EncryptBlob(randomness, padded);
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] EncryptBlob(byte[] randomness, byte[] plaintext)
|
||||||
|
{
|
||||||
|
var sho = new ShoHmacSha256(Ascii("Signal_ZKGroup_20200424_Random_GroupSecretParams_EncryptBlob"));
|
||||||
|
sho.AbsorbAndRatchet(randomness);
|
||||||
|
byte[] nonce = sho.SqueezeAndRatchet(12);
|
||||||
|
byte[] ct = GcmSiv(forEncryption: true, _blobKey, nonce, plaintext);
|
||||||
|
var result = new byte[ct.Length + 12 + 1]; // ciphertext‖nonce‖reserved(0)
|
||||||
|
Array.Copy(ct, result, ct.Length);
|
||||||
|
Array.Copy(nonce, 0, result, ct.Length, 12);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] DecryptBlobWithPadding(byte[] ciphertext)
|
||||||
|
{
|
||||||
|
byte[] dec = DecryptBlob(ciphertext);
|
||||||
|
if (dec.Length < 4) throw new ArgumentException("blob too short");
|
||||||
|
uint padLen = BinaryPrimitives.ReadUInt32BigEndian(dec);
|
||||||
|
int plen = dec.Length - 4 - (int)padLen;
|
||||||
|
if (plen < 0) throw new ArgumentException("bad padding length");
|
||||||
|
var pt = new byte[plen];
|
||||||
|
Array.Copy(dec, 4, pt, 0, plen);
|
||||||
|
return pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] DecryptBlob(byte[] ciphertext)
|
||||||
|
{
|
||||||
|
if (ciphertext.Length < 12 + 1) throw new ArgumentException("blob too short");
|
||||||
|
int unreserved = ciphertext.Length - 1; // drop trailing reserved byte
|
||||||
|
var nonce = new byte[12];
|
||||||
|
Array.Copy(ciphertext, unreserved - 12, nonce, 0, 12);
|
||||||
|
var ct = new byte[unreserved - 12];
|
||||||
|
Array.Copy(ciphertext, 0, ct, 0, ct.Length);
|
||||||
|
return GcmSiv(forEncryption: false, _blobKey, nonce, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] GcmSiv(bool forEncryption, byte[] key, byte[] nonce, byte[] input)
|
||||||
|
{
|
||||||
|
var cipher = new GcmSivBlockCipher(new AesEngine());
|
||||||
|
cipher.Init(forEncryption, new AeadParameters(new KeyParameter(key), 128, nonce));
|
||||||
|
var outBuf = new byte[cipher.GetOutputSize(input.Length)];
|
||||||
|
int n = cipher.ProcessBytes(input, 0, input.Length, outBuf, 0);
|
||||||
|
n += cipher.DoFinal(outBuf, n);
|
||||||
|
if (n != outBuf.Length) Array.Resize(ref outBuf, n);
|
||||||
|
return outBuf;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Ascii(string s) => Encoding.ASCII.GetBytes(s);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Wingnal.Protocol.ZkGroup.Curve;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.ZkGroup.Poksho;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// poksho's Schnorr signature (<c>poksho::sign</c>/<c>verify_signature</c>): a one-equation proof of
|
||||||
|
/// knowledge of the discrete log of a public key (<c>public_key = private_key·G</c>) bound to a message.
|
||||||
|
/// zkgroup signs each <c>GroupChange</c> with this (the server's sig key); the client verifies it before
|
||||||
|
/// applying a change. Reuses the byte-exact <see cref="Statement"/> engine, so signatures are interoperable
|
||||||
|
/// with libsignal. Validated against poksho's own signature vector.
|
||||||
|
/// </summary>
|
||||||
|
public static class PokshoSignature
|
||||||
|
{
|
||||||
|
/// <summary>Verifies a 64-byte signature over <paramref name="message"/> by <paramref name="publicKey"/>.</summary>
|
||||||
|
public static bool Verify(byte[] signature, Ristretto255 publicKey, byte[] message)
|
||||||
|
{
|
||||||
|
Statement st = SignatureStatement();
|
||||||
|
var points = new Dictionary<string, Ristretto255> { ["public_key"] = publicKey };
|
||||||
|
return st.VerifyProof(signature, points, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Produces a signature (needs the private scalar; mainly for offline testing).</summary>
|
||||||
|
public static byte[] Sign(Scalar25519 privateKey, Ristretto255 publicKey, byte[] message, byte[] randomness)
|
||||||
|
{
|
||||||
|
Statement st = SignatureStatement();
|
||||||
|
var scalars = new Dictionary<string, Scalar25519> { ["private_key"] = privateKey };
|
||||||
|
var points = new Dictionary<string, Ristretto255> { ["public_key"] = publicKey };
|
||||||
|
return st.Prove(scalars, points, message, randomness);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Statement SignatureStatement()
|
||||||
|
{
|
||||||
|
var st = new Statement();
|
||||||
|
st.Add("public_key", ("private_key", "G")); // G = the Ristretto basepoint (statement index 0)
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Wingnal.Protocol.ZkGroup.Curve;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.ZkGroup.Poksho;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Byte-exact port of libsignal poksho's Sigma/Schnorr proof system for arbitrary linear relations
|
||||||
|
/// (Boneh-Shoup §19.5.3) over Ristretto255. A <see cref="Statement"/> is a set of equations
|
||||||
|
/// "P = Σ scalarᵢ·pointᵢ"; <see cref="Statement.Prove"/> produces a Fiat-Shamir proof of knowledge of the
|
||||||
|
/// witness scalars, and <see cref="Statement.VerifyProof"/> checks it. The Fiat-Shamir transcript uses
|
||||||
|
/// <see cref="ShoHmacSha256"/> with label "POKSHO_Ristretto_SHOHMACSHA256". zkgroup credentials are all
|
||||||
|
/// expressed as poksho statements. Validated against poksho's own prove/verify test vector.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Statement
|
||||||
|
{
|
||||||
|
private static readonly byte[] Label =
|
||||||
|
System.Text.Encoding.ASCII.GetBytes("POKSHO_Ristretto_SHOHMACSHA256");
|
||||||
|
|
||||||
|
private readonly record struct Term(byte Scalar, byte Point);
|
||||||
|
private readonly record struct Equation(byte Lhs, List<Term> Rhs);
|
||||||
|
|
||||||
|
private readonly List<Equation> _equations = new();
|
||||||
|
private readonly Dictionary<string, byte> _scalarMap = new();
|
||||||
|
private readonly List<string> _scalarVec = new();
|
||||||
|
private readonly Dictionary<string, byte> _pointMap = new() { ["G"] = 0 };
|
||||||
|
private readonly List<string> _pointVec = new() { "G" }; // index 0 = Ristretto base point
|
||||||
|
|
||||||
|
/// <summary>Adds the equation lhs = Σ (scalar·point) over the given (scalarName, pointName) terms.</summary>
|
||||||
|
public void Add(string lhs, params (string scalar, string point)[] rhs)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(lhs) || rhs.Length == 0 || rhs.Length > 255 || _equations.Count >= 255)
|
||||||
|
throw new ArgumentException("poksho: bad statement sizes");
|
||||||
|
byte lhsIdx = AddPoint(lhs);
|
||||||
|
var terms = new List<Term>(rhs.Length);
|
||||||
|
foreach ((string s, string p) in rhs)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(s) || string.IsNullOrEmpty(p)) throw new ArgumentException("poksho: empty name");
|
||||||
|
terms.Add(new Term(AddScalar(s), AddPoint(p)));
|
||||||
|
}
|
||||||
|
_equations.Add(new Equation(lhsIdx, terms));
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte AddScalar(string name)
|
||||||
|
{
|
||||||
|
if (_scalarMap.TryGetValue(name, out byte i)) return i;
|
||||||
|
byte idx = checked((byte)_scalarMap.Count);
|
||||||
|
_scalarMap[name] = idx; _scalarVec.Add(name);
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte AddPoint(string name)
|
||||||
|
{
|
||||||
|
if (_pointMap.TryGetValue(name, out byte i)) return i;
|
||||||
|
byte idx = checked((byte)_pointMap.Count);
|
||||||
|
_pointMap[name] = idx; _pointVec.Add(name);
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal byte[] ToBytes()
|
||||||
|
{
|
||||||
|
var v = new List<byte> { (byte)_equations.Count };
|
||||||
|
foreach (Equation e in _equations)
|
||||||
|
{
|
||||||
|
v.Add(e.Lhs);
|
||||||
|
v.Add((byte)e.Rhs.Count);
|
||||||
|
foreach (Term t in e.Rhs) { v.Add(t.Scalar); v.Add(t.Point); }
|
||||||
|
}
|
||||||
|
return v.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Scalar25519[] SortScalars(IReadOnlyDictionary<string, Scalar25519> args)
|
||||||
|
{
|
||||||
|
if (args.Count != _scalarVec.Count) throw new ArgumentException("poksho: wrong number of scalar args");
|
||||||
|
return _scalarVec.Select(n => args.TryGetValue(n, out Scalar25519 s)
|
||||||
|
? s : throw new ArgumentException($"poksho: missing scalar {n}")).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Ristretto255[] SortPoints(IReadOnlyDictionary<string, Ristretto255> args)
|
||||||
|
{
|
||||||
|
if (args.Count != _pointVec.Count - 1) throw new ArgumentException("poksho: wrong number of point args");
|
||||||
|
var pts = new Ristretto255[_pointVec.Count];
|
||||||
|
pts[0] = Ristretto255.BasePoint;
|
||||||
|
for (int i = 1; i < _pointVec.Count; i++)
|
||||||
|
pts[i] = args.TryGetValue(_pointVec[i], out Ristretto255? p)
|
||||||
|
? p! : throw new ArgumentException($"poksho: missing point {_pointVec[i]}");
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// commitment[eq] = Σ g1[scalar]·points[point] (+ (-challenge)·points[lhs] when verifying)
|
||||||
|
private Ristretto255[] Homomorphism(Scalar25519[] g1, Ristretto255[] points, Scalar25519? challenge)
|
||||||
|
{
|
||||||
|
var result = new Ristretto255[_equations.Count];
|
||||||
|
for (int k = 0; k < _equations.Count; k++)
|
||||||
|
{
|
||||||
|
Equation e = _equations[k];
|
||||||
|
Ristretto255 acc = Ristretto255.Identity;
|
||||||
|
foreach (Term t in e.Rhs)
|
||||||
|
acc = Ristretto255.Add(acc, points[t.Point].Multiply(g1[t.Scalar]));
|
||||||
|
if (challenge is { } h)
|
||||||
|
acc = Ristretto255.Add(acc, points[e.Lhs].Multiply(Scalar25519.Negate(h)));
|
||||||
|
result[k] = acc;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] Prove(IReadOnlyDictionary<string, Scalar25519> scalarArgs,
|
||||||
|
IReadOnlyDictionary<string, Ristretto255> pointArgs, byte[] message, byte[] randomness)
|
||||||
|
{
|
||||||
|
if (randomness.Length != 32) throw new ArgumentException("poksho: randomness must be 32 bytes");
|
||||||
|
Scalar25519[] g1 = SortScalars(scalarArgs);
|
||||||
|
Ristretto255[] allPoints = SortPoints(pointArgs);
|
||||||
|
|
||||||
|
var sho = new ShoHmacSha256(Label);
|
||||||
|
sho.Absorb(ToBytes()); // D
|
||||||
|
foreach (Ristretto255 p in allPoints) sho.Absorb(p.Encode()); // A
|
||||||
|
sho.Ratchet();
|
||||||
|
|
||||||
|
// Synthetic nonce: hash randomness ‖ witness ‖ message in a forked transcript.
|
||||||
|
ShoHmacSha256 sho2 = sho.Clone();
|
||||||
|
sho2.Absorb(randomness); // Z
|
||||||
|
foreach (Scalar25519 s in g1) sho2.Absorb(s.ToBytes()); // a
|
||||||
|
sho2.Ratchet();
|
||||||
|
sho2.AbsorbAndRatchet(message); // M
|
||||||
|
byte[] nonceBytes = sho2.SqueezeAndRatchet(g1.Length * 64);
|
||||||
|
var nonce = new Scalar25519[g1.Length];
|
||||||
|
for (int i = 0; i < g1.Length; i++)
|
||||||
|
nonce[i] = Scalar25519.FromBytesModOrderWide(nonceBytes.AsSpan(i * 64, 64));
|
||||||
|
|
||||||
|
Ristretto255[] commitment = Homomorphism(nonce, allPoints, null);
|
||||||
|
foreach (Ristretto255 r in commitment) sho.Absorb(r.Encode()); // R
|
||||||
|
sho.AbsorbAndRatchet(message); // M
|
||||||
|
Scalar25519 challenge = Scalar25519.FromBytesModOrderWide(sho.SqueezeAndRatchet(64));
|
||||||
|
|
||||||
|
var response = new Scalar25519[g1.Length];
|
||||||
|
for (int i = 0; i < g1.Length; i++)
|
||||||
|
response[i] = Scalar25519.Add(nonce[i], Scalar25519.Mul(g1[i], challenge));
|
||||||
|
|
||||||
|
byte[] proof = SerializeProof(challenge, response);
|
||||||
|
if (!VerifyProof(proof, pointArgs, message)) // self-check before returning
|
||||||
|
throw new InvalidOperationException("poksho: proof failed self-verification");
|
||||||
|
return proof;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool VerifyProof(byte[] proofBytes, IReadOnlyDictionary<string, Ristretto255> pointArgs, byte[] message)
|
||||||
|
{
|
||||||
|
if (!TryParseProof(proofBytes, out Scalar25519 challenge, out Scalar25519[] response)) return false;
|
||||||
|
if (response.Length != _scalarVec.Count) return false;
|
||||||
|
|
||||||
|
Ristretto255[] allPoints;
|
||||||
|
try { allPoints = SortPoints(pointArgs); }
|
||||||
|
catch (ArgumentException) { throw; } // wrong number of point args is a usage error, not a failure
|
||||||
|
|
||||||
|
var sho = new ShoHmacSha256(Label);
|
||||||
|
sho.Absorb(ToBytes());
|
||||||
|
foreach (Ristretto255 p in allPoints) sho.Absorb(p.Encode());
|
||||||
|
sho.Ratchet();
|
||||||
|
|
||||||
|
Ristretto255[] commitment = Homomorphism(response, allPoints, challenge);
|
||||||
|
foreach (Ristretto255 r in commitment) sho.Absorb(r.Encode());
|
||||||
|
sho.AbsorbAndRatchet(message);
|
||||||
|
Scalar25519 expected = Scalar25519.FromBytesModOrderWide(sho.SqueezeAndRatchet(64));
|
||||||
|
return expected.Equals(challenge);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] SerializeProof(Scalar25519 challenge, Scalar25519[] response)
|
||||||
|
{
|
||||||
|
var v = new List<byte>(challenge.ToBytes());
|
||||||
|
foreach (Scalar25519 s in response) v.AddRange(s.ToBytes());
|
||||||
|
return v.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseProof(byte[] bytes, out Scalar25519 challenge, out Scalar25519[] response)
|
||||||
|
{
|
||||||
|
challenge = default; response = System.Array.Empty<Scalar25519>();
|
||||||
|
if (bytes.Length == 0 || bytes.Length % 32 != 0) return false;
|
||||||
|
int count = bytes.Length / 32;
|
||||||
|
if (count < 2 || count > 257) return false; // challenge + 1..256 responses
|
||||||
|
Scalar25519? ch = Scalar25519.FromCanonicalBytes(bytes.AsSpan(0, 32));
|
||||||
|
if (ch is null) return false;
|
||||||
|
challenge = ch.Value;
|
||||||
|
var resp = new Scalar25519[count - 1];
|
||||||
|
for (int i = 1; i < count; i++)
|
||||||
|
{
|
||||||
|
Scalar25519? s = Scalar25519.FromCanonicalBytes(bytes.AsSpan(i * 32, 32));
|
||||||
|
if (s is null) return false;
|
||||||
|
resp[i - 1] = s.Value;
|
||||||
|
}
|
||||||
|
response = resp;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.ZkGroup.Poksho;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Byte-exact port of libsignal poksho's <c>ShoHmacSha256</c> — a "stateful hash object" (sponge over
|
||||||
|
/// HMAC-SHA256) used throughout zkgroup for Fiat-Shamir transcripts and for deriving scalars/points.
|
||||||
|
/// Absorbing appends to an HMAC keyed by the chaining value; ratchet finalizes (message‖0x00) into a new
|
||||||
|
/// chaining value; squeeze is an HMAC-PRF expansion keyed by the chaining value over (BE64(i)‖0x01), and
|
||||||
|
/// re-ratchets via (BE64(outlen)‖0x02). Validated against poksho's own test vectors.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ShoHmacSha256
|
||||||
|
{
|
||||||
|
private const int HashLen = 32;
|
||||||
|
|
||||||
|
private byte[] _cv = new byte[HashLen]; // chaining value (starts all-zero, mode = RATCHETED)
|
||||||
|
private byte[] _key = new byte[HashLen]; // HMAC key in use while ABSORBING (the cv at absorb time)
|
||||||
|
private readonly List<byte> _buffer = new();
|
||||||
|
private bool _absorbing; // false = RATCHETED
|
||||||
|
|
||||||
|
public ShoHmacSha256(ReadOnlySpan<byte> label) => AbsorbAndRatchet(label);
|
||||||
|
|
||||||
|
private ShoHmacSha256() { }
|
||||||
|
|
||||||
|
/// <summary>Deep copy of the current state (poksho proves fork the transcript with a clone).</summary>
|
||||||
|
public ShoHmacSha256 Clone()
|
||||||
|
{
|
||||||
|
var c = new ShoHmacSha256
|
||||||
|
{
|
||||||
|
_cv = (byte[])_cv.Clone(),
|
||||||
|
_key = (byte[])_key.Clone(),
|
||||||
|
_absorbing = _absorbing,
|
||||||
|
};
|
||||||
|
c._buffer.AddRange(_buffer);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Absorb(ReadOnlySpan<byte> input)
|
||||||
|
{
|
||||||
|
if (!_absorbing)
|
||||||
|
{
|
||||||
|
_key = (byte[])_cv.Clone();
|
||||||
|
_buffer.Clear();
|
||||||
|
_absorbing = true;
|
||||||
|
}
|
||||||
|
_buffer.AddRange(input.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Ratchet()
|
||||||
|
{
|
||||||
|
if (!_absorbing) return;
|
||||||
|
_buffer.Add(0x00);
|
||||||
|
_cv = Hmac(_key, _buffer.ToArray());
|
||||||
|
_buffer.Clear();
|
||||||
|
_absorbing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AbsorbAndRatchet(ReadOnlySpan<byte> input) { Absorb(input); Ratchet(); }
|
||||||
|
|
||||||
|
public byte[] SqueezeAndRatchet(int outlen)
|
||||||
|
{
|
||||||
|
if (_absorbing) throw new InvalidOperationException("ShoHmacSha256: must ratchet before squeezing");
|
||||||
|
|
||||||
|
var output = new byte[outlen];
|
||||||
|
int pos = 0;
|
||||||
|
for (int i = 0; i * HashLen < outlen; i++)
|
||||||
|
{
|
||||||
|
var msg = new byte[9];
|
||||||
|
BinaryPrimitives.WriteUInt64BigEndian(msg, (ulong)i);
|
||||||
|
msg[8] = 0x01;
|
||||||
|
byte[] digest = Hmac(_cv, msg);
|
||||||
|
int num = Math.Min(HashLen, outlen - i * HashLen);
|
||||||
|
Array.Copy(digest, 0, output, pos, num);
|
||||||
|
pos += num;
|
||||||
|
}
|
||||||
|
|
||||||
|
var next = new byte[9];
|
||||||
|
BinaryPrimitives.WriteUInt64BigEndian(next, (ulong)outlen);
|
||||||
|
next[8] = 0x02;
|
||||||
|
_cv = Hmac(_cv, next);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>squeeze 64 bytes → scalar mod ℓ (poksho ShoExt.get_scalar).</summary>
|
||||||
|
public Curve.Scalar25519 GetScalar() => Curve.Scalar25519.FromBytesModOrderWide(SqueezeAndRatchet(64));
|
||||||
|
|
||||||
|
/// <summary>squeeze 64 bytes → a pseudorandom Ristretto point (poksho ShoExt.get_point).</summary>
|
||||||
|
public Curve.Ristretto255 GetPoint() => Curve.Ristretto255.FromUniformBytes(SqueezeAndRatchet(64));
|
||||||
|
|
||||||
|
private static byte[] Hmac(byte[] key, byte[] message)
|
||||||
|
{
|
||||||
|
using var h = new HMACSHA256(key);
|
||||||
|
return h.ComputeHash(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.ZkGroup.Poksho;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Byte-exact port of poksho's <c>ShoSha256</c> — the "innerpad" stateful hash object over SHA-256 (the
|
||||||
|
/// non-HMAC sibling of <see cref="ShoHmacSha256"/>). zkcredential's generic credential <c>SystemParams</c>
|
||||||
|
/// are derived through this. Absorbing prefixes a zero block + the chaining value; ratchet double-hashes;
|
||||||
|
/// squeeze is an SHA-256 PRF over (63 zeros‖0x01‖cv‖BE64(i)) re-ratcheting via (…‖0x02‖cv‖BE64(len)).
|
||||||
|
/// Validated against poksho's own test vectors.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ShoSha256
|
||||||
|
{
|
||||||
|
private const int BlockLen = 64;
|
||||||
|
private const int HashLen = 32;
|
||||||
|
|
||||||
|
private byte[] _cv = new byte[HashLen];
|
||||||
|
private IncrementalHash _hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||||
|
private bool _absorbing; // false = RATCHETED
|
||||||
|
|
||||||
|
public ShoSha256(ReadOnlySpan<byte> label) => AbsorbAndRatchet(label);
|
||||||
|
|
||||||
|
public void Absorb(ReadOnlySpan<byte> input)
|
||||||
|
{
|
||||||
|
if (!_absorbing)
|
||||||
|
{
|
||||||
|
_hasher.AppendData(new byte[BlockLen]); // 64 zero bytes
|
||||||
|
_hasher.AppendData(_cv);
|
||||||
|
_absorbing = true;
|
||||||
|
}
|
||||||
|
_hasher.AppendData(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Ratchet()
|
||||||
|
{
|
||||||
|
if (!_absorbing) return;
|
||||||
|
byte[] once = _hasher.GetHashAndReset();
|
||||||
|
_cv = SHA256.HashData(once); // double hash
|
||||||
|
_absorbing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AbsorbAndRatchet(ReadOnlySpan<byte> input) { Absorb(input); Ratchet(); }
|
||||||
|
|
||||||
|
public byte[] SqueezeAndRatchet(int outlen)
|
||||||
|
{
|
||||||
|
if (_absorbing) throw new InvalidOperationException("ShoSha256: must ratchet before squeezing");
|
||||||
|
var output = new byte[outlen];
|
||||||
|
for (int i = 0; i * HashLen < outlen; i++)
|
||||||
|
{
|
||||||
|
using var h = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||||
|
h.AppendData(new byte[BlockLen - 1]); // 63 zero bytes
|
||||||
|
h.AppendData(new byte[] { 0x01 });
|
||||||
|
h.AppendData(_cv);
|
||||||
|
Span<byte> ctr = stackalloc byte[8];
|
||||||
|
BinaryPrimitives.WriteUInt64BigEndian(ctr, (ulong)i);
|
||||||
|
h.AppendData(ctr);
|
||||||
|
byte[] digest = h.GetHashAndReset();
|
||||||
|
int num = Math.Min(HashLen, outlen - i * HashLen);
|
||||||
|
Array.Copy(digest, 0, output, i * HashLen, num);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var next = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||||
|
next.AppendData(new byte[BlockLen - 1]);
|
||||||
|
next.AppendData(new byte[] { 0x02 });
|
||||||
|
next.AppendData(_cv);
|
||||||
|
Span<byte> lenBe = stackalloc byte[8];
|
||||||
|
BinaryPrimitives.WriteUInt64BigEndian(lenBe, (ulong)outlen);
|
||||||
|
next.AppendData(lenBe);
|
||||||
|
_cv = next.GetHashAndReset();
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>squeeze 64 bytes → a pseudorandom Ristretto point (poksho ShoExt.get_point).</summary>
|
||||||
|
public Curve.Ristretto255 GetPoint() => Curve.Ristretto255.FromUniformBytes(SqueezeAndRatchet(64));
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Wingnal.Protocol.ZkGroup.Curve;
|
||||||
|
using Wingnal.Protocol.ZkGroup.Poksho;
|
||||||
|
using Wingnal.Protocol.ZkGroup.ZkCredential;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.ZkGroup;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// zkgroup's profile-key attribute (<c>ProfileKeyStruct</c>): M3 = single-Elligator hash of (profileKey‖uid),
|
||||||
|
/// M4 = single-Elligator encoding of the (bit-masked) 32-byte profile key. Verifiably-encrypted into a
|
||||||
|
/// <see cref="ProfileKeyCiphertext"/>; decryption recovers the profile key by inverting Elligator on M4 and
|
||||||
|
/// checking each candidate against M3.
|
||||||
|
/// </summary>
|
||||||
|
public readonly struct ProfileKeyStruct
|
||||||
|
{
|
||||||
|
public readonly Ristretto255 M3;
|
||||||
|
public readonly Ristretto255 M4;
|
||||||
|
public readonly byte[] ProfileKey; // 32 bytes (the original, un-masked)
|
||||||
|
|
||||||
|
private ProfileKeyStruct(Ristretto255 m3, Ristretto255 m4, byte[] profileKey)
|
||||||
|
{
|
||||||
|
M3 = m3; M4 = m4; ProfileKey = profileKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProfileKeyStruct New(byte[] profileKey32, byte[] uid16)
|
||||||
|
{
|
||||||
|
if (profileKey32.Length != 32 || uid16.Length != 16) throw new ArgumentException("bad sizes");
|
||||||
|
var encoded = (byte[])profileKey32.Clone();
|
||||||
|
encoded[0] &= 254;
|
||||||
|
encoded[31] &= 63;
|
||||||
|
Ristretto255 m3 = CalcM3(profileKey32, uid16);
|
||||||
|
Ristretto255 m4 = Ristretto255.FromSingleElligatorBytes(encoded);
|
||||||
|
return new ProfileKeyStruct(m3, m4, profileKey32);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Ristretto255 CalcM3(byte[] profileKey32, byte[] uid16)
|
||||||
|
{
|
||||||
|
var sho = new ShoHmacSha256(
|
||||||
|
Encoding.ASCII.GetBytes("Signal_ZKGroup_20200424_ProfileKeyAndUid_ProfileKey_CalcM3"));
|
||||||
|
var combined = new byte[48];
|
||||||
|
Array.Copy(profileKey32, 0, combined, 0, 32);
|
||||||
|
Array.Copy(uid16, 0, combined, 32, 16);
|
||||||
|
sho.AbsorbAndRatchet(combined);
|
||||||
|
return Ristretto255.FromSingleElligatorBytes(sho.SqueezeAndRatchet(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Ristretto255[] AsPoints() => new[] { M3, M4 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The profile-key verifiable-encryption domain (analogous to <see cref="UidEncryption"/>).</summary>
|
||||||
|
public static class ProfileKeyEncryption
|
||||||
|
{
|
||||||
|
public const string DomainId = "Signal_ZKGroup_20231011_ProfileKeyEncryption";
|
||||||
|
|
||||||
|
public static readonly (Ristretto255 Gb1, Ristretto255 Gb2) SystemParams = GenerateSystemParams();
|
||||||
|
|
||||||
|
private static (Ristretto255, Ristretto255) GenerateSystemParams()
|
||||||
|
{
|
||||||
|
var sho = new ShoHmacSha256(Encoding.ASCII.GetBytes(
|
||||||
|
"Signal_ZKGroup_20200424_Constant_ProfileKeyEncryption_SystemParams_Generate"));
|
||||||
|
sho.AbsorbAndRatchet(Array.Empty<byte>());
|
||||||
|
Ristretto255 gb1 = Ristretto255.FromUniformBytes(sho.SqueezeAndRatchet(64));
|
||||||
|
Ristretto255 gb2 = Ristretto255.FromUniformBytes(sho.SqueezeAndRatchet(64));
|
||||||
|
return (gb1, gb2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static readonly byte[] SystemHardcoded =
|
||||||
|
{
|
||||||
|
0xf6, 0xba, 0xa3, 0x17, 0xce, 0x18, 0x39, 0xc9, 0x3d, 0x61, 0x7e, 0x0c, 0xd8, 0x37, 0xd1,
|
||||||
|
0x9d, 0xa9, 0xc8, 0xa4, 0xc5, 0x20, 0xbf, 0x7c, 0x51, 0xb1, 0xe6, 0xc2, 0xcb, 0x2a, 0x04,
|
||||||
|
0x9c, 0x61, 0x2e, 0x01, 0x75, 0x89, 0x4c, 0x87, 0x30, 0xb2, 0x03, 0xab, 0x3b, 0xd9, 0x8e,
|
||||||
|
0xcb, 0x2d, 0x81, 0xab, 0xac, 0xb6, 0x5f, 0x8a, 0x61, 0x24, 0xf4, 0x97, 0x71, 0xd1, 0x4a,
|
||||||
|
0x98, 0x52, 0x12, 0x0c,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static AttributeKeyPair DeriveKeyPair(ShoHmacSha256 sho) =>
|
||||||
|
AttributeKeyPair.DeriveFrom(sho, SystemParams.Gb1, SystemParams.Gb2);
|
||||||
|
|
||||||
|
public static AttributeCiphertext Encrypt(AttributeKeyPair keyPair, ProfileKeyStruct pk) =>
|
||||||
|
keyPair.Encrypt(pk.M3, pk.M4);
|
||||||
|
|
||||||
|
/// <summary>Decrypts a profile-key ciphertext back to the 32-byte profile key, given the member's uid.
|
||||||
|
/// Port of zkgroup <c>ProfileKeyEncryptionDomain::decrypt</c>.</summary>
|
||||||
|
public static byte[] Decrypt(AttributeKeyPair keyPair, AttributeCiphertext ct, byte[] uid16)
|
||||||
|
{
|
||||||
|
Ristretto255 m4 = keyPair.DecryptToSecondPoint(ct);
|
||||||
|
(byte mask, Fe[] fes) = m4.ElligatorInverse();
|
||||||
|
Ristretto255 targetM3 = ct.EA1.Multiply(keyPair.A1.Invert());
|
||||||
|
|
||||||
|
byte[]? result = null;
|
||||||
|
int found = 0;
|
||||||
|
for (int i = 0; i < 8; i++)
|
||||||
|
{
|
||||||
|
if (((mask >> i) & 1) == 0) continue;
|
||||||
|
byte[] candidate = fes[i].Encode(); // 32-byte field-element encoding
|
||||||
|
for (int j = 0; j < 8; j++)
|
||||||
|
{
|
||||||
|
var pk = (byte[])candidate.Clone();
|
||||||
|
if (((j >> 2) & 1) == 1) pk[0] |= 0x01;
|
||||||
|
if (((j >> 1) & 1) == 1) pk[31] |= 0x80;
|
||||||
|
if ((j & 1) == 1) pk[31] |= 0x40;
|
||||||
|
Ristretto255 m3 = ProfileKeyStruct.CalcM3(pk, uid16);
|
||||||
|
if (m3.ConstantTimeEquals(targetM3)) { result = pk; found++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found != 1 || result is null) throw new ZkGroupVerificationException("profile key decrypt failed");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using Wingnal.Protocol.ZkGroup.Curve;
|
||||||
|
using Wingnal.Protocol.ZkGroup.ZkCredential;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.ZkGroup;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Signal's published <c>ServerPublicParams</c> — the server's public credential/signature keys that every
|
||||||
|
/// client embeds (it is NOT derivable; like the pinned CA, it is fixed production data). We only need two of
|
||||||
|
/// its fields: the <see cref="GenericCredentialPublicKey"/> (to receive + present the AuthCredentialWithPni)
|
||||||
|
/// and the <see cref="SigPublicKey"/> (to verify the server's signature on a GroupChange).
|
||||||
|
///
|
||||||
|
/// Layout is bincode in struct-field order (total <c>SERVER_PUBLIC_PARAMS_LEN</c> = 673):
|
||||||
|
/// reserved(1) ‖ 6×oldCredentialPublicKey(64 = C_W‖I) with sig_public_key(32) as the 3rd field ‖
|
||||||
|
/// generic_credential_public_key(224 = C_W‖I[6]) ‖ endorsement_public_key(32). So sig = [129,161),
|
||||||
|
/// generic = [417,641).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ServerPublicParams
|
||||||
|
{
|
||||||
|
public const int SerializedLen = 673;
|
||||||
|
private const int SigPublicKeyOffset = 129;
|
||||||
|
private const int GenericCredentialOffset = 417;
|
||||||
|
private const int GenericCredentialLen = 224;
|
||||||
|
|
||||||
|
public CredentialPublicKey GenericCredentialPublicKey { get; }
|
||||||
|
public Ristretto255 SigPublicKey { get; }
|
||||||
|
|
||||||
|
private ServerPublicParams(CredentialPublicKey generic, Ristretto255 sig)
|
||||||
|
{
|
||||||
|
GenericCredentialPublicKey = generic;
|
||||||
|
SigPublicKey = sig;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ServerPublicParams Parse(ReadOnlySpan<byte> bytes)
|
||||||
|
{
|
||||||
|
if (bytes.Length != SerializedLen)
|
||||||
|
throw new ArgumentException($"ServerPublicParams must be {SerializedLen} bytes, got {bytes.Length}");
|
||||||
|
if (bytes[0] != 0) throw new ArgumentException("ServerPublicParams: bad reserved byte");
|
||||||
|
Ristretto255 sig = Ristretto255.Decode(bytes.Slice(SigPublicKeyOffset, 32))
|
||||||
|
?? throw new ArgumentException("ServerPublicParams: bad sig public key");
|
||||||
|
CredentialPublicKey generic = CredentialPublicKey.Deserialize(
|
||||||
|
bytes.Slice(GenericCredentialOffset, GenericCredentialLen));
|
||||||
|
return new ServerPublicParams(generic, sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The base64 of Signal's PRODUCTION ServerPublicParams (from Signal-Android
|
||||||
|
/// <c>BuildConfig.ZKGROUP_SERVER_PUBLIC_PARAMS</c>; the staging value differs).</summary>
|
||||||
|
public const string ProductionBase64 =
|
||||||
|
"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8" +
|
||||||
|
"Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt" +
|
||||||
|
"8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGv" +
|
||||||
|
"CoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQF" +
|
||||||
|
"j5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCn" +
|
||||||
|
"ImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw" +
|
||||||
|
"3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P+NameAZYOD12qRkxosQQP5uux6B2nRyZ7sAV54DgFyLiRcq1FvwKw" +
|
||||||
|
"2EPQdk4HDoePrO/RNUbyNddnM/mMgj4FW65xCoT1LmjrIjsv/Ggdlx46ueczhMgtBunx1/w8k8V+l8LVZ8gAT6w" +
|
||||||
|
"kU5J+DPQalQguMg12Jzug3q4TbdHiGCmD9EunCwOmsLuLJkz6EcSYXtrlDEnAM+hicw7iergYLLlMXpfTdGxJCW" +
|
||||||
|
"JmP4zqUFeTTmsmhsjGBt7NiEB/9pFFEB3pSbf4iiUukw63Eo8Aqnf4iwob6X1QviCWuc8t0LUlT9vALgh/f2DPV" +
|
||||||
|
"OOmR0RW6bgRvc7DSF20V/omg+YBw==";
|
||||||
|
|
||||||
|
private static readonly Lazy<ServerPublicParams> _production =
|
||||||
|
new(() => Parse(Convert.FromBase64String(ProductionBase64)));
|
||||||
|
|
||||||
|
/// <summary>The parsed production server public params.</summary>
|
||||||
|
public static ServerPublicParams Production => _production.Value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Wingnal.Protocol.ZkGroup.Curve;
|
||||||
|
using Wingnal.Protocol.ZkGroup.Poksho;
|
||||||
|
using Wingnal.Protocol.ZkGroup.ZkCredential;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.ZkGroup;
|
||||||
|
|
||||||
|
/// <summary>A Signal service id (ACI or PNI) as the 16-byte raw UUID plus its kind, for zkgroup encoding.</summary>
|
||||||
|
public readonly struct ServiceId
|
||||||
|
{
|
||||||
|
public readonly byte[] RawUuid; // 16 bytes
|
||||||
|
public readonly bool IsPni;
|
||||||
|
|
||||||
|
public ServiceId(byte[] rawUuid16, bool isPni)
|
||||||
|
{
|
||||||
|
if (rawUuid16.Length != 16) throw new ArgumentException("uuid must be 16 bytes");
|
||||||
|
RawUuid = rawUuid16; IsPni = isPni;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ServiceId Aci(byte[] uuid16) => new(uuid16, isPni: false);
|
||||||
|
public static ServiceId Pni(byte[] uuid16) => new(uuid16, isPni: true);
|
||||||
|
|
||||||
|
/// <summary>libsignal-core <c>service_id_binary</c>: ACI = 16 raw bytes; PNI = 0x01‖16.</summary>
|
||||||
|
public byte[] ServiceIdBinary()
|
||||||
|
{
|
||||||
|
if (!IsPni) return (byte[])RawUuid.Clone();
|
||||||
|
var b = new byte[17];
|
||||||
|
b[0] = 0x01;
|
||||||
|
Array.Copy(RawUuid, 0, b, 1, 16);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// zkgroup's UID attribute (<c>UidStruct</c>): M1 = hash-to-group of the service-id binary; M2 = the Lizard
|
||||||
|
/// encoding of the raw 16-byte UUID. The pair is verifiably-encrypted into a <see cref="UuidCiphertext"/>.
|
||||||
|
/// </summary>
|
||||||
|
public readonly struct UidStruct
|
||||||
|
{
|
||||||
|
public readonly Ristretto255 M1;
|
||||||
|
public readonly Ristretto255 M2;
|
||||||
|
public readonly byte[] RawUuid;
|
||||||
|
|
||||||
|
private UidStruct(Ristretto255 m1, Ristretto255 m2, byte[] rawUuid) { M1 = m1; M2 = m2; RawUuid = rawUuid; }
|
||||||
|
|
||||||
|
public static UidStruct FromServiceId(ServiceId sid)
|
||||||
|
{
|
||||||
|
Ristretto255 m1 = CalcM1(sid);
|
||||||
|
Ristretto255 m2 = Lizard.Encode(sid.RawUuid);
|
||||||
|
return new UidStruct(m1, m2, sid.RawUuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Ristretto255 CalcM1(ServiceId sid)
|
||||||
|
{
|
||||||
|
var sho = new ShoHmacSha256(Encoding.ASCII.GetBytes("Signal_ZKGroup_20200424_UID_CalcM1"));
|
||||||
|
sho.AbsorbAndRatchet(sid.ServiceIdBinary());
|
||||||
|
return Ristretto255.FromUniformBytes(sho.SqueezeAndRatchet(64));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Ristretto255[] AsPoints() => new[] { M1, M2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The UID verifiable-encryption domain: a fixed pair of generator points (G_a1, G_a2) derived
|
||||||
|
/// deterministically via the SHO. <see cref="SystemHardcoded"/> is libsignal's pinned serialization of
|
||||||
|
/// these two points and gates the derivation byte-for-byte.
|
||||||
|
/// </summary>
|
||||||
|
public static class UidEncryption
|
||||||
|
{
|
||||||
|
public const string DomainId = "Signal_ZKGroup_20230419_UidEncryption";
|
||||||
|
|
||||||
|
public static readonly (Ristretto255 Ga1, Ristretto255 Ga2) SystemParams = GenerateSystemParams();
|
||||||
|
|
||||||
|
private static (Ristretto255, Ristretto255) GenerateSystemParams()
|
||||||
|
{
|
||||||
|
var sho = new ShoHmacSha256(
|
||||||
|
Encoding.ASCII.GetBytes("Signal_ZKGroup_20200424_Constant_UidEncryption_SystemParams_Generate"));
|
||||||
|
sho.AbsorbAndRatchet(Array.Empty<byte>());
|
||||||
|
Ristretto255 ga1 = Ristretto255.FromUniformBytes(sho.SqueezeAndRatchet(64));
|
||||||
|
Ristretto255 ga2 = Ristretto255.FromUniformBytes(sho.SqueezeAndRatchet(64));
|
||||||
|
return (ga1, ga2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>zkgroup's pinned 64-byte serialization of (G_a1, G_a2) — the Phase D2 test gate.</summary>
|
||||||
|
public static readonly byte[] SystemHardcoded =
|
||||||
|
{
|
||||||
|
0xa6, 0x32, 0x4c, 0x36, 0x8d, 0xf7, 0x34, 0x69, 0x11, 0x47, 0x98, 0x13, 0x48, 0xb6, 0xe7,
|
||||||
|
0xeb, 0x42, 0xc3, 0x30, 0x7e, 0x71, 0x1b, 0x6c, 0x7e, 0xcc, 0xd3, 0x03, 0x2d, 0x45, 0x69,
|
||||||
|
0x3f, 0x5a, 0x04, 0x80, 0x13, 0x52, 0x5b, 0x76, 0x12, 0x4b, 0xf2, 0x64, 0x0c, 0x5e, 0x93,
|
||||||
|
0x69, 0xc7, 0x6e, 0xfb, 0xe8, 0x0a, 0xba, 0x2a, 0x24, 0xaa, 0x5d, 0x8e, 0x18, 0xa9, 0x8e,
|
||||||
|
0xba, 0x14, 0xf8, 0x37,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static AttributeKeyPair DeriveKeyPair(ShoHmacSha256 sho) =>
|
||||||
|
AttributeKeyPair.DeriveFrom(sho, SystemParams.Ga1, SystemParams.Ga2);
|
||||||
|
|
||||||
|
public static AttributeCiphertext Encrypt(AttributeKeyPair keyPair, UidStruct uid) =>
|
||||||
|
keyPair.Encrypt(uid.M1, uid.M2);
|
||||||
|
|
||||||
|
/// <summary>Decrypts a UID ciphertext back to a service id, trying both ACI and PNI interpretations and
|
||||||
|
/// confirming via M1 (zkgroup <c>UidEncryptionDomain::decrypt</c>).</summary>
|
||||||
|
public static ServiceId Decrypt(AttributeKeyPair keyPair, AttributeCiphertext ct)
|
||||||
|
{
|
||||||
|
Ristretto255 m2 = keyPair.DecryptToSecondPoint(ct);
|
||||||
|
byte[]? uuid = Lizard.Decode(m2) ?? throw new ZkGroupVerificationException("lizard decode failed");
|
||||||
|
|
||||||
|
var aci = ServiceId.Aci(uuid);
|
||||||
|
var pni = ServiceId.Pni(uuid);
|
||||||
|
Ristretto255 decryptedM1 = ct.EA1.Multiply(keyPair.A1.Invert());
|
||||||
|
if (decryptedM1.ConstantTimeEquals(UidStruct.CalcM1(aci))) return aci;
|
||||||
|
if (decryptedM1.ConstantTimeEquals(UidStruct.CalcM1(pni))) return pni;
|
||||||
|
throw new ZkGroupVerificationException("uid ciphertext did not match ACI or PNI");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using Wingnal.Protocol.ZkGroup.Curve;
|
||||||
|
using Wingnal.Protocol.ZkGroup.Poksho;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.ZkGroup.ZkCredential;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Port of libsignal's <c>zkcredential::attributes</c> verifiable-encryption layer (Chase-Perrin-Zaverucha
|
||||||
|
/// §4.1). An attribute is a pair of Ristretto points (M1, M2). A <see cref="AttributeKeyPair"/> holds two
|
||||||
|
/// scalars (a1, a2); encryption is <c>E_A1 = a1·M1; E_A2 = a2·E_A1 + M2</c>. The verifying server can match
|
||||||
|
/// the ciphertext without learning the plaintext, which is how a group hides its members' ACIs/profile keys.
|
||||||
|
/// </summary>
|
||||||
|
public readonly struct AttributeCiphertext
|
||||||
|
{
|
||||||
|
public readonly Ristretto255 EA1;
|
||||||
|
public readonly Ristretto255 EA2;
|
||||||
|
|
||||||
|
public AttributeCiphertext(Ristretto255 ea1, Ristretto255 ea2) { EA1 = ea1; EA2 = ea2; }
|
||||||
|
|
||||||
|
/// <summary>The ciphertext as its own attribute (for chaining), per zkcredential.</summary>
|
||||||
|
public Ristretto255[] AsPoints() => new[] { EA1, EA2 };
|
||||||
|
|
||||||
|
/// <summary>64-byte serialization: E_A1‖E_A2 (each a 32-byte compressed Ristretto point).</summary>
|
||||||
|
public byte[] Serialize()
|
||||||
|
{
|
||||||
|
var b = new byte[64];
|
||||||
|
Array.Copy(EA1.Encode(), 0, b, 0, 32);
|
||||||
|
Array.Copy(EA2.Encode(), 0, b, 32, 32);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AttributeCiphertext Deserialize(ReadOnlySpan<byte> bytes64)
|
||||||
|
{
|
||||||
|
if (bytes64.Length != 64) throw new ArgumentException("ciphertext must be 64 bytes");
|
||||||
|
Ristretto255 ea1 = Ristretto255.Decode(bytes64[..32]) ?? throw new ArgumentException("bad E_A1");
|
||||||
|
Ristretto255 ea2 = Ristretto255.Decode(bytes64[32..64]) ?? throw new ArgumentException("bad E_A2");
|
||||||
|
return new AttributeCiphertext(ea1, ea2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A key for encrypting one kind of attribute (a domain). The private key is (a1, a2); the public key is
|
||||||
|
/// A = a1·G_a1 + a2·G_a2. Different domains use different generator points so ciphertexts can't be confused.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AttributeKeyPair
|
||||||
|
{
|
||||||
|
public Scalar25519 A1 { get; }
|
||||||
|
public Scalar25519 A2 { get; }
|
||||||
|
public Ristretto255 PublicKey { get; } // A
|
||||||
|
|
||||||
|
private AttributeKeyPair(Scalar25519 a1, Scalar25519 a2, Ristretto255 publicKey)
|
||||||
|
{
|
||||||
|
A1 = a1; A2 = a2; PublicKey = publicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Derives a deterministic key pair from the SHO state and the domain's generator points.</summary>
|
||||||
|
public static AttributeKeyPair DeriveFrom(ShoHmacSha256 sho, Ristretto255 ga1, Ristretto255 ga2)
|
||||||
|
{
|
||||||
|
Scalar25519 a1 = Scalar25519.FromBytesModOrderWide(sho.SqueezeAndRatchet(64));
|
||||||
|
Scalar25519 a2 = Scalar25519.FromBytesModOrderWide(sho.SqueezeAndRatchet(64));
|
||||||
|
Ristretto255 a = Ristretto255.Add(ga1.Multiply(a1), ga2.Multiply(a2));
|
||||||
|
return new AttributeKeyPair(a1, a2, a);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AttributeKeyPair FromScalars(Scalar25519 a1, Scalar25519 a2, Ristretto255 ga1, Ristretto255 ga2)
|
||||||
|
=> new(a1, a2, Ristretto255.Add(ga1.Multiply(a1), ga2.Multiply(a2)));
|
||||||
|
|
||||||
|
/// <summary>Encrypts an attribute (M1, M2): E_A1 = a1·M1; E_A2 = a2·E_A1 + M2.</summary>
|
||||||
|
public AttributeCiphertext Encrypt(Ristretto255 m1, Ristretto255 m2)
|
||||||
|
{
|
||||||
|
Ristretto255 ea1 = m1.Multiply(A1);
|
||||||
|
Ristretto255 ea2 = Ristretto255.Add(ea1.Multiply(A2), m2);
|
||||||
|
return new AttributeCiphertext(ea1, ea2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Recovers M2 = E_A2 − a2·E_A1. Throws if E_A1 is the basepoint (a1 not actually encrypting).
|
||||||
|
/// The caller MUST verify the decoded value re-encrypts to E_A1 (decode is otherwise garbage-in/out).</summary>
|
||||||
|
public Ristretto255 DecryptToSecondPoint(AttributeCiphertext ct)
|
||||||
|
{
|
||||||
|
if (ct.EA1.ConstantTimeEquals(Ristretto255.BasePoint))
|
||||||
|
throw new ZkGroupVerificationException("E_A1 is the basepoint");
|
||||||
|
Ristretto255 a2EA1 = ct.EA1.Multiply(A2);
|
||||||
|
return Ristretto255.Add(ct.EA2, Ristretto255.Negate(a2EA1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A zkgroup verification failure (a wrong key, forged ciphertext, or invalid proof).</summary>
|
||||||
|
public sealed class ZkGroupVerificationException : Exception
|
||||||
|
{
|
||||||
|
public ZkGroupVerificationException(string message) : base(message) { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
using Wingnal.Protocol.ZkGroup.Curve;
|
||||||
|
using Wingnal.Protocol.ZkGroup.Poksho;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.ZkGroup.ZkCredential;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issuance + presentation proofs for the zkcredential MAC system (Chase-Perrin-Zaverucha §3.2 / §4.1),
|
||||||
|
/// expressed as poksho <see cref="Statement"/>s (reusing the byte-exact Schnorr engine). The client uses
|
||||||
|
/// <see cref="IssuanceProofBuilder"/> to verify a server-issued credential and
|
||||||
|
/// <see cref="PresentationProofBuilder"/> to build the anonymous presentation it sends to the verifying
|
||||||
|
/// (storage) server. <see cref="PresentationProofVerifier"/> is included for offline round-trip testing.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EncryptionKeyContext
|
||||||
|
{
|
||||||
|
public string Id = "";
|
||||||
|
public Ristretto255 Ga1 = Ristretto255.Identity;
|
||||||
|
public Ristretto255 Ga2 = Ristretto255.Identity;
|
||||||
|
public Scalar25519 A1, A2; // present for the prover (KeyPair)
|
||||||
|
public Ristretto255? PublicKeyA; // the encryption public key A (present when key is "verified")
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class IssuanceProof
|
||||||
|
{
|
||||||
|
public Credential Credential = null!;
|
||||||
|
public byte[] PokshoProof = System.Array.Empty<byte>();
|
||||||
|
|
||||||
|
/// <summary>bincode: Credential(96) ‖ Vec<u8>(u64le len ‖ proof bytes).</summary>
|
||||||
|
public byte[] Serialize()
|
||||||
|
{
|
||||||
|
var b = new List<byte>(Credential.Serialize());
|
||||||
|
Span<byte> len = stackalloc byte[8];
|
||||||
|
BinaryPrimitives.WriteUInt64LittleEndian(len, (ulong)PokshoProof.Length);
|
||||||
|
b.AddRange(len.ToArray());
|
||||||
|
b.AddRange(PokshoProof);
|
||||||
|
return b.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IssuanceProof Deserialize(ReadOnlySpan<byte> bytes)
|
||||||
|
{
|
||||||
|
if (bytes.Length < 96 + 8) throw new ArgumentException("IssuanceProof too short");
|
||||||
|
var credential = Credential.Deserialize(bytes[..96]);
|
||||||
|
ulong len = BinaryPrimitives.ReadUInt64LittleEndian(bytes.Slice(96, 8));
|
||||||
|
if (96 + 8 + (int)len != bytes.Length) throw new ArgumentException("IssuanceProof length mismatch");
|
||||||
|
return new IssuanceProof { Credential = credential, PokshoProof = bytes.Slice(104, (int)len).ToArray() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class IssuanceProofBuilder
|
||||||
|
{
|
||||||
|
private readonly ShoHmacSha256 _publicAttrs;
|
||||||
|
private readonly byte[] _message;
|
||||||
|
private readonly List<Ristretto255> _attrPoints = new() { Ristretto255.Identity }; // [0] reserved for public
|
||||||
|
|
||||||
|
public IssuanceProofBuilder(byte[] label, byte[]? message = null)
|
||||||
|
{
|
||||||
|
_publicAttrs = new ShoHmacSha256(label);
|
||||||
|
_message = message ?? System.Array.Empty<byte>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IssuanceProofBuilder AddPublicAttributeU64(ulong value)
|
||||||
|
{
|
||||||
|
Span<byte> be = stackalloc byte[8];
|
||||||
|
BinaryPrimitives.WriteUInt64BigEndian(be, value);
|
||||||
|
_publicAttrs.AbsorbAndRatchet(be); // ratchet() after is a no-op (already ratcheted)
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IssuanceProofBuilder AddAttribute(Ristretto255[] points)
|
||||||
|
{
|
||||||
|
_attrPoints.AddRange(points);
|
||||||
|
if (_attrPoints.Count > CredentialSystem.NumSupportedAttrs)
|
||||||
|
throw new ArgumentException("too many attribute points");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FinalizePublicAttrs() => _attrPoints[0] = _publicAttrs.GetPoint();
|
||||||
|
|
||||||
|
private Statement BuildStatement()
|
||||||
|
{
|
||||||
|
var st = new Statement();
|
||||||
|
st.Add("C_W", ("w", "G_w"), ("wprime", "G_wprime"));
|
||||||
|
|
||||||
|
var gvi = new (string, string)[]
|
||||||
|
{
|
||||||
|
("x0", "G_x0"), ("x1", "G_x1"),
|
||||||
|
("y0", "G_y0"), ("y1", "G_y1"), ("y2", "G_y2"), ("y3", "G_y3"),
|
||||||
|
("y4", "G_y4"), ("y5", "G_y5"), ("y6", "G_y6"),
|
||||||
|
};
|
||||||
|
st.Add("G_V-I", gvi[..(2 + _attrPoints.Count)]);
|
||||||
|
|
||||||
|
var vt = new (string, string)[]
|
||||||
|
{
|
||||||
|
("w", "G_w"), ("x0", "U"), ("x1", "tU"),
|
||||||
|
("y0", "M0"), ("y1", "M1"), ("y2", "M2"), ("y3", "M3"),
|
||||||
|
("y4", "M4"), ("y5", "M5"), ("y6", "M6"),
|
||||||
|
};
|
||||||
|
st.Add("V", vt[..(3 + _attrPoints.Count)]);
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, Ristretto255> PointArgs(CredentialPublicKey key, Credential credential)
|
||||||
|
{
|
||||||
|
var s = CredentialSystem.SystemParams.Hardcoded;
|
||||||
|
var p = new Dictionary<string, Ristretto255>
|
||||||
|
{
|
||||||
|
["C_W"] = key.CW,
|
||||||
|
["G_w"] = s.GW,
|
||||||
|
["G_wprime"] = s.GWprime,
|
||||||
|
["G_V-I"] = Ristretto255.Add(s.GV, Ristretto255.Negate(key.IFor(_attrPoints.Count))),
|
||||||
|
["G_x0"] = s.GX0,
|
||||||
|
["G_x1"] = s.GX1,
|
||||||
|
["V"] = credential.V,
|
||||||
|
["U"] = credential.U,
|
||||||
|
["tU"] = credential.U.Multiply(credential.T),
|
||||||
|
};
|
||||||
|
string[] gy = { "G_y0", "G_y1", "G_y2", "G_y3", "G_y4", "G_y5", "G_y6" };
|
||||||
|
for (int i = 0; i < _attrPoints.Count; i++) p[gy[i]] = s.GY[i];
|
||||||
|
string[] mn = { "M0", "M1", "M2", "M3", "M4", "M5", "M6" };
|
||||||
|
for (int i = 0; i < _attrPoints.Count; i++) p[mn[i]] = _attrPoints[i];
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies a server-issued credential, returning it on success.</summary>
|
||||||
|
public Credential Verify(CredentialPublicKey publicKey, IssuanceProof proof)
|
||||||
|
{
|
||||||
|
FinalizePublicAttrs();
|
||||||
|
Dictionary<string, Ristretto255> points = PointArgs(publicKey, proof.Credential);
|
||||||
|
if (!BuildStatement().VerifyProof(proof.PokshoProof, points, _message))
|
||||||
|
throw new ZkGroupVerificationException("issuance proof did not verify");
|
||||||
|
return proof.Credential;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Issues a credential (server side; used for offline tests).</summary>
|
||||||
|
public IssuanceProof Issue(CredentialKeyPair keyPair, byte[] randomness)
|
||||||
|
{
|
||||||
|
FinalizePublicAttrs();
|
||||||
|
var sho = new ShoHmacSha256(Encoding.ASCII.GetBytes("Signal_ZKCredential_Issuance_20230410"));
|
||||||
|
sho.AbsorbAndRatchet(randomness);
|
||||||
|
Credential credential = keyPair.Private.CredentialCore(_attrPoints.ToArray(), sho);
|
||||||
|
|
||||||
|
var scalars = new Dictionary<string, Scalar25519>
|
||||||
|
{
|
||||||
|
["w"] = keyPair.Private.W,
|
||||||
|
["wprime"] = keyPair.Private.Wprime,
|
||||||
|
["x0"] = keyPair.Private.X0,
|
||||||
|
["x1"] = keyPair.Private.X1,
|
||||||
|
};
|
||||||
|
string[] yn = { "y0", "y1", "y2", "y3", "y4", "y5", "y6" };
|
||||||
|
for (int i = 0; i < _attrPoints.Count; i++) scalars[yn[i]] = keyPair.Private.Y[i];
|
||||||
|
|
||||||
|
Dictionary<string, Ristretto255> points = PointArgs(keyPair.Public, credential);
|
||||||
|
byte[] poksho = BuildStatement().Prove(scalars, points, _message, sho.SqueezeAndRatchet(32));
|
||||||
|
return new IssuanceProof { Credential = credential, PokshoProof = poksho };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Wingnal.Protocol.ZkGroup.Curve;
|
||||||
|
using Wingnal.Protocol.ZkGroup.Poksho;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.ZkGroup.ZkCredential;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Port of libsignal's <c>zkcredential::credentials</c> — the algebraic-MAC credential system
|
||||||
|
/// (Chase-Perrin-Zaverucha §3.1) that AuthCredential/ProfileKeyCredential are built on. Supports up to
|
||||||
|
/// <see cref="NumSupportedAttrs"/> attribute points. The shared <see cref="SystemParams"/> generators are
|
||||||
|
/// derived via <see cref="ShoSha256"/> and gated against libsignal's hardcoded serialization.
|
||||||
|
/// </summary>
|
||||||
|
public static class CredentialSystem
|
||||||
|
{
|
||||||
|
public const int NumSupportedAttrs = 7; // 1 aggregate public + 3 two-point private attributes
|
||||||
|
|
||||||
|
public sealed class SystemParams
|
||||||
|
{
|
||||||
|
public Ristretto255 GW, GWprime, GX0, GX1, GV, GZ;
|
||||||
|
public Ristretto255[] GY = new Ristretto255[NumSupportedAttrs];
|
||||||
|
|
||||||
|
public static readonly SystemParams Hardcoded = Generate();
|
||||||
|
|
||||||
|
public static SystemParams Generate()
|
||||||
|
{
|
||||||
|
var sho = new ShoSha256(Encoding.ASCII.GetBytes(
|
||||||
|
"Signal_ZKCredential_ConstantSystemParams_generate_20230410"));
|
||||||
|
var p = new SystemParams
|
||||||
|
{
|
||||||
|
GW = sho.GetPoint(),
|
||||||
|
GWprime = sho.GetPoint(),
|
||||||
|
GX0 = sho.GetPoint(),
|
||||||
|
GX1 = sho.GetPoint(),
|
||||||
|
GV = sho.GetPoint(),
|
||||||
|
GZ = sho.GetPoint(),
|
||||||
|
};
|
||||||
|
for (int i = 0; i < NumSupportedAttrs; i++) p.GY[i] = sho.GetPoint();
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>bincode serialization: G_w‖G_wprime‖G_x0‖G_x1‖G_V‖G_z‖G_y[0..7] (13 × 32 = 416 bytes).</summary>
|
||||||
|
public byte[] Serialize()
|
||||||
|
{
|
||||||
|
var b = new byte[13 * 32];
|
||||||
|
int o = 0;
|
||||||
|
foreach (Ristretto255 p in new[] { GW, GWprime, GX0, GX1, GV, GZ })
|
||||||
|
{ Array.Copy(p.Encode(), 0, b, o, 32); o += 32; }
|
||||||
|
foreach (Ristretto255 p in GY) { Array.Copy(p.Encode(), 0, b, o, 32); o += 32; }
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A credential: the MAC (t, U, V) issued by the server over a set of attributes.</summary>
|
||||||
|
public sealed class Credential
|
||||||
|
{
|
||||||
|
public Scalar25519 T;
|
||||||
|
public Ristretto255 U;
|
||||||
|
public Ristretto255 V;
|
||||||
|
|
||||||
|
public Credential(Scalar25519 t, Ristretto255 u, Ristretto255 v) { T = t; U = u; V = v; }
|
||||||
|
|
||||||
|
/// <summary>bincode: t(32)‖U(32)‖V(32) = 96 bytes.</summary>
|
||||||
|
public byte[] Serialize()
|
||||||
|
{
|
||||||
|
var b = new byte[96];
|
||||||
|
Array.Copy(T.ToBytes(), 0, b, 0, 32);
|
||||||
|
Array.Copy(U.Encode(), 0, b, 32, 32);
|
||||||
|
Array.Copy(V.Encode(), 0, b, 64, 32);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Credential Deserialize(ReadOnlySpan<byte> b)
|
||||||
|
{
|
||||||
|
if (b.Length != 96) throw new ArgumentException("credential must be 96 bytes");
|
||||||
|
Scalar25519 t = Scalar25519.FromCanonicalBytes(b[..32]) ?? throw new ArgumentException("bad t");
|
||||||
|
Ristretto255 u = Ristretto255.Decode(b[32..64]) ?? throw new ArgumentException("bad U");
|
||||||
|
Ristretto255 v = Ristretto255.Decode(b[64..96]) ?? throw new ArgumentException("bad V");
|
||||||
|
return new Credential(t, u, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The server's private credential key (only needed locally for tests / a verifying server).</summary>
|
||||||
|
public sealed class CredentialPrivateKey
|
||||||
|
{
|
||||||
|
public Scalar25519 W, Wprime, X0, X1;
|
||||||
|
public Ristretto255 BigW;
|
||||||
|
public Scalar25519[] Y = new Scalar25519[CredentialSystem.NumSupportedAttrs];
|
||||||
|
|
||||||
|
public static CredentialPrivateKey Generate(byte[] randomness)
|
||||||
|
{
|
||||||
|
var sho = new ShoHmacSha256(Encoding.ASCII.GetBytes(
|
||||||
|
"Signal_ZKCredential_CredentialPrivateKey_generate_20230410"));
|
||||||
|
sho.AbsorbAndRatchet(randomness);
|
||||||
|
var system = CredentialSystem.SystemParams.Hardcoded;
|
||||||
|
var k = new CredentialPrivateKey();
|
||||||
|
k.W = sho.GetScalar();
|
||||||
|
k.BigW = system.GW.Multiply(k.W);
|
||||||
|
k.Wprime = sho.GetScalar();
|
||||||
|
k.X0 = sho.GetScalar();
|
||||||
|
k.X1 = sho.GetScalar();
|
||||||
|
for (int i = 0; i < CredentialSystem.NumSupportedAttrs; i++) k.Y[i] = sho.GetScalar();
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Produces the MAC over the attribute points (Chase-Perrin-Zaverucha §3.1).</summary>
|
||||||
|
public Credential CredentialCore(Ristretto255[] m, ShoHmacSha256 sho)
|
||||||
|
{
|
||||||
|
if (m.Length > CredentialSystem.NumSupportedAttrs) throw new ArgumentException("too many attributes");
|
||||||
|
Scalar25519 t = sho.GetScalar();
|
||||||
|
Ristretto255 u = sho.GetPoint();
|
||||||
|
// V = W + (x0 + x1·t)·U + Σ y_i·M_i
|
||||||
|
Scalar25519 coeff = Scalar25519.Add(X0, Scalar25519.Mul(X1, t));
|
||||||
|
Ristretto255 v = Ristretto255.Add(BigW, u.Multiply(coeff));
|
||||||
|
for (int i = 0; i < m.Length; i++) v = Ristretto255.Add(v, m[i].Multiply(Y[i]));
|
||||||
|
return new Credential(t, u, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The server's public credential key the client uses to receive + present credentials.</summary>
|
||||||
|
public sealed class CredentialPublicKey
|
||||||
|
{
|
||||||
|
public Ristretto255 CW;
|
||||||
|
public Ristretto255[] I = new Ristretto255[CredentialSystem.NumSupportedAttrs - 1]; // I_2 .. I_7
|
||||||
|
|
||||||
|
/// <summary>I for a credential with <paramref name="numAttrs"/> attribute points (≥2).</summary>
|
||||||
|
public Ristretto255 IFor(int numAttrs) => I[numAttrs - 2];
|
||||||
|
|
||||||
|
public static CredentialPublicKey FromPrivate(CredentialPrivateKey priv)
|
||||||
|
{
|
||||||
|
var system = CredentialSystem.SystemParams.Hardcoded;
|
||||||
|
var pub = new CredentialPublicKey
|
||||||
|
{
|
||||||
|
CW = Ristretto255.Add(priv.BigW, system.GWprime.Multiply(priv.Wprime)),
|
||||||
|
};
|
||||||
|
// I_i = G_V - x0·G_x0 - x1·G_x1 - Σ_{j≤i} y_j·G_y_j
|
||||||
|
Ristretto255 ii = system.GV;
|
||||||
|
ii = Ristretto255.Add(ii, Ristretto255.Negate(system.GX0.Multiply(priv.X0)));
|
||||||
|
ii = Ristretto255.Add(ii, Ristretto255.Negate(system.GX1.Multiply(priv.X1)));
|
||||||
|
ii = Ristretto255.Add(ii, Ristretto255.Negate(system.GY[0].Multiply(priv.Y[0])));
|
||||||
|
for (int n = 1; n < CredentialSystem.NumSupportedAttrs; n++)
|
||||||
|
{
|
||||||
|
ii = Ristretto255.Add(ii, Ristretto255.Negate(system.GY[n].Multiply(priv.Y[n])));
|
||||||
|
pub.I[n - 1] = ii;
|
||||||
|
}
|
||||||
|
return pub;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>bincode: C_W(32)‖I[6]·32 = 224 bytes.</summary>
|
||||||
|
public byte[] Serialize()
|
||||||
|
{
|
||||||
|
var b = new byte[32 + 32 * (CredentialSystem.NumSupportedAttrs - 1)];
|
||||||
|
Array.Copy(CW.Encode(), 0, b, 0, 32);
|
||||||
|
for (int i = 0; i < I.Length; i++) Array.Copy(I[i].Encode(), 0, b, 32 + 32 * i, 32);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CredentialPublicKey Deserialize(ReadOnlySpan<byte> b)
|
||||||
|
{
|
||||||
|
int expected = 32 + 32 * (CredentialSystem.NumSupportedAttrs - 1);
|
||||||
|
if (b.Length != expected) throw new ArgumentException("bad CredentialPublicKey length");
|
||||||
|
var pub = new CredentialPublicKey
|
||||||
|
{
|
||||||
|
CW = Ristretto255.Decode(b[..32]) ?? throw new ArgumentException("bad C_W"),
|
||||||
|
};
|
||||||
|
for (int i = 0; i < pub.I.Length; i++)
|
||||||
|
pub.I[i] = Ristretto255.Decode(b.Slice(32 + 32 * i, 32)) ?? throw new ArgumentException("bad I");
|
||||||
|
return pub;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The server's credential key pair (private + derived public).</summary>
|
||||||
|
public sealed class CredentialKeyPair
|
||||||
|
{
|
||||||
|
public CredentialPrivateKey Private { get; }
|
||||||
|
public CredentialPublicKey Public { get; }
|
||||||
|
|
||||||
|
private CredentialKeyPair(CredentialPrivateKey priv, CredentialPublicKey pub) { Private = priv; Public = pub; }
|
||||||
|
|
||||||
|
public static CredentialKeyPair Generate(byte[] randomness)
|
||||||
|
{
|
||||||
|
var priv = CredentialPrivateKey.Generate(randomness);
|
||||||
|
return new CredentialKeyPair(priv, CredentialPublicKey.FromPrivate(priv));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
using Wingnal.Protocol.ZkGroup.Curve;
|
||||||
|
using Wingnal.Protocol.ZkGroup.Poksho;
|
||||||
|
|
||||||
|
namespace Wingnal.Protocol.ZkGroup.ZkCredential;
|
||||||
|
|
||||||
|
/// <summary>A credential presentation proof (Chase-Perrin-Zaverucha §3.2/§4.1): the commitments plus the
|
||||||
|
/// poksho proof. Serialized with bincode (fixint, little-endian; Vec = u64 length prefix + elements).</summary>
|
||||||
|
public sealed class PresentationProof
|
||||||
|
{
|
||||||
|
public Ristretto255 Cx0 = null!, Cx1 = null!, Cv = null!;
|
||||||
|
public Ristretto255[] Cy = System.Array.Empty<Ristretto255>();
|
||||||
|
public byte[] PokshoProof = System.Array.Empty<byte>();
|
||||||
|
|
||||||
|
public byte[] Serialize()
|
||||||
|
{
|
||||||
|
var ms = new List<byte>();
|
||||||
|
ms.AddRange(Cx0.Encode());
|
||||||
|
ms.AddRange(Cx1.Encode());
|
||||||
|
ms.AddRange(Cv.Encode());
|
||||||
|
AddU64Le(ms, (ulong)Cy.Length);
|
||||||
|
foreach (Ristretto255 p in Cy) ms.AddRange(p.Encode());
|
||||||
|
AddU64Le(ms, (ulong)PokshoProof.Length);
|
||||||
|
ms.AddRange(PokshoProof);
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddU64Le(List<byte> dst, ulong v)
|
||||||
|
{
|
||||||
|
Span<byte> b = stackalloc byte[8];
|
||||||
|
BinaryPrimitives.WriteUInt64LittleEndian(b, v);
|
||||||
|
dst.AddRange(b.ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal struct AttrRef { public int? KeyIndex; public int First; public int Second; }
|
||||||
|
|
||||||
|
/// <summary>Builds a credential presentation (the anonymous proof sent to the verifying/storage server).</summary>
|
||||||
|
public sealed class PresentationProofBuilder
|
||||||
|
{
|
||||||
|
private readonly byte[] _message;
|
||||||
|
private readonly List<EncryptionKeyContext> _keys = new();
|
||||||
|
private readonly List<AttrRef> _attrs = new();
|
||||||
|
private readonly List<Ristretto255> _attrPoints = new() { Ristretto255.Identity };
|
||||||
|
|
||||||
|
public PresentationProofBuilder(byte[] label, byte[]? message = null)
|
||||||
|
{
|
||||||
|
_ = label; // label is ignored on the prover side (public attrs are server-provided)
|
||||||
|
_message = message ?? System.Array.Empty<byte>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public PresentationProofBuilder AddAttribute(Ristretto255[] points, EncryptionKeyContext key)
|
||||||
|
{
|
||||||
|
int first = _attrPoints.Count;
|
||||||
|
_attrPoints.AddRange(points);
|
||||||
|
if (_attrPoints.Count > CredentialSystem.NumSupportedAttrs)
|
||||||
|
throw new ArgumentException("too many attribute points");
|
||||||
|
int keyIndex = _keys.FindIndex(k => k.Id == key.Id);
|
||||||
|
if (keyIndex < 0) { keyIndex = _keys.Count; _keys.Add(key); }
|
||||||
|
_attrs.Add(new AttrRef { KeyIndex = keyIndex, First = first, Second = first + points.Length - 1 });
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PresentationProof Present(CredentialPublicKey publicKey, Credential credential, byte[] randomness)
|
||||||
|
{
|
||||||
|
var s = CredentialSystem.SystemParams.Hardcoded;
|
||||||
|
var sho = new ShoHmacSha256(Encoding.ASCII.GetBytes("Signal_ZKCredential_Presentation_20230410"));
|
||||||
|
sho.AbsorbAndRatchet(randomness);
|
||||||
|
Scalar25519 z = sho.GetScalar();
|
||||||
|
|
||||||
|
var cy = new Ristretto255[_attrPoints.Count];
|
||||||
|
for (int i = 0; i < _attrPoints.Count; i++)
|
||||||
|
cy[i] = Ristretto255.Add(s.GY[i].Multiply(z), _attrPoints[i]);
|
||||||
|
Ristretto255 cx0 = Ristretto255.Add(s.GX0.Multiply(z), credential.U);
|
||||||
|
Ristretto255 cv = Ristretto255.Add(s.GV.Multiply(z), credential.V);
|
||||||
|
Ristretto255 cx1 = Ristretto255.Add(s.GX1.Multiply(z), credential.U.Multiply(credential.T));
|
||||||
|
|
||||||
|
Scalar25519 z0 = Scalar25519.Negate(Scalar25519.Mul(z, credential.T));
|
||||||
|
Ristretto255 ii = publicKey.IFor(_attrPoints.Count);
|
||||||
|
Ristretto255 bigZ = ii.Multiply(z);
|
||||||
|
|
||||||
|
var scalars = new Dictionary<string, Scalar25519> { ["z"] = z, ["t"] = credential.T, ["z0"] = z0 };
|
||||||
|
foreach (EncryptionKeyContext k in _keys)
|
||||||
|
{
|
||||||
|
scalars[$"a1_{k.Id}"] = k.A1;
|
||||||
|
scalars[$"a2_{k.Id}"] = k.A2;
|
||||||
|
scalars[$"z1_{k.Id}"] = Scalar25519.Negate(Scalar25519.Mul(z, k.A1));
|
||||||
|
}
|
||||||
|
|
||||||
|
Dictionary<string, Ristretto255> points = PrepareNonAttrPoints(ii, cx0, cx1, cy);
|
||||||
|
points["Z"] = bigZ;
|
||||||
|
foreach (AttrRef attr in _attrs)
|
||||||
|
{
|
||||||
|
points[$"C_y{attr.First}"] = cy[attr.First];
|
||||||
|
if (attr.KeyIndex is { } ki)
|
||||||
|
{
|
||||||
|
EncryptionKeyContext k = _keys[ki];
|
||||||
|
Ristretto255 eA1 = _attrPoints[attr.First].Multiply(k.A1);
|
||||||
|
Ristretto255 eA2 = Ristretto255.Add(eA1.Multiply(k.A2), _attrPoints[attr.Second]);
|
||||||
|
points[$"E_A{attr.First}"] = eA1;
|
||||||
|
points[$"-E_A{attr.First}"] = Ristretto255.Negate(eA1);
|
||||||
|
points[$"C_y{attr.Second}-E_A{attr.Second}"] = Ristretto255.Add(cy[attr.Second], Ristretto255.Negate(eA2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] poksho = BuildStatement(_keys, _attrs).Prove(scalars, points, _message, sho.SqueezeAndRatchet(32));
|
||||||
|
return new PresentationProof { Cx0 = cx0, Cx1 = cx1, Cv = cv, Cy = cy, PokshoProof = poksho };
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, Ristretto255> PrepareNonAttrPoints(
|
||||||
|
Ristretto255 ii, Ristretto255 cx0, Ristretto255 cx1, Ristretto255[] cy)
|
||||||
|
{
|
||||||
|
var s = CredentialSystem.SystemParams.Hardcoded;
|
||||||
|
var p = new Dictionary<string, Ristretto255>
|
||||||
|
{
|
||||||
|
["I"] = ii, ["C_x0"] = cx0, ["C_x1"] = cx1, ["G_x0"] = s.GX0, ["G_x1"] = s.GX1,
|
||||||
|
};
|
||||||
|
if (_keys.Count > 0)
|
||||||
|
{
|
||||||
|
p["0"] = Ristretto255.Identity;
|
||||||
|
Ristretto255 sumA = Ristretto255.Identity;
|
||||||
|
bool any = false;
|
||||||
|
foreach (EncryptionKeyContext k in _keys)
|
||||||
|
{
|
||||||
|
if (k.PublicKeyA is { } a)
|
||||||
|
{
|
||||||
|
p[$"G_a1_{k.Id}"] = k.Ga1;
|
||||||
|
p[$"G_a2_{k.Id}"] = k.Ga2;
|
||||||
|
sumA = Ristretto255.Add(sumA, a);
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (any) p["sum(A)"] = sumA;
|
||||||
|
}
|
||||||
|
string[] gy = { "G_y0", "G_y1", "G_y2", "G_y3", "G_y4", "G_y5", "G_y6" };
|
||||||
|
for (int i = 0; i < _attrPoints.Count; i++) p[gy[i]] = s.GY[i];
|
||||||
|
p["C_y0"] = cy[0];
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Statement BuildStatement(List<EncryptionKeyContext> keys, List<AttrRef> attrs)
|
||||||
|
{
|
||||||
|
var st = new Statement();
|
||||||
|
st.Add("Z", ("z", "I"));
|
||||||
|
st.Add("C_x1", ("t", "C_x0"), ("z0", "G_x0"), ("z", "G_x1"));
|
||||||
|
|
||||||
|
var sumTerms = new List<(string, string)>();
|
||||||
|
foreach (EncryptionKeyContext k in keys)
|
||||||
|
{
|
||||||
|
st.Add("0", ($"z1_{k.Id}", "I"), ($"a1_{k.Id}", "Z"));
|
||||||
|
if (k.PublicKeyA is not null)
|
||||||
|
{
|
||||||
|
sumTerms.Add(($"a1_{k.Id}", $"G_a1_{k.Id}"));
|
||||||
|
sumTerms.Add(($"a2_{k.Id}", $"G_a2_{k.Id}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sumTerms.Count > 0) st.Add("sum(A)", sumTerms.ToArray());
|
||||||
|
|
||||||
|
foreach (AttrRef attr in attrs)
|
||||||
|
{
|
||||||
|
if (attr.KeyIndex is { } ki)
|
||||||
|
{
|
||||||
|
string id = keys[ki].Id;
|
||||||
|
st.Add($"E_A{attr.First}", ($"a1_{id}", $"C_y{attr.First}"), ($"z1_{id}", $"G_y{attr.First}"));
|
||||||
|
st.Add($"C_y{attr.Second}-E_A{attr.Second}",
|
||||||
|
("z", $"G_y{attr.Second}"), ($"a2_{id}", $"-E_A{attr.First}"));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
st.Add($"C_y{attr.First}", ("z", $"G_y{attr.First}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st.Add("C_y0", ("z", "G_y0"));
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies a presentation (the verifying-server side; used here for offline round-trip testing).</summary>
|
||||||
|
public sealed class PresentationProofVerifier
|
||||||
|
{
|
||||||
|
private readonly ShoHmacSha256 _publicAttrs;
|
||||||
|
private readonly byte[] _message;
|
||||||
|
private readonly List<EncryptionKeyContext> _keys = new();
|
||||||
|
private readonly List<AttrRef> _attrs = new();
|
||||||
|
private readonly List<Ristretto255> _attrPoints = new() { Ristretto255.Identity };
|
||||||
|
|
||||||
|
public PresentationProofVerifier(byte[] label, byte[]? message = null)
|
||||||
|
{
|
||||||
|
_publicAttrs = new ShoHmacSha256(label);
|
||||||
|
_message = message ?? System.Array.Empty<byte>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public PresentationProofVerifier AddPublicAttributeU64(ulong value)
|
||||||
|
{
|
||||||
|
Span<byte> be = stackalloc byte[8];
|
||||||
|
BinaryPrimitives.WriteUInt64BigEndian(be, value);
|
||||||
|
_publicAttrs.AbsorbAndRatchet(be);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Adds an encrypted attribute (the ciphertext points) + the public encryption key context.</summary>
|
||||||
|
public PresentationProofVerifier AddAttribute(Ristretto255[] ciphertextPoints, EncryptionKeyContext key)
|
||||||
|
{
|
||||||
|
int first = _attrPoints.Count;
|
||||||
|
_attrPoints.AddRange(ciphertextPoints);
|
||||||
|
int keyIndex = _keys.FindIndex(k => k.Id == key.Id);
|
||||||
|
if (keyIndex < 0) { keyIndex = _keys.Count; _keys.Add(key); }
|
||||||
|
_attrs.Add(new AttrRef { KeyIndex = keyIndex, First = first, Second = first + ciphertextPoints.Length - 1 });
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Verify(CredentialKeyPair keyPair, PresentationProof proof)
|
||||||
|
{
|
||||||
|
_attrPoints[0] = _publicAttrs.GetPoint();
|
||||||
|
if (proof.Cy.Length != _attrPoints.Count) return false;
|
||||||
|
|
||||||
|
CredentialPrivateKey priv = keyPair.Private;
|
||||||
|
// Z = C_V - W - x0·C_x0 - x1·C_x1 - Σ y_i·C_y_i - y0·M0
|
||||||
|
Ristretto255 z = proof.Cv;
|
||||||
|
z = Ristretto255.Add(z, Ristretto255.Negate(priv.BigW));
|
||||||
|
z = Ristretto255.Add(z, Ristretto255.Negate(proof.Cx0.Multiply(priv.X0)));
|
||||||
|
z = Ristretto255.Add(z, Ristretto255.Negate(proof.Cx1.Multiply(priv.X1)));
|
||||||
|
for (int i = 0; i < proof.Cy.Length; i++)
|
||||||
|
z = Ristretto255.Add(z, Ristretto255.Negate(proof.Cy[i].Multiply(priv.Y[i])));
|
||||||
|
z = Ristretto255.Add(z, Ristretto255.Negate(_attrPoints[0].Multiply(priv.Y[0])));
|
||||||
|
|
||||||
|
Ristretto255 ii = keyPair.Public.IFor(_attrPoints.Count);
|
||||||
|
Dictionary<string, Ristretto255> points = PrepareNonAttrPoints(ii, proof.Cx0, proof.Cx1, proof.Cy);
|
||||||
|
foreach (AttrRef attr in _attrs)
|
||||||
|
{
|
||||||
|
points[$"C_y{attr.First}"] = proof.Cy[attr.First];
|
||||||
|
if (attr.KeyIndex is not null)
|
||||||
|
{
|
||||||
|
points[$"E_A{attr.First}"] = _attrPoints[attr.First];
|
||||||
|
points[$"-E_A{attr.First}"] = Ristretto255.Negate(_attrPoints[attr.First]);
|
||||||
|
points[$"C_y{attr.Second}-E_A{attr.Second}"] =
|
||||||
|
Ristretto255.Add(proof.Cy[attr.Second], Ristretto255.Negate(_attrPoints[attr.Second]));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
z = Ristretto255.Add(z, Ristretto255.Negate(_attrPoints[attr.First].Multiply(priv.Y[attr.First])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
points["Z"] = z;
|
||||||
|
return PresentationProofBuilder.BuildStatement(_keys, _attrs).VerifyProof(proof.PokshoProof, points, _message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, Ristretto255> PrepareNonAttrPoints(
|
||||||
|
Ristretto255 ii, Ristretto255 cx0, Ristretto255 cx1, Ristretto255[] cy)
|
||||||
|
{
|
||||||
|
var s = CredentialSystem.SystemParams.Hardcoded;
|
||||||
|
var p = new Dictionary<string, Ristretto255>
|
||||||
|
{
|
||||||
|
["I"] = ii, ["C_x0"] = cx0, ["C_x1"] = cx1, ["G_x0"] = s.GX0, ["G_x1"] = s.GX1,
|
||||||
|
};
|
||||||
|
if (_keys.Count > 0)
|
||||||
|
{
|
||||||
|
p["0"] = Ristretto255.Identity;
|
||||||
|
Ristretto255 sumA = Ristretto255.Identity;
|
||||||
|
bool any = false;
|
||||||
|
foreach (EncryptionKeyContext k in _keys)
|
||||||
|
{
|
||||||
|
if (k.PublicKeyA is { } a)
|
||||||
|
{
|
||||||
|
p[$"G_a1_{k.Id}"] = k.Ga1; p[$"G_a2_{k.Id}"] = k.Ga2;
|
||||||
|
sumA = Ristretto255.Add(sumA, a); any = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (any) p["sum(A)"] = sumA;
|
||||||
|
}
|
||||||
|
string[] gy = { "G_y0", "G_y1", "G_y2", "G_y3", "G_y4", "G_y5", "G_y6" };
|
||||||
|
for (int i = 0; i < _attrPoints.Count; i++) p[gy[i]] = s.GY[i];
|
||||||
|
p["C_y0"] = cy[0];
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using Wingnal.Protocol.Curve;
|
||||||
|
using Wingnal.Protocol.State;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Account;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Signal protocol store for the linked account, seeded from the persisted <see cref="SignalAccount"/>
|
||||||
|
/// (ACI identity + the signed/kyber prekeys registered at link time). Sessions and learned remote
|
||||||
|
/// identities live in memory for the process lifetime (see SHORTCUTS.md: not yet persisted across runs).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AccountProtocolStore : ISignalProtocolStore
|
||||||
|
{
|
||||||
|
private readonly IdentityKeyPair _identityKeyPair;
|
||||||
|
private readonly uint _registrationId;
|
||||||
|
private readonly Dictionary<SignalProtocolAddress, IdentityKey> _identities = new();
|
||||||
|
private readonly Dictionary<uint, PreKeyRecord> _preKeys = new();
|
||||||
|
private readonly Dictionary<uint, SignedPreKeyRecord> _signedPreKeys = new();
|
||||||
|
private readonly Dictionary<uint, KyberPreKeyRecord> _kyberPreKeys = new();
|
||||||
|
private readonly Dictionary<SignalProtocolAddress, SessionRecord> _sessions = new();
|
||||||
|
private readonly SignalAccount _account;
|
||||||
|
private readonly Action? _onChanged;
|
||||||
|
|
||||||
|
/// <param name="onChanged">Invoked after a one-time prekey is consumed (removed from
|
||||||
|
/// <see cref="SignalAccount.AciOneTimePreKeys"/>) so the caller can re-persist the account. Null =
|
||||||
|
/// no persistence (tests / ephemeral use).</param>
|
||||||
|
public AccountProtocolStore(SignalAccount account, Action? onChanged = null)
|
||||||
|
{
|
||||||
|
_account = account;
|
||||||
|
_onChanged = onChanged;
|
||||||
|
_identityKeyPair = account.AciIdentityKeyPair;
|
||||||
|
_registrationId = account.AciRegistrationId;
|
||||||
|
|
||||||
|
RegisteredPreKeys pk = account.AciPreKeys;
|
||||||
|
_signedPreKeys[pk.SignedPreKeyId] = new SignedPreKeyRecord(
|
||||||
|
pk.SignedPreKeyId,
|
||||||
|
new ECKeyPair(pk.SignedPreKeyPrivate, pk.SignedPreKeyPublic),
|
||||||
|
pk.SignedPreKeySignature, timestamp: 0);
|
||||||
|
_kyberPreKeys[pk.KyberPreKeyId] = new KyberPreKeyRecord(
|
||||||
|
pk.KyberPreKeyId,
|
||||||
|
new KyberKeyPair(pk.KyberPreKeyPublic, pk.KyberPreKeyPrivate),
|
||||||
|
pk.KyberPreKeySignature, timestamp: 0);
|
||||||
|
|
||||||
|
foreach (OneTimePreKey otp in account.AciOneTimePreKeys)
|
||||||
|
_preKeys[otp.Id] = new PreKeyRecord(otp.Id, new ECKeyPair(otp.Private, otp.Public));
|
||||||
|
}
|
||||||
|
|
||||||
|
public IdentityKeyPair GetIdentityKeyPair() => _identityKeyPair;
|
||||||
|
public uint GetLocalRegistrationId() => _registrationId;
|
||||||
|
|
||||||
|
public bool SaveIdentity(SignalProtocolAddress address, IdentityKey identity)
|
||||||
|
{
|
||||||
|
bool changed = _identities.TryGetValue(address, out IdentityKey? existing)
|
||||||
|
&& !existing!.PublicKey.AsSpan().SequenceEqual(identity.PublicKey);
|
||||||
|
_identities[address] = identity;
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsTrustedIdentity(SignalProtocolAddress address, IdentityKey identity)
|
||||||
|
{
|
||||||
|
if (!_identities.TryGetValue(address, out IdentityKey? existing)) return true; // first use
|
||||||
|
return existing!.PublicKey.AsSpan().SequenceEqual(identity.PublicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IdentityKey? GetIdentity(SignalProtocolAddress address) =>
|
||||||
|
_identities.TryGetValue(address, out IdentityKey? id) ? id : null;
|
||||||
|
|
||||||
|
public PreKeyRecord LoadPreKey(uint preKeyId) => _preKeys[preKeyId];
|
||||||
|
public void StorePreKey(uint preKeyId, PreKeyRecord record) => _preKeys[preKeyId] = record;
|
||||||
|
public bool ContainsPreKey(uint preKeyId) => _preKeys.ContainsKey(preKeyId);
|
||||||
|
public void RemovePreKey(uint preKeyId)
|
||||||
|
{
|
||||||
|
_preKeys.Remove(preKeyId);
|
||||||
|
// Persist consumption: a used one-time prekey must never be reused.
|
||||||
|
int removed = _account.AciOneTimePreKeys.RemoveAll(k => k.Id == preKeyId);
|
||||||
|
if (removed > 0) _onChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public SignedPreKeyRecord LoadSignedPreKey(uint id) => _signedPreKeys[id];
|
||||||
|
public void StoreSignedPreKey(uint id, SignedPreKeyRecord record) => _signedPreKeys[id] = record;
|
||||||
|
public bool ContainsSignedPreKey(uint id) => _signedPreKeys.ContainsKey(id);
|
||||||
|
|
||||||
|
public KyberPreKeyRecord LoadKyberPreKey(uint id) => _kyberPreKeys[id];
|
||||||
|
public void StoreKyberPreKey(uint id, KyberPreKeyRecord record) => _kyberPreKeys[id] = record;
|
||||||
|
public bool ContainsKyberPreKey(uint id) => _kyberPreKeys.ContainsKey(id);
|
||||||
|
public void MarkKyberPreKeyUsed(uint id) { /* last-resort key: kept */ }
|
||||||
|
|
||||||
|
public SessionRecord LoadSession(SignalProtocolAddress address) =>
|
||||||
|
_sessions.TryGetValue(address, out SessionRecord? record) ? record : new SessionRecord();
|
||||||
|
public bool ContainsSession(SignalProtocolAddress address) => _sessions.ContainsKey(address);
|
||||||
|
public void StoreSession(SignalProtocolAddress address, SessionRecord record) => _sessions[address] = record;
|
||||||
|
public void DeleteSession(SignalProtocolAddress address) => _sessions.Remove(address);
|
||||||
|
|
||||||
|
/// <summary>Device ids of <paramref name="name"/> that already have an established session — used to
|
||||||
|
/// reuse active sessions on send (Sesame) instead of re-fetching a prekey bundle every time.</summary>
|
||||||
|
public IReadOnlyList<uint> GetSubDeviceSessions(string name) =>
|
||||||
|
_sessions.Keys.Where(a => a.Name == name).Select(a => a.DeviceId).ToList();
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Account;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists the linked <see cref="SignalAccount"/> to disk, encrypted at rest with Windows DPAPI
|
||||||
|
/// (per-user). Stored under %LOCALAPPDATA%\Wingnal\account.bin.
|
||||||
|
/// </summary>
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
public sealed class AccountStore
|
||||||
|
{
|
||||||
|
private static readonly byte[] Entropy = "Wingnal.Account.v1"u8.ToArray();
|
||||||
|
private readonly string _path;
|
||||||
|
|
||||||
|
public AccountStore(string? directory = null)
|
||||||
|
{
|
||||||
|
directory ??= Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal");
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
_path = Path.Combine(directory, "account.bin");
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Exists => File.Exists(_path);
|
||||||
|
|
||||||
|
public void Save(SignalAccount account)
|
||||||
|
{
|
||||||
|
byte[] json = JsonSerializer.SerializeToUtf8Bytes(account);
|
||||||
|
byte[] encrypted = ProtectedData.Protect(json, Entropy, DataProtectionScope.CurrentUser);
|
||||||
|
File.WriteAllBytes(_path, encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SignalAccount? Load()
|
||||||
|
{
|
||||||
|
if (!Exists)
|
||||||
|
return null;
|
||||||
|
byte[] encrypted = File.ReadAllBytes(_path);
|
||||||
|
byte[] json = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser);
|
||||||
|
return JsonSerializer.Deserialize<SignalAccount>(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Delete()
|
||||||
|
{
|
||||||
|
if (Exists)
|
||||||
|
File.Delete(_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Account;
|
||||||
|
|
||||||
|
/// <summary>A synced contact: ACI + display info, used to name conversations.</summary>
|
||||||
|
public sealed record Contact(string Aci, string? Number, string? Name, int InboxPosition);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQLite store of contacts learned from a SyncMessage.Contacts blob, so the conversation list can show
|
||||||
|
/// names instead of raw ACIs (%LOCALAPPDATA%\Wingnal\contacts.db). Keyed by ACI. Names + numbers are
|
||||||
|
/// encrypted at rest with <see cref="LocalCipher"/> (so the file can't be read to map an ACI → a real
|
||||||
|
/// person); legacy plaintext rows decrypt-through unchanged.
|
||||||
|
/// </summary>
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
public sealed class ContactsStore
|
||||||
|
{
|
||||||
|
private readonly string _connectionString;
|
||||||
|
private readonly LocalCipher _cipher;
|
||||||
|
|
||||||
|
public ContactsStore(string? path = null, LocalCipher? cipher = null)
|
||||||
|
{
|
||||||
|
if (path is null)
|
||||||
|
{
|
||||||
|
string dir = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
path = Path.Combine(dir, "contacts.db");
|
||||||
|
}
|
||||||
|
_cipher = cipher ?? LocalCipher.Default();
|
||||||
|
_connectionString = $"Data Source={path}";
|
||||||
|
Initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Initialize()
|
||||||
|
{
|
||||||
|
using SqliteConnection conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText =
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
|
aci TEXT PRIMARY KEY,
|
||||||
|
number TEXT,
|
||||||
|
name TEXT,
|
||||||
|
inboxPosition INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
""";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Upsert(Contact contact)
|
||||||
|
{
|
||||||
|
using SqliteConnection conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText =
|
||||||
|
"""
|
||||||
|
INSERT INTO contacts (aci, number, name, inboxPosition) VALUES ($aci, $number, $name, $pos)
|
||||||
|
ON CONFLICT(aci) DO UPDATE SET number = $number, name = $name, inboxPosition = $pos;
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("$aci", contact.Aci);
|
||||||
|
cmd.Parameters.AddWithValue("$number", Enc(contact.Number));
|
||||||
|
cmd.Parameters.AddWithValue("$name", Enc(contact.Name));
|
||||||
|
cmd.Parameters.AddWithValue("$pos", contact.InboxPosition);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? NameFor(string aci)
|
||||||
|
{
|
||||||
|
using SqliteConnection conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT name FROM contacts WHERE aci = $aci;";
|
||||||
|
cmd.Parameters.AddWithValue("$aci", aci);
|
||||||
|
return cmd.ExecuteScalar() is string s ? _cipher.Unprotect(s) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Contacts whose name or number contains <paramref name="query"/> (empty = all named),
|
||||||
|
/// ordered by inbox position then name. Filtered in memory because the columns are encrypted.</summary>
|
||||||
|
public IReadOnlyList<Contact> Search(string? query, int limit = 25)
|
||||||
|
{
|
||||||
|
string q = (query ?? string.Empty).Trim();
|
||||||
|
IEnumerable<Contact> contacts = All();
|
||||||
|
contacts = q.Length == 0
|
||||||
|
? contacts.Where(c => !string.IsNullOrEmpty(c.Name))
|
||||||
|
: contacts.Where(c =>
|
||||||
|
(c.Name?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false) ||
|
||||||
|
(c.Number?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false));
|
||||||
|
return contacts.OrderBy(c => c.InboxPosition).ThenBy(c => c.Name).Take(limit).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<Contact> All()
|
||||||
|
{
|
||||||
|
using SqliteConnection conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT aci, number, name, inboxPosition FROM contacts ORDER BY inboxPosition;";
|
||||||
|
var result = new List<Contact>();
|
||||||
|
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
result.Add(new Contact(
|
||||||
|
reader.GetString(0),
|
||||||
|
reader.IsDBNull(1) ? null : _cipher.Unprotect(reader.GetString(1)),
|
||||||
|
reader.IsDBNull(2) ? null : _cipher.Unprotect(reader.GetString(2)),
|
||||||
|
reader.GetInt32(3)));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private object Enc(string? value) => value is null ? DBNull.Value : _cipher.Protect(value);
|
||||||
|
|
||||||
|
/// <summary>Removes all synced contacts (used when unlinking).</summary>
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
using SqliteConnection conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "DELETE FROM contacts;";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
private SqliteConnection Open()
|
||||||
|
{
|
||||||
|
var conn = new SqliteConnection(_connectionString);
|
||||||
|
conn.Open();
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Wingnal.Protocol.Crypto;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Account;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encrypts sensitive local-database fields at rest (message bodies, contact names) with AES-256-GCM
|
||||||
|
/// under a per-install key. The key itself is a random 32 bytes wrapped with Windows DPAPI
|
||||||
|
/// (CurrentUser) on disk, so the SQLite files no longer contain plaintext content. Also provides a
|
||||||
|
/// deterministic keyed MAC for fields that must remain queryable/dedupable.
|
||||||
|
/// </summary>
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
public sealed class LocalCipher
|
||||||
|
{
|
||||||
|
private static readonly byte[] Entropy = "Wingnal.LocalCipher.v1"u8.ToArray();
|
||||||
|
private readonly byte[] _key; // 32 bytes
|
||||||
|
|
||||||
|
public LocalCipher(byte[] key)
|
||||||
|
{
|
||||||
|
if (key.Length != 32) throw new ArgumentException("key must be 32 bytes", nameof(key));
|
||||||
|
_key = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Loads (or creates) the DPAPI-wrapped per-install key at %LOCALAPPDATA%\Wingnal\local.key.</summary>
|
||||||
|
public static LocalCipher Default(string? directory = null)
|
||||||
|
{
|
||||||
|
directory ??= Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal");
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
string keyPath = Path.Combine(directory, "local.key");
|
||||||
|
|
||||||
|
byte[] key;
|
||||||
|
if (File.Exists(keyPath))
|
||||||
|
{
|
||||||
|
key = ProtectedData.Unprotect(File.ReadAllBytes(keyPath), Entropy, DataProtectionScope.CurrentUser);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
key = RandomNumberGenerator.GetBytes(32);
|
||||||
|
File.WriteAllBytes(keyPath, ProtectedData.Protect(key, Entropy, DataProtectionScope.CurrentUser));
|
||||||
|
}
|
||||||
|
return new LocalCipher(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Encrypts a string to base64( nonce[12] ‖ ciphertext ‖ tag[16] ).</summary>
|
||||||
|
public string Protect(string plaintext)
|
||||||
|
{
|
||||||
|
byte[] nonce = RandomNumberGenerator.GetBytes(12);
|
||||||
|
byte[] ctAndTag = CryptoPrimitives.AesGcmEncrypt(_key, nonce, Encoding.UTF8.GetBytes(plaintext));
|
||||||
|
var blob = new byte[nonce.Length + ctAndTag.Length];
|
||||||
|
Buffer.BlockCopy(nonce, 0, blob, 0, nonce.Length);
|
||||||
|
Buffer.BlockCopy(ctAndTag, 0, blob, nonce.Length, ctAndTag.Length);
|
||||||
|
return Convert.ToBase64String(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Inverse of <see cref="Protect"/>. If <paramref name="stored"/> isn't a value we wrote
|
||||||
|
/// (e.g. a legacy plaintext row from before encryption), it's returned unchanged.</summary>
|
||||||
|
public string Unprotect(string stored)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
byte[] blob = Convert.FromBase64String(stored);
|
||||||
|
if (blob.Length < 12 + 16) return stored;
|
||||||
|
byte[] nonce = blob.AsSpan(0, 12).ToArray();
|
||||||
|
byte[] ctAndTag = blob.AsSpan(12).ToArray();
|
||||||
|
return Encoding.UTF8.GetString(CryptoPrimitives.AesGcmDecrypt(_key, nonce, ctAndTag));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return stored; // legacy plaintext or not ours — leave as-is
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deterministic keyed identity of a value, for dedup/index columns (hex HMAC-SHA256).</summary>
|
||||||
|
public string Mac(string value) =>
|
||||||
|
Convert.ToHexString(CryptoPrimitives.HmacSha256(_key, Encoding.UTF8.GetBytes(value)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System.Runtime.Versioning;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Account;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stores peers' profile keys (learned from the <c>profileKey</c> field of their inbound DataMessages),
|
||||||
|
/// so we can derive their unidentified-access key and send them sealed-sender (metadata-minimized)
|
||||||
|
/// messages. Profile keys are encrypted at rest with <see cref="LocalCipher"/>.
|
||||||
|
/// </summary>
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
public sealed class ProfileKeyStore
|
||||||
|
{
|
||||||
|
private readonly string _connectionString;
|
||||||
|
private readonly LocalCipher _cipher;
|
||||||
|
|
||||||
|
public ProfileKeyStore(string? path = null, LocalCipher? cipher = null)
|
||||||
|
{
|
||||||
|
if (path is null)
|
||||||
|
{
|
||||||
|
string dir = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
path = Path.Combine(dir, "profilekeys.db");
|
||||||
|
}
|
||||||
|
_cipher = cipher ?? LocalCipher.Default();
|
||||||
|
_connectionString = $"Data Source={path}";
|
||||||
|
using SqliteConnection conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "CREATE TABLE IF NOT EXISTS profile_keys (aci TEXT PRIMARY KEY, key TEXT NOT NULL);";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Store(string aci, byte[] profileKey)
|
||||||
|
{
|
||||||
|
if (profileKey.Length != 32) return;
|
||||||
|
using SqliteConnection conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText =
|
||||||
|
"INSERT INTO profile_keys (aci, key) VALUES ($a, $k) ON CONFLICT(aci) DO UPDATE SET key = $k;";
|
||||||
|
cmd.Parameters.AddWithValue("$a", aci);
|
||||||
|
cmd.Parameters.AddWithValue("$k", _cipher.Protect(Convert.ToBase64String(profileKey)));
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[]? Get(string aci)
|
||||||
|
{
|
||||||
|
using SqliteConnection conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT key FROM profile_keys WHERE aci = $a;";
|
||||||
|
cmd.Parameters.AddWithValue("$a", aci);
|
||||||
|
if (cmd.ExecuteScalar() is not string enc) return null;
|
||||||
|
try { return Convert.FromBase64String(_cipher.Unprotect(enc)); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
using SqliteConnection conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "DELETE FROM profile_keys;";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
private SqliteConnection Open()
|
||||||
|
{
|
||||||
|
var conn = new SqliteConnection(_connectionString);
|
||||||
|
conn.Open();
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Wingnal.Protocol.Curve;
|
||||||
|
using Wingnal.Protocol.State;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Account;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Everything this linked secondary device needs to authenticate to Signal and run the protocol:
|
||||||
|
/// account identifiers, the device id/password assigned at link time, registration ids, and the ACI
|
||||||
|
/// and PNI identity key pairs (received from the primary during provisioning).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SignalAccount
|
||||||
|
{
|
||||||
|
public required string Aci { get; init; }
|
||||||
|
public required string Pni { get; init; }
|
||||||
|
public required string Number { get; init; }
|
||||||
|
public required int DeviceId { get; set; }
|
||||||
|
public required string Password { get; init; }
|
||||||
|
public required uint AciRegistrationId { get; init; }
|
||||||
|
public required uint PniRegistrationId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>33-byte serialized DjbECPublicKey.</summary>
|
||||||
|
public required byte[] AciIdentityPublic { get; init; }
|
||||||
|
public required byte[] AciIdentityPrivate { get; init; }
|
||||||
|
public required byte[] PniIdentityPublic { get; init; }
|
||||||
|
public required byte[] PniIdentityPrivate { get; init; }
|
||||||
|
public required byte[] ProfileKey { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Signed + last-resort kyber prekeys registered at link time (ACI identity).</summary>
|
||||||
|
public required RegisteredPreKeys AciPreKeys { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Signed + last-resort kyber prekeys registered at link time (PNI identity).</summary>
|
||||||
|
public required RegisteredPreKeys PniPreKeys { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Unused one-time EC prekeys uploaded for the ACI identity (consumed as inbound sessions
|
||||||
|
/// use them; removed on consumption and re-persisted). Not <c>required</c> so older account.bin
|
||||||
|
/// files (without this field) still deserialize as empty.</summary>
|
||||||
|
public List<OneTimePreKey> AciOneTimePreKeys { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>The one-time 32-byte link'n'sync backup key the primary sent in the ProvisionMessage,
|
||||||
|
/// used to decrypt the message-history transfer archive once. Cleared after a successful import (or
|
||||||
|
/// null when the primary didn't offer link+sync). Not <c>required</c> for back-compat.</summary>
|
||||||
|
public byte[]? EphemeralBackupKey { get; set; }
|
||||||
|
|
||||||
|
public IdentityKeyPair AciIdentityKeyPair =>
|
||||||
|
new(IdentityKey.Decode(AciIdentityPublic), AciIdentityPrivate);
|
||||||
|
|
||||||
|
public IdentityKeyPair PniIdentityKeyPair =>
|
||||||
|
new(IdentityKey.Decode(PniIdentityPublic), PniIdentityPrivate);
|
||||||
|
|
||||||
|
/// <summary>HTTP Basic auth value (without the "Basic " prefix) for the authenticated chat API.</summary>
|
||||||
|
public string BasicAuthToken()
|
||||||
|
{
|
||||||
|
string user = DeviceId == 1 ? Aci : $"{Aci}.{DeviceId}";
|
||||||
|
return Convert.ToBase64String(Encoding.UTF8.GetBytes($"{user}:{Password}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The durable private material for the prekeys this device registered for one identity.</summary>
|
||||||
|
public sealed class RegisteredPreKeys
|
||||||
|
{
|
||||||
|
public required uint SignedPreKeyId { get; init; }
|
||||||
|
public required byte[] SignedPreKeyPublic { get; init; } // raw 32
|
||||||
|
public required byte[] SignedPreKeyPrivate { get; init; } // raw 32
|
||||||
|
public required byte[] SignedPreKeySignature { get; init; }
|
||||||
|
public required uint KyberPreKeyId { get; init; }
|
||||||
|
public required byte[] KyberPreKeyPublic { get; init; } // ML-KEM encoded
|
||||||
|
public required byte[] KyberPreKeyPrivate { get; init; }
|
||||||
|
public required byte[] KyberPreKeySignature { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One unused one-time prekey's durable material (id + raw 32-byte EC key pair).</summary>
|
||||||
|
public sealed class OneTimePreKey
|
||||||
|
{
|
||||||
|
public uint Id { get; set; }
|
||||||
|
public byte[] Public { get; set; } = Array.Empty<byte>(); // raw 32
|
||||||
|
public byte[] Private { get; set; } = Array.Empty<byte>(); // raw 32
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Wingnal.Protocol.Groups;
|
||||||
|
using Wingnal.Protocol.State;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Account;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Durable <see cref="ISenderKeyStore"/>: persists group sender-key records to SQLite, keyed by
|
||||||
|
/// (sender address, distribution id), so an established group chain survives an app restart — mirroring
|
||||||
|
/// how <see cref="SqliteSignalProtocolStore"/> persists 1:1 sessions. Each record blob is DPAPI-protected
|
||||||
|
/// at rest. State serialization is local-only (never sent to a peer).
|
||||||
|
/// </summary>
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
public sealed class SqliteSenderKeyStore : ISenderKeyStore
|
||||||
|
{
|
||||||
|
private static readonly byte[] Entropy = "Wingnal.SenderKeyStore.v1"u8.ToArray();
|
||||||
|
|
||||||
|
private readonly string _connectionString;
|
||||||
|
|
||||||
|
public SqliteSenderKeyStore(string dbFileName = "senderkeys.db", string? directory = null)
|
||||||
|
{
|
||||||
|
directory ??= Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal");
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
_connectionString = $"Data Source={Path.Combine(directory, dbFileName)}";
|
||||||
|
Initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Initialize()
|
||||||
|
{
|
||||||
|
using SqliteConnection conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText =
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS sender_keys (
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
device INTEGER NOT NULL,
|
||||||
|
distribution TEXT NOT NULL,
|
||||||
|
data BLOB NOT NULL,
|
||||||
|
PRIMARY KEY (name, device, distribution));
|
||||||
|
""";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StoreSenderKey(SignalProtocolAddress sender, Guid distributionId, SenderKeyRecord record)
|
||||||
|
{
|
||||||
|
using SqliteConnection conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText =
|
||||||
|
"INSERT OR REPLACE INTO sender_keys (name, device, distribution, data) VALUES ($n, $d, $g, $data);";
|
||||||
|
cmd.Parameters.AddWithValue("$n", sender.Name);
|
||||||
|
cmd.Parameters.AddWithValue("$d", sender.DeviceId);
|
||||||
|
cmd.Parameters.AddWithValue("$g", distributionId.ToString("D"));
|
||||||
|
cmd.Parameters.AddWithValue("$data", Protect(record.Serialize()));
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
public SenderKeyRecord? LoadSenderKey(SignalProtocolAddress sender, Guid distributionId)
|
||||||
|
{
|
||||||
|
using SqliteConnection conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText =
|
||||||
|
"SELECT data FROM sender_keys WHERE name = $n AND device = $d AND distribution = $g;";
|
||||||
|
cmd.Parameters.AddWithValue("$n", sender.Name);
|
||||||
|
cmd.Parameters.AddWithValue("$d", sender.DeviceId);
|
||||||
|
cmd.Parameters.AddWithValue("$g", distributionId.ToString("D"));
|
||||||
|
return cmd.ExecuteScalar() is byte[] data ? SenderKeyRecord.Deserialize(Unprotect(data)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wipes all sender keys (used when unlinking, alongside the session/message wipe).</summary>
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
using SqliteConnection conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "DELETE FROM sender_keys;";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Protect(byte[] plain) => ProtectedData.Protect(plain, Entropy, DataProtectionScope.CurrentUser);
|
||||||
|
private static byte[] Unprotect(byte[] enc) => ProtectedData.Unprotect(enc, Entropy, DataProtectionScope.CurrentUser);
|
||||||
|
|
||||||
|
private SqliteConnection Open()
|
||||||
|
{
|
||||||
|
var conn = new SqliteConnection(_connectionString);
|
||||||
|
conn.Open();
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Wingnal.Protocol.Curve;
|
||||||
|
using Wingnal.Protocol.State;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Account;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Durable Signal protocol store: identity + signed/kyber/one-time prekeys are seeded from the
|
||||||
|
/// persisted <see cref="SignalAccount"/> (same as <see cref="AccountProtocolStore"/>), while sessions
|
||||||
|
/// and learned remote identities persist to a SQLite DB so conversations survive app restarts. Each
|
||||||
|
/// stored blob is DPAPI-protected at rest. One-time prekey consumption re-persists the account via the
|
||||||
|
/// <c>onChanged</c> callback.
|
||||||
|
/// </summary>
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
public sealed class SqliteSignalProtocolStore : ISignalProtocolStore
|
||||||
|
{
|
||||||
|
private static readonly byte[] Entropy = "Wingnal.ProtocolStore.v1"u8.ToArray();
|
||||||
|
|
||||||
|
private readonly SignalAccount _account;
|
||||||
|
private readonly Action? _onChanged;
|
||||||
|
private readonly IdentityKeyPair _identityKeyPair;
|
||||||
|
private readonly uint _registrationId;
|
||||||
|
private readonly Dictionary<uint, PreKeyRecord> _preKeys = new();
|
||||||
|
private readonly Dictionary<uint, SignedPreKeyRecord> _signedPreKeys = new();
|
||||||
|
private readonly Dictionary<uint, KyberPreKeyRecord> _kyberPreKeys = new();
|
||||||
|
private readonly string _connectionString;
|
||||||
|
|
||||||
|
/// <param name="dbFileName">Distinct file lets send/receive keep separate session spaces.</param>
|
||||||
|
public SqliteSignalProtocolStore(SignalAccount account, string dbFileName = "protocol.db",
|
||||||
|
Action? onChanged = null, string? directory = null)
|
||||||
|
{
|
||||||
|
_account = account;
|
||||||
|
_onChanged = onChanged;
|
||||||
|
_identityKeyPair = account.AciIdentityKeyPair;
|
||||||
|
_registrationId = account.AciRegistrationId;
|
||||||
|
|
||||||
|
RegisteredPreKeys pk = account.AciPreKeys;
|
||||||
|
_signedPreKeys[pk.SignedPreKeyId] = new SignedPreKeyRecord(
|
||||||
|
pk.SignedPreKeyId, new ECKeyPair(pk.SignedPreKeyPrivate, pk.SignedPreKeyPublic), pk.SignedPreKeySignature, 0);
|
||||||
|
_kyberPreKeys[pk.KyberPreKeyId] = new KyberPreKeyRecord(
|
||||||
|
pk.KyberPreKeyId, new KyberKeyPair(pk.KyberPreKeyPublic, pk.KyberPreKeyPrivate), pk.KyberPreKeySignature, 0);
|
||||||
|
foreach (OneTimePreKey otp in account.AciOneTimePreKeys)
|
||||||
|
_preKeys[otp.Id] = new PreKeyRecord(otp.Id, new ECKeyPair(otp.Private, otp.Public));
|
||||||
|
|
||||||
|
directory ??= Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal");
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
_connectionString = $"Data Source={Path.Combine(directory, dbFileName)}";
|
||||||
|
Initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Initialize()
|
||||||
|
{
|
||||||
|
using SqliteConnection conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText =
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (name TEXT NOT NULL, device INTEGER NOT NULL, data BLOB NOT NULL, PRIMARY KEY (name, device));
|
||||||
|
CREATE TABLE IF NOT EXISTS identities (name TEXT NOT NULL, device INTEGER NOT NULL, data BLOB NOT NULL, PRIMARY KEY (name, device));
|
||||||
|
""";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── identities ──
|
||||||
|
public IdentityKeyPair GetIdentityKeyPair() => _identityKeyPair;
|
||||||
|
public uint GetLocalRegistrationId() => _registrationId;
|
||||||
|
|
||||||
|
public bool SaveIdentity(SignalProtocolAddress address, IdentityKey identity)
|
||||||
|
{
|
||||||
|
IdentityKey? existing = GetIdentity(address);
|
||||||
|
bool changed = existing is not null && !existing.PublicKey.AsSpan().SequenceEqual(identity.PublicKey);
|
||||||
|
Upsert("identities", address, Protect(identity.PublicKey));
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsTrustedIdentity(SignalProtocolAddress address, IdentityKey identity)
|
||||||
|
{
|
||||||
|
IdentityKey? existing = GetIdentity(address);
|
||||||
|
return existing is null || existing.PublicKey.AsSpan().SequenceEqual(identity.PublicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IdentityKey? GetIdentity(SignalProtocolAddress address)
|
||||||
|
{
|
||||||
|
byte[]? data = Load("identities", address);
|
||||||
|
return data is null ? null : new IdentityKey(Unprotect(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── prekeys (from account; consumption re-persists the account) ──
|
||||||
|
public PreKeyRecord LoadPreKey(uint preKeyId) => _preKeys[preKeyId];
|
||||||
|
public void StorePreKey(uint preKeyId, PreKeyRecord record) => _preKeys[preKeyId] = record;
|
||||||
|
public bool ContainsPreKey(uint preKeyId) => _preKeys.ContainsKey(preKeyId);
|
||||||
|
public void RemovePreKey(uint preKeyId)
|
||||||
|
{
|
||||||
|
_preKeys.Remove(preKeyId);
|
||||||
|
if (_account.AciOneTimePreKeys.RemoveAll(k => k.Id == preKeyId) > 0) _onChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public SignedPreKeyRecord LoadSignedPreKey(uint id) => _signedPreKeys[id];
|
||||||
|
public void StoreSignedPreKey(uint id, SignedPreKeyRecord record) => _signedPreKeys[id] = record;
|
||||||
|
public bool ContainsSignedPreKey(uint id) => _signedPreKeys.ContainsKey(id);
|
||||||
|
|
||||||
|
public KyberPreKeyRecord LoadKyberPreKey(uint id) => _kyberPreKeys[id];
|
||||||
|
public void StoreKyberPreKey(uint id, KyberPreKeyRecord record) => _kyberPreKeys[id] = record;
|
||||||
|
public bool ContainsKyberPreKey(uint id) => _kyberPreKeys.ContainsKey(id);
|
||||||
|
public void MarkKyberPreKeyUsed(uint id) { /* last-resort key: kept */ }
|
||||||
|
|
||||||
|
// ── sessions (durable) ──
|
||||||
|
public SessionRecord LoadSession(SignalProtocolAddress address)
|
||||||
|
{
|
||||||
|
byte[]? data = Load("sessions", address);
|
||||||
|
return data is null ? new SessionRecord() : SessionRecord.Deserialize(Unprotect(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ContainsSession(SignalProtocolAddress address) => Load("sessions", address) is not null;
|
||||||
|
public void StoreSession(SignalProtocolAddress address, SessionRecord record) =>
|
||||||
|
Upsert("sessions", address, Protect(record.Serialize()));
|
||||||
|
|
||||||
|
public void DeleteSession(SignalProtocolAddress address)
|
||||||
|
{
|
||||||
|
using SqliteConnection conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "DELETE FROM sessions WHERE name = $n AND device = $d;";
|
||||||
|
cmd.Parameters.AddWithValue("$n", address.Name);
|
||||||
|
cmd.Parameters.AddWithValue("$d", address.DeviceId);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<uint> GetSubDeviceSessions(string name)
|
||||||
|
{
|
||||||
|
using SqliteConnection conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT device FROM sessions WHERE name = $n;";
|
||||||
|
cmd.Parameters.AddWithValue("$n", name);
|
||||||
|
var result = new List<uint>();
|
||||||
|
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read()) result.Add((uint)reader.GetInt64(0));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Forgets a peer's learned identities + sessions across all their devices. Used to APPROVE
|
||||||
|
/// a changed identity: after this, the next session re-establishes trust-on-first-use with the new
|
||||||
|
/// key (and the dead sessions tied to the old key are dropped).</summary>
|
||||||
|
public void ResetPeer(string name)
|
||||||
|
{
|
||||||
|
using SqliteConnection conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "DELETE FROM identities WHERE name = $n; DELETE FROM sessions WHERE name = $n;";
|
||||||
|
cmd.Parameters.AddWithValue("$n", name);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wipes all sessions + learned identities (used when unlinking, so a re-link starts fresh
|
||||||
|
/// and never reuses sessions tied to the old identity keys).</summary>
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
using SqliteConnection conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "DELETE FROM sessions; DELETE FROM identities;";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──
|
||||||
|
private void Upsert(string table, SignalProtocolAddress address, byte[] data)
|
||||||
|
{
|
||||||
|
using SqliteConnection conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = $"INSERT OR REPLACE INTO {table} (name, device, data) VALUES ($n, $d, $data);";
|
||||||
|
cmd.Parameters.AddWithValue("$n", address.Name);
|
||||||
|
cmd.Parameters.AddWithValue("$d", address.DeviceId);
|
||||||
|
cmd.Parameters.AddWithValue("$data", data);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[]? Load(string table, SignalProtocolAddress address)
|
||||||
|
{
|
||||||
|
using SqliteConnection conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = $"SELECT data FROM {table} WHERE name = $n AND device = $d;";
|
||||||
|
cmd.Parameters.AddWithValue("$n", address.Name);
|
||||||
|
cmd.Parameters.AddWithValue("$d", address.DeviceId);
|
||||||
|
return cmd.ExecuteScalar() as byte[];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Protect(byte[] plain) => ProtectedData.Protect(plain, Entropy, DataProtectionScope.CurrentUser);
|
||||||
|
private static byte[] Unprotect(byte[] enc) => ProtectedData.Unprotect(enc, Entropy, DataProtectionScope.CurrentUser);
|
||||||
|
|
||||||
|
private SqliteConnection Open()
|
||||||
|
{
|
||||||
|
var conn = new SqliteConnection(_connectionString);
|
||||||
|
conn.Open();
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Wingnal.Protocol.Crypto;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Attachments;
|
||||||
|
|
||||||
|
/// <summary>Thrown when an attachment fails its MAC or digest check, or is malformed.</summary>
|
||||||
|
public sealed class InvalidAttachmentException : Exception
|
||||||
|
{
|
||||||
|
public InvalidAttachmentException(string message) : base(message) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decrypts a Signal attachment blob. Layout on the CDN is
|
||||||
|
/// <c>iv[16] || AES-256-CBC(cipherKey, plaintext+padding) || HMAC-SHA256(macKey, iv||ciphertext)[32]</c>.
|
||||||
|
/// The 64-byte attachment key is <c>cipherKey[32] || macKey[32]</c>; the optional <c>digest</c> is
|
||||||
|
/// SHA-256 over the WHOLE blob. Mirrors libsignal/Signal-Android AttachmentCipherInputStream.
|
||||||
|
/// </summary>
|
||||||
|
public static class AttachmentCipher
|
||||||
|
{
|
||||||
|
private const int IvLength = 16;
|
||||||
|
private const int MacLength = 32;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the digest (if given) and the HMAC, then AES-256-CBC decrypts. If
|
||||||
|
/// <paramref name="plaintextLength"/> is provided (the AttachmentPointer <c>size</c>), the result is
|
||||||
|
/// truncated to it to strip bucket padding.
|
||||||
|
/// </summary>
|
||||||
|
public static byte[] Decrypt(byte[] blob, byte[] combinedKey, byte[]? digest = null, int? plaintextLength = null)
|
||||||
|
{
|
||||||
|
if (combinedKey.Length != 64)
|
||||||
|
throw new InvalidAttachmentException($"attachment key must be 64 bytes, got {combinedKey.Length}");
|
||||||
|
if (blob.Length <= IvLength + MacLength)
|
||||||
|
throw new InvalidAttachmentException("attachment blob too short");
|
||||||
|
|
||||||
|
byte[] cipherKey = combinedKey.AsSpan(0, 32).ToArray();
|
||||||
|
byte[] macKey = combinedKey.AsSpan(32, 32).ToArray();
|
||||||
|
|
||||||
|
// Whole-blob digest (covers iv + ciphertext + mac).
|
||||||
|
if (digest is not null)
|
||||||
|
{
|
||||||
|
byte[] actual = SHA256.HashData(blob);
|
||||||
|
if (!CryptographicOperations.FixedTimeEquals(actual, digest))
|
||||||
|
throw new InvalidAttachmentException("attachment digest mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
int macOffset = blob.Length - MacLength;
|
||||||
|
byte[] theirMac = blob.AsSpan(macOffset, MacLength).ToArray();
|
||||||
|
byte[] ourMac = CryptoPrimitives.HmacSha256(macKey, blob.AsSpan(0, macOffset));
|
||||||
|
if (!CryptographicOperations.FixedTimeEquals(theirMac, ourMac))
|
||||||
|
throw new InvalidAttachmentException("attachment MAC mismatch");
|
||||||
|
|
||||||
|
byte[] iv = blob.AsSpan(0, IvLength).ToArray();
|
||||||
|
byte[] ciphertext = blob.AsSpan(IvLength, macOffset - IvLength).ToArray();
|
||||||
|
byte[] plaintext = CryptoPrimitives.AesCbcDecrypt(cipherKey, iv, ciphertext);
|
||||||
|
|
||||||
|
if (plaintextLength is { } len && len >= 0 && len < plaintext.Length)
|
||||||
|
plaintext = plaintext.AsSpan(0, len).ToArray();
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an encrypted attachment blob the way Signal does (for tests / round-trip verification):
|
||||||
|
/// <c>iv || AES-256-CBC(cipherKey, plaintext) || HMAC(macKey, iv||ct)</c>, returning the blob and
|
||||||
|
/// its SHA-256 digest.
|
||||||
|
/// </summary>
|
||||||
|
public static (byte[] Blob, byte[] Digest) Encrypt(byte[] plaintext, byte[] combinedKey, byte[] iv)
|
||||||
|
{
|
||||||
|
if (combinedKey.Length != 64) throw new ArgumentException("key must be 64 bytes", nameof(combinedKey));
|
||||||
|
if (iv.Length != IvLength) throw new ArgumentException("iv must be 16 bytes", nameof(iv));
|
||||||
|
|
||||||
|
byte[] cipherKey = combinedKey.AsSpan(0, 32).ToArray();
|
||||||
|
byte[] macKey = combinedKey.AsSpan(32, 32).ToArray();
|
||||||
|
|
||||||
|
byte[] ciphertext = CryptoPrimitives.AesCbcEncrypt(cipherKey, iv, plaintext);
|
||||||
|
|
||||||
|
var withoutMac = new byte[IvLength + ciphertext.Length];
|
||||||
|
Array.Copy(iv, 0, withoutMac, 0, IvLength);
|
||||||
|
Array.Copy(ciphertext, 0, withoutMac, IvLength, ciphertext.Length);
|
||||||
|
|
||||||
|
byte[] mac = CryptoPrimitives.HmacSha256(macKey, withoutMac);
|
||||||
|
var blob = new byte[withoutMac.Length + MacLength];
|
||||||
|
Array.Copy(withoutMac, 0, blob, 0, withoutMac.Length);
|
||||||
|
Array.Copy(mac, 0, blob, withoutMac.Length, MacLength);
|
||||||
|
|
||||||
|
return (blob, SHA256.HashData(blob));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using Wingnal.Service.Net;
|
||||||
|
using Wingnal.Service.Protos;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Attachments;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Downloads an <see cref="AttachmentPointer"/> from the Signal CDN and decrypts it. The download is
|
||||||
|
/// GET <c>{cdnUrl}/attachments/{cdnKey|cdnId}</c>; decryption is <see cref="AttachmentCipher"/>
|
||||||
|
/// (AES-256-CBC + HMAC-SHA256 + whole-blob SHA-256 digest). Used for contact/group sync blobs now and
|
||||||
|
/// media later.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AttachmentDownloader : IDisposable
|
||||||
|
{
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly bool _ownsClient;
|
||||||
|
|
||||||
|
/// <param name="http">Optional shared client. If null, a cert-pinned client is created (see
|
||||||
|
/// SHORTCUTS.md re: CDN cert pinning). Auth: the CDN serves attachments without account auth.</param>
|
||||||
|
public AttachmentDownloader(HttpClient? http = null)
|
||||||
|
{
|
||||||
|
if (http is null)
|
||||||
|
{
|
||||||
|
var handler = new SocketsHttpHandler();
|
||||||
|
handler.SslOptions.RemoteCertificateValidationCallback =
|
||||||
|
(sender, cert, chain, errors) => SignalTrust.Validate(sender, cert, chain, errors);
|
||||||
|
_http = new HttpClient(handler);
|
||||||
|
_ownsClient = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
}
|
||||||
|
_http.DefaultRequestHeaders.UserAgent.TryParseAdd(SignalServiceConfig.UserAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Downloads + decrypts the attachment, returning the plaintext bytes.</summary>
|
||||||
|
public async Task<byte[]> DownloadAsync(AttachmentPointer pointer, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (pointer.Key is null || pointer.Key.Length != 64)
|
||||||
|
throw new InvalidAttachmentException("attachment pointer has no/invalid 64-byte key");
|
||||||
|
|
||||||
|
string location = LocationFor(pointer);
|
||||||
|
string url = $"{SignalServiceConfig.CdnUrl(pointer.CdnNumber)}/attachments/{location}";
|
||||||
|
|
||||||
|
using var msg = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
msg.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/octet-stream"));
|
||||||
|
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
throw new HttpRequestException($"attachment download failed: {(int)response.StatusCode} {response.ReasonPhrase}");
|
||||||
|
|
||||||
|
byte[] blob = await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||||
|
byte[]? digest = pointer.HasDigest ? pointer.Digest.ToByteArray() : null;
|
||||||
|
int? size = pointer.HasSize ? (int)pointer.Size : null;
|
||||||
|
return AttachmentCipher.Decrypt(blob, pointer.Key.ToByteArray(), digest, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Raw CDN GET (no attachment decryption) for a cdn-number + object key — used for the
|
||||||
|
/// link'n'sync transfer archive, which is decrypted by <c>BackupReader</c> with a MessageBackupKey
|
||||||
|
/// rather than an attachment key.</summary>
|
||||||
|
public async Task<byte[]> DownloadRawAsync(uint cdnNumber, string cdnKey, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
string url = $"{SignalServiceConfig.CdnUrl(cdnNumber)}/attachments/{cdnKey}";
|
||||||
|
using var msg = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
msg.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/octet-stream"));
|
||||||
|
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
throw new HttpRequestException($"archive download failed: {(int)response.StatusCode} {response.ReasonPhrase}");
|
||||||
|
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string LocationFor(AttachmentPointer pointer)
|
||||||
|
{
|
||||||
|
// cdn2/cdn3 use a string cdnKey; the legacy cdn0 uses a numeric cdnId.
|
||||||
|
if (pointer.AttachmentIdentifierCase == AttachmentPointer.AttachmentIdentifierOneofCase.CdnKey)
|
||||||
|
return pointer.CdnKey;
|
||||||
|
if (pointer.AttachmentIdentifierCase == AttachmentPointer.AttachmentIdentifierOneofCase.CdnId)
|
||||||
|
return pointer.CdnId.ToString();
|
||||||
|
throw new InvalidAttachmentException("attachment pointer has no cdn id/key");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_ownsClient) _http.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using Wingnal.Service.Diagnostics;
|
||||||
|
using Wingnal.Service.Protos;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Attachments;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Downloads + decrypts an inbound <see cref="AttachmentPointer"/> and saves the plaintext to a local
|
||||||
|
/// media file, returning its path (for the chat UI to show/open). Best-effort: returns null on any
|
||||||
|
/// failure so a missing/expired attachment never breaks message display. Reuses the tested
|
||||||
|
/// <see cref="AttachmentDownloader"/> (CDN GET) + <see cref="AttachmentCipher"/> (AES-CBC + HMAC + digest).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AttachmentService
|
||||||
|
{
|
||||||
|
private readonly string _mediaDir;
|
||||||
|
|
||||||
|
public AttachmentService(string? mediaDir = null)
|
||||||
|
{
|
||||||
|
_mediaDir = mediaDir ?? Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal", "media");
|
||||||
|
Directory.CreateDirectory(_mediaDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> SaveAsync(AttachmentPointer pointer, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var downloader = new AttachmentDownloader();
|
||||||
|
byte[] plaintext = await downloader.DownloadAsync(pointer, ct).ConfigureAwait(false);
|
||||||
|
string path = Path.Combine(_mediaDir, FileNameFor(pointer));
|
||||||
|
await File.WriteAllBytesAsync(path, plaintext, ct).ConfigureAwait(false);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
FileLog.Write($"attachment: download failed: {ex.GetType().Name}: {ex.Message}");
|
||||||
|
return null; // show the placeholder; don't break the message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes pre-decrypted bytes to the media folder (test/local helper); returns the path.</summary>
|
||||||
|
public string Save(byte[] plaintext, string extension)
|
||||||
|
{
|
||||||
|
string path = Path.Combine(_mediaDir, Guid.NewGuid().ToString("N") + Normalize(extension));
|
||||||
|
File.WriteAllBytes(path, plaintext);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FileNameFor(AttachmentPointer p)
|
||||||
|
{
|
||||||
|
string ext = !string.IsNullOrEmpty(p.FileName) && Path.HasExtension(p.FileName)
|
||||||
|
? Path.GetExtension(p.FileName)
|
||||||
|
: ExtensionForContentType(p.ContentType);
|
||||||
|
return Guid.NewGuid().ToString("N") + ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtensionForContentType(string? contentType) => contentType switch
|
||||||
|
{
|
||||||
|
"image/jpeg" => ".jpg",
|
||||||
|
"image/png" => ".png",
|
||||||
|
"image/gif" => ".gif",
|
||||||
|
"image/webp" => ".webp",
|
||||||
|
"video/mp4" => ".mp4",
|
||||||
|
"audio/aac" or "audio/mp4" => ".m4a",
|
||||||
|
_ => ".bin",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string Normalize(string ext) => ext.StartsWith('.') ? ext : "." + ext;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Google.Protobuf;
|
||||||
|
using Wingnal.Protocol.Crypto;
|
||||||
|
using Wingnal.Protocol.Curve;
|
||||||
|
using Wingnal.Protocol.State;
|
||||||
|
using Wingnal.Service.Protos;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Crypto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encrypts a linked device's display name to the account's identity key, matching Signal's
|
||||||
|
/// DeviceNameCipher: ECDH to an ephemeral key, HMAC-derived synthetic IV + cipher key, then
|
||||||
|
/// AES-256-CTR. The primary device decrypts this to label us in its linked-devices list.
|
||||||
|
/// </summary>
|
||||||
|
public static class DeviceNameCipher
|
||||||
|
{
|
||||||
|
public static byte[] EncryptDeviceName(string deviceName, IdentityKeyPair identityKeyPair)
|
||||||
|
{
|
||||||
|
byte[] plaintext = Encoding.UTF8.GetBytes(deviceName);
|
||||||
|
ECKeyPair ephemeral = Curve25519.GenerateKeyPair();
|
||||||
|
byte[] masterSecret = Curve25519.CalculateAgreement(identityKeyPair.PublicKey.PublicKey, ephemeral.PrivateKey);
|
||||||
|
|
||||||
|
byte[] syntheticIv = ComputeSyntheticIv(masterSecret, plaintext);
|
||||||
|
byte[] cipherKey = ComputeCipherKey(masterSecret, syntheticIv);
|
||||||
|
// Signal encrypts with a zero CTR IV; the syntheticIv only derives the key and is sent in the proto.
|
||||||
|
byte[] ciphertext = CryptoPrimitives.AesCtr(cipherKey, new byte[16], plaintext);
|
||||||
|
|
||||||
|
var message = new DeviceName
|
||||||
|
{
|
||||||
|
EphemeralPublic = ByteString.CopyFrom(Curve25519.EncodePoint(ephemeral.PublicKey)),
|
||||||
|
SyntheticIv = ByteString.CopyFrom(syntheticIv),
|
||||||
|
Ciphertext = ByteString.CopyFrom(ciphertext),
|
||||||
|
};
|
||||||
|
return message.ToByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Decrypts a serialized DeviceName proto with the identity key, mirroring Signal's
|
||||||
|
/// decrypt (re-derives and verifies the synthetic IV). Returns null if undecryptable/tampered.</summary>
|
||||||
|
public static string? DecryptDeviceName(byte[] serialized, IdentityKeyPair identityKeyPair)
|
||||||
|
{
|
||||||
|
DeviceName message = DeviceName.Parser.ParseFrom(serialized);
|
||||||
|
if (message.EphemeralPublic.IsEmpty || message.SyntheticIv.IsEmpty || message.Ciphertext.IsEmpty)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
byte[] ephemeralPublic = Curve25519.DecodePoint(message.EphemeralPublic.Span);
|
||||||
|
byte[] masterSecret = Curve25519.CalculateAgreement(ephemeralPublic, identityKeyPair.PrivateKey);
|
||||||
|
byte[] syntheticIv = message.SyntheticIv.ToByteArray();
|
||||||
|
|
||||||
|
byte[] cipherKey = ComputeCipherKey(masterSecret, syntheticIv);
|
||||||
|
byte[] plaintext = CryptoPrimitives.AesCtr(cipherKey, new byte[16], message.Ciphertext.ToByteArray());
|
||||||
|
|
||||||
|
byte[] expectedIv = ComputeSyntheticIv(masterSecret, plaintext);
|
||||||
|
if (!CryptographicOperations.FixedTimeEquals(expectedIv, syntheticIv))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return Encoding.UTF8.GetString(plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] ComputeSyntheticIv(byte[] masterSecret, byte[] plaintext)
|
||||||
|
{
|
||||||
|
byte[] keyMaterial = CryptoPrimitives.HmacSha256(masterSecret, "auth"u8);
|
||||||
|
byte[] mac = CryptoPrimitives.HmacSha256(keyMaterial, plaintext);
|
||||||
|
return mac[..16];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] ComputeCipherKey(byte[] masterSecret, byte[] syntheticIv)
|
||||||
|
{
|
||||||
|
byte[] keyMaterial = CryptoPrimitives.HmacSha256(masterSecret, "cipher"u8);
|
||||||
|
return CryptoPrimitives.HmacSha256(keyMaterial, syntheticIv);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Wingnal.Protocol.Crypto;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Crypto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives a recipient's "unidentified access key" from their profile key, used to send sealed-sender
|
||||||
|
/// (metadata-minimized) messages without our own auth credentials. Matches Signal-Android
|
||||||
|
/// <c>UnidentifiedAccess.deriveAccessKeyFrom</c>: the first 16 bytes of AES-256-GCM(profileKey, iv=0¹²,
|
||||||
|
/// plaintext=0¹⁶).
|
||||||
|
/// </summary>
|
||||||
|
public static class UnidentifiedAccess
|
||||||
|
{
|
||||||
|
public static byte[] DeriveAccessKey(byte[] profileKey)
|
||||||
|
{
|
||||||
|
if (profileKey.Length != 32) throw new ArgumentException("profile key must be 32 bytes", nameof(profileKey));
|
||||||
|
byte[] ctAndTag = CryptoPrimitives.AesGcmEncrypt(profileKey, new byte[12], new byte[16]);
|
||||||
|
return ctAndTag.AsSpan(0, 16).ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
namespace Wingnal.Service.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dead-simple append-only log to %LOCALAPPDATA%\Wingnal\wingnal.log for diagnosing live behavior
|
||||||
|
/// (we can't attach a debugger to the deployed app easily). Best-effort; never throws.
|
||||||
|
/// </summary>
|
||||||
|
public static class FileLog
|
||||||
|
{
|
||||||
|
private static readonly object Gate = new();
|
||||||
|
private static readonly string Path = System.IO.Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal", "wingnal.log");
|
||||||
|
|
||||||
|
public static string LogPath => Path;
|
||||||
|
|
||||||
|
/// <summary>Dumps raw bytes next to the log for offline analysis (e.g. an undecryptable envelope).</summary>
|
||||||
|
public static void Dump(string fileName, byte[] bytes)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string dir = System.IO.Path.GetDirectoryName(Path)!;
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
File.WriteAllBytes(System.IO.Path.Combine(dir, fileName), bytes);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Write(string message)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string line = $"{DateTime.Now:HH:mm:ss.fff} {message}{Environment.NewLine}";
|
||||||
|
lock (Gate)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!);
|
||||||
|
File.AppendAllText(Path, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Logging must never break the app.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Wingnal.Service.Groups;
|
||||||
|
|
||||||
|
/// <summary>A group's state after decrypting the storage-service <c>Group</c> blob with the group's
|
||||||
|
/// secret params: plaintext title/roster/revision the app can render.</summary>
|
||||||
|
public sealed record DecryptedGroup(
|
||||||
|
string Title,
|
||||||
|
string? Description,
|
||||||
|
uint Revision,
|
||||||
|
IReadOnlyList<DecryptedGroupMember> Members);
|
||||||
|
|
||||||
|
/// <summary>A decrypted group member: their service id (lowercase UUID string), role, and join revision.</summary>
|
||||||
|
public sealed record DecryptedGroupMember(string ServiceId, bool IsPni, GroupMemberRole Role, uint JoinedAtRevision);
|
||||||
|
|
||||||
|
public enum GroupMemberRole { Unknown = 0, Default = 1, Administrator = 2 }
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using Wingnal.Protocol.ZkGroup;
|
||||||
|
using Wingnal.Service.Protos.Groups;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Groups;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies a storage-service <c>GroupChange.Actions</c> delta to a local <see cref="DecryptedGroup"/>,
|
||||||
|
/// decrypting the change's encrypted member/title fields with the group's secret params. Handles the core
|
||||||
|
/// membership/attribute actions (add/remove/promote members, modify title/description); other action kinds
|
||||||
|
/// (pending/requesting/banned members, access control, timer) are recognised and skipped for now. The read
|
||||||
|
/// half of Phase F — incremental reconciliation of <c>GET /v2/groups/logs</c>.
|
||||||
|
///
|
||||||
|
/// NOTE: server-signature verification of each change is a separate step (<see cref="GroupSignatureVerifier"/>)
|
||||||
|
/// that needs Signal's published server sig key; callers should verify BEFORE applying.
|
||||||
|
/// </summary>
|
||||||
|
public static class GroupChangeApplier
|
||||||
|
{
|
||||||
|
public static DecryptedGroup Apply(DecryptedGroup current, GroupChange.Types.Actions actions, GroupSecretParams gsp)
|
||||||
|
{
|
||||||
|
var members = new List<DecryptedGroupMember>(current.Members);
|
||||||
|
string title = current.Title;
|
||||||
|
string? description = current.Description;
|
||||||
|
uint revision = actions.Version;
|
||||||
|
|
||||||
|
foreach (GroupChange.Types.Actions.Types.AddMemberAction add in actions.AddMembers)
|
||||||
|
{
|
||||||
|
if (add.Added is not { } m) continue;
|
||||||
|
string id = DecryptId(m.UserId.Span, gsp);
|
||||||
|
members.RemoveAll(x => x.ServiceId == id);
|
||||||
|
members.Add(new DecryptedGroupMember(id, IsPni(id), (GroupMemberRole)(int)m.Role, revision));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (GroupChange.Types.Actions.Types.DeleteMemberAction del in actions.DeleteMembers)
|
||||||
|
{
|
||||||
|
string id = DecryptId(del.DeletedUserId.Span, gsp);
|
||||||
|
members.RemoveAll(x => x.ServiceId == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (GroupChange.Types.Actions.Types.ModifyMemberRoleAction mod in actions.ModifyMemberRoles)
|
||||||
|
{
|
||||||
|
string id = DecryptId(mod.UserId.Span, gsp);
|
||||||
|
for (int i = 0; i < members.Count; i++)
|
||||||
|
if (members[i].ServiceId == id)
|
||||||
|
members[i] = members[i] with { Role = (GroupMemberRole)(int)mod.Role };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promoting a pending/requesting member adds them (their userId comes from the presentation/userId field).
|
||||||
|
foreach (var promo in actions.PromoteMembersPendingProfileKey)
|
||||||
|
{
|
||||||
|
if (promo.UserId.IsEmpty) continue; // userId is set in newer change epochs
|
||||||
|
string id = DecryptId(promo.UserId.Span, gsp);
|
||||||
|
if (members.All(x => x.ServiceId != id))
|
||||||
|
members.Add(new DecryptedGroupMember(id, IsPni(id), GroupMemberRole.Default, revision));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actions.ModifyTitle is { } mt && !mt.Title.IsEmpty)
|
||||||
|
title = GroupStateCodec.DecryptBlobTitle(mt.Title.ToByteArray(), gsp);
|
||||||
|
if (actions.ModifyDescription is { } md && !md.Description.IsEmpty)
|
||||||
|
description = GroupStateCodec.DecryptBlobDescription(md.Description.ToByteArray(), gsp);
|
||||||
|
|
||||||
|
return current with { Title = title, Description = description, Revision = revision, Members = members };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DecryptId(ReadOnlySpan<byte> uuidCiphertext, GroupSecretParams gsp) =>
|
||||||
|
GroupStateCodec.ServiceIdString(gsp.DecryptServiceId(UuidCiphertext.Deserialize(uuidCiphertext)));
|
||||||
|
|
||||||
|
private static bool IsPni(string serviceId) => serviceId.StartsWith("PNI:", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using Wingnal.Protocol.ZkGroup.Curve;
|
||||||
|
using Wingnal.Protocol.ZkGroup.Poksho;
|
||||||
|
using Wingnal.Service.Protos.Groups;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Groups;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the server's signature on a <c>GroupChange</c> (the storage service signs the serialized
|
||||||
|
/// <c>actions</c> with its sig key so a client can trust a change it didn't author). Callers must verify
|
||||||
|
/// BEFORE applying a change (<see cref="GroupChangeApplier"/>).
|
||||||
|
///
|
||||||
|
/// The server's sig public key is the <c>sig_public_key</c> field of Signal's published
|
||||||
|
/// <c>ServerPublicParams</c>; obtaining/parsing that production constant is the remaining live-flow step
|
||||||
|
/// (see SHORTCUTS.md). This method takes the already-parsed key so the verification logic itself is testable.
|
||||||
|
/// </summary>
|
||||||
|
public static class GroupSignatureVerifier
|
||||||
|
{
|
||||||
|
public static bool Verify(Ristretto255 serverSigPublicKey, GroupChange change) =>
|
||||||
|
!change.ServerSignature.IsEmpty &&
|
||||||
|
PokshoSignature.Verify(change.ServerSignature.ToByteArray(), serverSigPublicKey, change.Actions.ToByteArray());
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using Wingnal.Protocol.ZkGroup;
|
||||||
|
using Wingnal.Service.Protos.Groups;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Groups;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decrypts a storage-service <c>Group</c> proto into a plaintext <see cref="DecryptedGroup"/> using the
|
||||||
|
/// group's <see cref="GroupSecretParams"/>: member service ids via the UID ciphertext (Phase D2), the title
|
||||||
|
/// via the AES-256-GCM-SIV attribute blob (Phase D1). All member identity fields on the wire are zkgroup
|
||||||
|
/// ciphertexts, so this is the read half of Phase E. Pure/offline (no network).
|
||||||
|
/// </summary>
|
||||||
|
public static class GroupStateCodec
|
||||||
|
{
|
||||||
|
public static DecryptedGroup Decode(Group group, GroupSecretParams gsp)
|
||||||
|
{
|
||||||
|
string title = DecryptBlobTitle(group.Title.ToByteArray(), gsp);
|
||||||
|
string? description = group.Description.IsEmpty
|
||||||
|
? null : DecryptBlobDescription(group.Description.ToByteArray(), gsp);
|
||||||
|
|
||||||
|
var members = new List<DecryptedGroupMember>(group.Members.Count);
|
||||||
|
foreach (Member m in group.Members)
|
||||||
|
{
|
||||||
|
UuidCiphertext ct = UuidCiphertext.Deserialize(m.UserId.Span);
|
||||||
|
ServiceId sid = gsp.DecryptServiceId(ct);
|
||||||
|
members.Add(new DecryptedGroupMember(
|
||||||
|
ServiceIdString(sid), sid.IsPni, (GroupMemberRole)(int)m.Role, m.JoinedAtVersion));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DecryptedGroup(title, description, group.Version, members);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string DecryptBlobTitle(byte[] encrypted, GroupSecretParams gsp)
|
||||||
|
{
|
||||||
|
if (encrypted.Length == 0) return string.Empty;
|
||||||
|
var blob = GroupAttributeBlob.Parser.ParseFrom(gsp.DecryptBlobWithPadding(encrypted));
|
||||||
|
return blob.ContentCase == GroupAttributeBlob.ContentOneofCase.Title ? blob.Title : string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string? DecryptBlobDescription(byte[] encrypted, GroupSecretParams gsp)
|
||||||
|
{
|
||||||
|
if (encrypted.Length == 0) return null;
|
||||||
|
var blob = GroupAttributeBlob.Parser.ParseFrom(gsp.DecryptBlobWithPadding(encrypted));
|
||||||
|
return blob.ContentCase == GroupAttributeBlob.ContentOneofCase.DescriptionText ? blob.DescriptionText : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A zkgroup service id → canonical lowercase UUID string (PNI prefixed with "PNI:").</summary>
|
||||||
|
internal static string ServiceIdString(ServiceId sid)
|
||||||
|
{
|
||||||
|
string uuid = new Guid(sid.RawUuid, bigEndian: true).ToString("D").ToLowerInvariant();
|
||||||
|
return sid.IsPni ? $"PNI:{uuid}" : uuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Wingnal.Service.Account;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Groups;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists the decrypted state of groups the user is in (%LOCALAPPDATA%\Wingnal\groups.db), keyed by the
|
||||||
|
/// lowercase-hex group id. Stores the 32-byte master key (so the group can be re-fetched / re-derived) plus
|
||||||
|
/// the current revision, title, and roster. The master key, title, and roster JSON are encrypted at rest
|
||||||
|
/// with <see cref="LocalCipher"/> (the group id stays plaintext so it can key/route conversations).
|
||||||
|
/// </summary>
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
public sealed class GroupStore
|
||||||
|
{
|
||||||
|
private readonly string _connectionString;
|
||||||
|
private readonly LocalCipher _cipher;
|
||||||
|
|
||||||
|
public GroupStore(string? path = null, LocalCipher? cipher = null)
|
||||||
|
{
|
||||||
|
if (path is null)
|
||||||
|
{
|
||||||
|
string dir = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
path = Path.Combine(dir, "groups.db");
|
||||||
|
}
|
||||||
|
_cipher = cipher ?? LocalCipher.Default();
|
||||||
|
_connectionString = $"Data Source={path}";
|
||||||
|
Initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Initialize()
|
||||||
|
{
|
||||||
|
using SqliteConnection conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText =
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS groups (
|
||||||
|
group_id TEXT PRIMARY KEY,
|
||||||
|
master_key TEXT NOT NULL,
|
||||||
|
revision INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
roster TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Inserts or updates the stored state for a group.</summary>
|
||||||
|
public void Save(string groupId, byte[] masterKey, DecryptedGroup group)
|
||||||
|
{
|
||||||
|
using SqliteConnection conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText =
|
||||||
|
"""
|
||||||
|
INSERT INTO groups (group_id, master_key, revision, title, roster)
|
||||||
|
VALUES ($id, $mk, $rev, $title, $roster)
|
||||||
|
ON CONFLICT(group_id) DO UPDATE SET master_key = $mk, revision = $rev, title = $title, roster = $roster;
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("$id", groupId);
|
||||||
|
cmd.Parameters.AddWithValue("$mk", _cipher.Protect(Convert.ToHexString(masterKey)));
|
||||||
|
cmd.Parameters.AddWithValue("$rev", group.Revision);
|
||||||
|
cmd.Parameters.AddWithValue("$title", _cipher.Protect(group.Title));
|
||||||
|
cmd.Parameters.AddWithValue("$roster", _cipher.Protect(JsonSerializer.Serialize(group.Members)));
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Loads a group's state, or null if not present.</summary>
|
||||||
|
public StoredGroup? Load(string groupId)
|
||||||
|
{
|
||||||
|
using SqliteConnection conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT master_key, revision, title, roster FROM groups WHERE group_id = $id;";
|
||||||
|
cmd.Parameters.AddWithValue("$id", groupId);
|
||||||
|
using SqliteDataReader r = cmd.ExecuteReader();
|
||||||
|
if (!r.Read()) return null;
|
||||||
|
byte[] masterKey = Convert.FromHexString(_cipher.Unprotect(r.GetString(0)));
|
||||||
|
uint revision = (uint)r.GetInt64(1);
|
||||||
|
string title = _cipher.Unprotect(r.GetString(2));
|
||||||
|
var members = JsonSerializer.Deserialize<List<DecryptedGroupMember>>(_cipher.Unprotect(r.GetString(3)))
|
||||||
|
?? new List<DecryptedGroupMember>();
|
||||||
|
return new StoredGroup(groupId, masterKey, new DecryptedGroup(title, null, revision, members));
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<string> AllGroupIds()
|
||||||
|
{
|
||||||
|
using SqliteConnection conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT group_id FROM groups;";
|
||||||
|
var ids = new List<string>();
|
||||||
|
using SqliteDataReader r = cmd.ExecuteReader();
|
||||||
|
while (r.Read()) ids.Add(r.GetString(0));
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
using SqliteConnection conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "DELETE FROM groups;";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
private SqliteConnection Open()
|
||||||
|
{
|
||||||
|
var conn = new SqliteConnection(_connectionString);
|
||||||
|
conn.Open();
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A group's persisted state: id, master key, and decrypted roster/title/revision.</summary>
|
||||||
|
public sealed record StoredGroup(string GroupId, byte[] MasterKey, DecryptedGroup Group);
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using Google.Protobuf;
|
||||||
|
using Wingnal.Service.Net;
|
||||||
|
using Wingnal.Service.Protos.Groups;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Groups;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client for Signal's group storage service (<c>storage.signal.org</c>, GroupsV2). All requests authenticate
|
||||||
|
/// with a per-call Basic header whose username is hex(GroupPublicParams) and password is
|
||||||
|
/// hex(AuthCredentialWithPni presentation) — the server matches the encrypted member identities without
|
||||||
|
/// learning the caller's ACI. TLS pins to the bundled Signal CA via <see cref="SignalTrust"/>.
|
||||||
|
///
|
||||||
|
/// LIVE-UNTESTED (headless): the actual storage-service round-trip. The request/response shapes and auth
|
||||||
|
/// header follow Signal-Android's <c>PushServiceSocket</c> / <c>GroupsV2AuthorizationString</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GroupsApiClient : IDisposable
|
||||||
|
{
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
|
||||||
|
public GroupsApiClient(HttpClient? http = null) => _http = http ?? CreatePinnedClient();
|
||||||
|
|
||||||
|
private static HttpClient CreatePinnedClient()
|
||||||
|
{
|
||||||
|
var handler = new SocketsHttpHandler();
|
||||||
|
handler.SslOptions.RemoteCertificateValidationCallback =
|
||||||
|
(sender, cert, chain, errors) => SignalTrust.Validate(sender, cert, chain, errors);
|
||||||
|
return new HttpClient(handler) { BaseAddress = new Uri(SignalServiceConfig.StorageUrl) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The Basic authorization value for a group call: base64(hex(publicParams):hex(presentation)).</summary>
|
||||||
|
public static string AuthHeader(byte[] groupPublicParams, byte[] authPresentation)
|
||||||
|
{
|
||||||
|
string user = Convert.ToHexString(groupPublicParams).ToLowerInvariant();
|
||||||
|
string pass = Convert.ToHexString(authPresentation).ToLowerInvariant();
|
||||||
|
return Convert.ToBase64String(Encoding.ASCII.GetBytes($"{user}:{pass}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>GET /v2/groups/ — the current encrypted group state (decode with <see cref="GroupStateCodec"/>).</summary>
|
||||||
|
public async Task<Group> GetGroupAsync(byte[] groupPublicParams, byte[] authPresentation, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Get, "/v2/groups/");
|
||||||
|
req.Headers.Authorization = new AuthenticationHeaderValue("Basic", AuthHeader(groupPublicParams, authPresentation));
|
||||||
|
using HttpResponseMessage resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
|
||||||
|
await EnsureOkAsync(resp).ConfigureAwait(false);
|
||||||
|
byte[] body = await resp.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||||
|
return GroupResponse.Parser.ParseFrom(body).Group;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>GET /v2/groups/logs/{fromRevision} — incremental changes from a known revision.</summary>
|
||||||
|
public async Task<GroupChanges> GetGroupLogsAsync(byte[] groupPublicParams, byte[] authPresentation,
|
||||||
|
uint fromRevision, uint maxSupportedChangeEpoch = 6, bool includeFirstState = true, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
string path = $"/v2/groups/logs/{fromRevision}?maxSupportedChangeEpoch={maxSupportedChangeEpoch}" +
|
||||||
|
$"&includeFirstState={includeFirstState.ToString().ToLowerInvariant()}&includeLastState=false";
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Get, path);
|
||||||
|
req.Headers.Authorization = new AuthenticationHeaderValue("Basic", AuthHeader(groupPublicParams, authPresentation));
|
||||||
|
using HttpResponseMessage resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
|
||||||
|
await EnsureOkAsync(resp).ConfigureAwait(false);
|
||||||
|
byte[] body = await resp.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||||
|
return GroupChanges.Parser.ParseFrom(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>PATCH /v2/groups/ — apply a group change; returns the server's signed change + new state.</summary>
|
||||||
|
public async Task<GroupChangeResponse> PatchGroupAsync(byte[] groupPublicParams, byte[] authPresentation,
|
||||||
|
GroupChange.Types.Actions actions, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Patch, "/v2/groups/")
|
||||||
|
{
|
||||||
|
Content = new ByteArrayContent(actions.ToByteArray())
|
||||||
|
{ Headers = { ContentType = new MediaTypeHeaderValue("application/x-protobuf") } },
|
||||||
|
};
|
||||||
|
req.Headers.Authorization = new AuthenticationHeaderValue("Basic", AuthHeader(groupPublicParams, authPresentation));
|
||||||
|
using HttpResponseMessage resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
|
||||||
|
await EnsureOkAsync(resp).ConfigureAwait(false);
|
||||||
|
byte[] body = await resp.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||||
|
return GroupChangeResponse.Parser.ParseFrom(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task EnsureOkAsync(HttpResponseMessage resp)
|
||||||
|
{
|
||||||
|
if (resp.IsSuccessStatusCode) return;
|
||||||
|
string reason = resp.StatusCode == HttpStatusCode.Forbidden
|
||||||
|
? " (403 — credential presentation rejected or not a member)" : "";
|
||||||
|
string body = "";
|
||||||
|
try { body = await resp.Content.ReadAsStringAsync().ConfigureAwait(false); } catch { /* ignore */ }
|
||||||
|
throw new GroupsApiException((int)resp.StatusCode, $"group storage request failed: {(int)resp.StatusCode}{reason} {body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _http.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A non-success response from the group storage service (403 = rejected presentation / not a member).</summary>
|
||||||
|
public sealed class GroupsApiException : Exception
|
||||||
|
{
|
||||||
|
public int StatusCode { get; }
|
||||||
|
public GroupsApiException(int statusCode, string message) : base(message) => StatusCode = statusCode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Wingnal.Protocol.Curve;
|
||||||
|
using Wingnal.Protocol.State;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Keys;
|
||||||
|
|
||||||
|
/// <summary>Generates the prekeys a freshly linked device must register with the server.</summary>
|
||||||
|
public static class PreKeyHelper
|
||||||
|
{
|
||||||
|
/// <summary>A 14-bit Signal registration id (1..16383).</summary>
|
||||||
|
public static uint GenerateRegistrationId() => (uint)RandomNumberGenerator.GetInt32(1, 16384);
|
||||||
|
|
||||||
|
/// <summary>A signed prekey whose public key is signed with the given identity private key.</summary>
|
||||||
|
public static SignedPreKeyRecord GenerateSignedPreKey(byte[] identityPrivateKey, uint id)
|
||||||
|
{
|
||||||
|
ECKeyPair keyPair = Curve25519.GenerateKeyPair();
|
||||||
|
byte[] signature = XEd25519.CalculateSignature(
|
||||||
|
identityPrivateKey, Curve25519.EncodePoint(keyPair.PublicKey), RandomNumberGenerator.GetBytes(64));
|
||||||
|
return new SignedPreKeyRecord(id, keyPair, signature, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A last-resort ML-KEM prekey signed with the given identity private key.</summary>
|
||||||
|
public static KyberPreKeyRecord GenerateKyberPreKey(byte[] identityPrivateKey, uint id)
|
||||||
|
{
|
||||||
|
KyberKeyPair keyPair = Kyber.GenerateKeyPair();
|
||||||
|
byte[] signature = XEd25519.CalculateSignature(
|
||||||
|
identityPrivateKey, KemKeySerialization.Serialize(keyPair.PublicKey), RandomNumberGenerator.GetBytes(64));
|
||||||
|
return new KyberPreKeyRecord(id, keyPair, signature, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A batch of one-time prekeys, ids starting at <paramref name="startId"/>.</summary>
|
||||||
|
public static List<PreKeyRecord> GenerateOneTimePreKeys(uint startId, int count)
|
||||||
|
{
|
||||||
|
var result = new List<PreKeyRecord>(count);
|
||||||
|
for (uint i = 0; i < count; i++)
|
||||||
|
result.Add(PreKeyRecord.Generate(startId + i));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using Wingnal.Protocol.Curve;
|
||||||
|
using Wingnal.Protocol.State;
|
||||||
|
using Wingnal.Service.Account;
|
||||||
|
using Wingnal.Service.Crypto;
|
||||||
|
using Wingnal.Service.Keys;
|
||||||
|
using Wingnal.Service.Net;
|
||||||
|
using Wingnal.Service.Protos;
|
||||||
|
using Wingnal.Service.Provisioning;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Linking;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Orchestrates the full secondary-device link: drive the provisioning handshake, generate this
|
||||||
|
/// device's credentials + prekeys, register with the service, and persist the resulting account.
|
||||||
|
/// </summary>
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
public sealed class LinkingManager
|
||||||
|
{
|
||||||
|
private const uint SignedPreKeyId = 1;
|
||||||
|
private const uint KyberPreKeyId = 1;
|
||||||
|
|
||||||
|
private readonly AccountStore _accountStore;
|
||||||
|
private readonly SignalRestClient _restClient;
|
||||||
|
private readonly string _deviceName;
|
||||||
|
|
||||||
|
public LinkingManager(AccountStore accountStore, SignalRestClient restClient, string deviceName = "Wingnal")
|
||||||
|
{
|
||||||
|
_accountStore = accountStore;
|
||||||
|
_restClient = restClient;
|
||||||
|
_deviceName = deviceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the link flow. <paramref name="onQrReady"/> is invoked with the QR URI to display; the
|
||||||
|
/// returned account is also persisted to the <see cref="AccountStore"/>.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<SignalAccount> LinkAsync(Func<string, Task> onQrReady, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var provisioning = new ProvisioningManager();
|
||||||
|
// Advertise link+sync so the primary offers a message-history transfer archive (docs/SYNC.md).
|
||||||
|
ProvisionMessage message = await provisioning
|
||||||
|
.LinkAsync(onQrReady, ct, new[] { ProvisioningManager.LinkAndSyncCapability })
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
string password = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
|
||||||
|
uint aciRegistrationId = PreKeyHelper.GenerateRegistrationId();
|
||||||
|
uint pniRegistrationId = PreKeyHelper.GenerateRegistrationId();
|
||||||
|
|
||||||
|
byte[] aciIdentityPrivate = message.AciIdentityKeyPrivate.ToByteArray();
|
||||||
|
byte[] aciIdentityPublic = message.AciIdentityKeyPublic.ToByteArray();
|
||||||
|
byte[] pniIdentityPrivate = message.PniIdentityKeyPrivate.ToByteArray();
|
||||||
|
byte[] pniIdentityPublic = message.PniIdentityKeyPublic.ToByteArray();
|
||||||
|
|
||||||
|
SignedPreKeyRecord aciSigned = PreKeyHelper.GenerateSignedPreKey(aciIdentityPrivate, SignedPreKeyId);
|
||||||
|
KyberPreKeyRecord aciKyber = PreKeyHelper.GenerateKyberPreKey(aciIdentityPrivate, KyberPreKeyId);
|
||||||
|
SignedPreKeyRecord pniSigned = PreKeyHelper.GenerateSignedPreKey(pniIdentityPrivate, SignedPreKeyId);
|
||||||
|
KyberPreKeyRecord pniKyber = PreKeyHelper.GenerateKyberPreKey(pniIdentityPrivate, KyberPreKeyId);
|
||||||
|
|
||||||
|
byte[] encryptedName = DeviceNameCipher.EncryptDeviceName(
|
||||||
|
_deviceName, new IdentityKeyPair(IdentityKey.Decode(aciIdentityPublic), aciIdentityPrivate));
|
||||||
|
|
||||||
|
var request = new LinkDeviceRequest(
|
||||||
|
VerificationCode: message.ProvisioningCode,
|
||||||
|
AccountAttributes: new AccountAttributes(
|
||||||
|
FetchesMessages: true,
|
||||||
|
RegistrationId: aciRegistrationId,
|
||||||
|
PniRegistrationId: pniRegistrationId,
|
||||||
|
Name: Convert.ToBase64String(encryptedName),
|
||||||
|
Capabilities: new AccountCapabilities()),
|
||||||
|
AciSignedPreKey: ToSignedEntity(aciSigned),
|
||||||
|
PniSignedPreKey: ToSignedEntity(pniSigned),
|
||||||
|
AciPqLastResortPreKey: ToKyberEntity(aciKyber),
|
||||||
|
PniPqLastResortPreKey: ToKyberEntity(pniKyber));
|
||||||
|
|
||||||
|
LinkDeviceResponse response = await _restClient
|
||||||
|
.LinkDeviceAsync(message.Number, password, request, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var account = new SignalAccount
|
||||||
|
{
|
||||||
|
Aci = response.Uuid,
|
||||||
|
Pni = response.Pni,
|
||||||
|
Number = message.Number,
|
||||||
|
DeviceId = response.DeviceId,
|
||||||
|
Password = password,
|
||||||
|
AciRegistrationId = aciRegistrationId,
|
||||||
|
PniRegistrationId = pniRegistrationId,
|
||||||
|
AciIdentityPublic = aciIdentityPublic,
|
||||||
|
AciIdentityPrivate = aciIdentityPrivate,
|
||||||
|
PniIdentityPublic = pniIdentityPublic,
|
||||||
|
PniIdentityPrivate = pniIdentityPrivate,
|
||||||
|
ProfileKey = message.ProfileKey.ToByteArray(),
|
||||||
|
AciPreKeys = ToMaterial(aciSigned, aciKyber),
|
||||||
|
PniPreKeys = ToMaterial(pniSigned, pniKyber),
|
||||||
|
// Present only if the primary accepted link+sync; used once to import message history.
|
||||||
|
EphemeralBackupKey = message.HasEphemeralBackupKey && message.EphemeralBackupKey.Length == 32
|
||||||
|
? message.EphemeralBackupKey.ToByteArray()
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register a batch of one-time prekeys so inbound sessions get per-message forward secrecy
|
||||||
|
// instead of always falling back to the signed prekey. Best-effort: the link already succeeded,
|
||||||
|
// so a failure here just leaves us on the last-resort prekeys (prior behavior).
|
||||||
|
try
|
||||||
|
{
|
||||||
|
List<PreKeyRecord> oneTime = PreKeyHelper.GenerateOneTimePreKeys(startId: 1, count: 100);
|
||||||
|
await _restClient.UploadPreKeysAsync("aci", new SetKeysRequest
|
||||||
|
{
|
||||||
|
PreKeys = oneTime.Select(p => new PreKeyEntity
|
||||||
|
{
|
||||||
|
KeyId = p.Id,
|
||||||
|
PublicKey = Convert.ToBase64String(Curve25519.EncodePoint(p.KeyPair.PublicKey)),
|
||||||
|
}).ToArray(),
|
||||||
|
}, account.BasicAuthToken(), ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
account.AciOneTimePreKeys = oneTime
|
||||||
|
.Select(p => new OneTimePreKey { Id = p.Id, Public = p.KeyPair.PublicKey, Private = p.KeyPair.PrivateKey })
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Non-fatal — fall back to last-resort signed/kyber prekeys.
|
||||||
|
}
|
||||||
|
|
||||||
|
_accountStore.Save(account);
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SignedPreKeyEntity ToSignedEntity(SignedPreKeyRecord record) => new(
|
||||||
|
record.Id,
|
||||||
|
Convert.ToBase64String(Curve25519.EncodePoint(record.KeyPair.PublicKey)),
|
||||||
|
Convert.ToBase64String(record.Signature));
|
||||||
|
|
||||||
|
private static KyberPreKeyEntity ToKyberEntity(KyberPreKeyRecord record) => new(
|
||||||
|
record.Id,
|
||||||
|
Convert.ToBase64String(KemKeySerialization.Serialize(record.KeyPair.PublicKey)),
|
||||||
|
Convert.ToBase64String(record.Signature));
|
||||||
|
|
||||||
|
private static RegisteredPreKeys ToMaterial(SignedPreKeyRecord signed, KyberPreKeyRecord kyber) => new()
|
||||||
|
{
|
||||||
|
SignedPreKeyId = signed.Id,
|
||||||
|
SignedPreKeyPublic = signed.KeyPair.PublicKey,
|
||||||
|
SignedPreKeyPrivate = signed.KeyPair.PrivateKey,
|
||||||
|
SignedPreKeySignature = signed.Signature,
|
||||||
|
KyberPreKeyId = kyber.Id,
|
||||||
|
KyberPreKeyPublic = kyber.KeyPair.PublicKey,
|
||||||
|
KyberPreKeyPrivate = kyber.KeyPair.PrivateKey,
|
||||||
|
KyberPreKeySignature = kyber.Signature,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
using Wingnal.Protocol.Groups;
|
||||||
|
using Wingnal.Protocol.State;
|
||||||
|
using Wingnal.Service.Account;
|
||||||
|
using Wingnal.Service.Diagnostics;
|
||||||
|
using Wingnal.Service.Net;
|
||||||
|
using Wingnal.Service.Protos;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Messaging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Connects the authenticated chat WebSocket and delivers incoming 1:1 texts. The server pushes
|
||||||
|
/// queued messages as PUT /api/v1/message (Envelope body); we ack each with 200 and answer
|
||||||
|
/// keepalives. Decryption failures are surfaced via <paramref name="onError"/> but still acked so the
|
||||||
|
/// queue drains (see SHORTCUTS.md).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChatReceiver
|
||||||
|
{
|
||||||
|
private readonly SignalAccount _account;
|
||||||
|
private readonly MessageDecryptor _decryptor;
|
||||||
|
|
||||||
|
public ChatReceiver(SignalAccount account, ISignalProtocolStore store,
|
||||||
|
Account.ProfileKeyStore? profileKeys = null, ISenderKeyStore? senderKeys = null)
|
||||||
|
{
|
||||||
|
_account = account;
|
||||||
|
_decryptor = new MessageDecryptor(store, profileKeys, senderKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReceiveAsync(
|
||||||
|
Func<DecryptedMessage, Task> onMessage,
|
||||||
|
Action<Envelope, Exception>? onError,
|
||||||
|
CancellationToken ct,
|
||||||
|
Func<SyncMessage, Task>? onSync = null,
|
||||||
|
Func<string, ReceiptMessage, Task>? onReceipt = null,
|
||||||
|
Func<string, TypingMessage, Task>? onTyping = null)
|
||||||
|
{
|
||||||
|
using var socket = new SignalWebSocket();
|
||||||
|
var uri = new Uri($"{SignalServiceConfig.WebSocketUrl}{SignalServiceConfig.ChatWebSocketPath}");
|
||||||
|
var headers = new Dictionary<string, string> { ["Authorization"] = $"Basic {_account.BasicAuthToken()}" };
|
||||||
|
|
||||||
|
FileLog.Write($"chat: connecting login={_account.Aci}.{_account.DeviceId} (Authorization header)");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await socket.ConnectAsync(uri, headers, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
FileLog.Write($"chat: CONNECT FAILED {ex.GetType().Name}: {ex.Message}");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
FileLog.Write("chat: connected (websocket upgrade OK)");
|
||||||
|
|
||||||
|
using var loopCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
Task keepAlive = KeepAliveLoopAsync(socket, loopCts.Token);
|
||||||
|
|
||||||
|
int frames = 0;
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
WebSocketRequestMessage? request = await socket.ReadRequestAsync(ct).ConfigureAwait(false);
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
FileLog.Write($"chat: socket closed after {frames} frame(s). {socket.CloseReason}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
frames++;
|
||||||
|
bool isMessage = request.Verb == "PUT" && request.Path == "/api/v1/message";
|
||||||
|
FileLog.Write($"chat: frame #{frames} verb={request.Verb} path={request.Path} bodyLen={request.Body?.Length ?? 0}");
|
||||||
|
|
||||||
|
if (!isMessage)
|
||||||
|
{
|
||||||
|
// keepalive, queue-empty, etc. — ack immediately.
|
||||||
|
await socket.SendResponseAsync(request.Id, 200, "OK", ct).ConfigureAwait(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Envelope envelope = Envelope.Parser.ParseFrom(request.Body);
|
||||||
|
FileLog.Write($"chat: envelope type={envelope.Type} from={envelope.SourceServiceId}.{envelope.SourceDeviceId} contentLen={envelope.Content?.Length ?? 0}");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
MessageDecryptor.Result result = _decryptor.DecryptEnvelope(envelope);
|
||||||
|
// Ack only after a successful decrypt, so a message we can't yet handle is redelivered.
|
||||||
|
await socket.SendResponseAsync(request.Id, 200, "OK", ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (result.Message is { } message)
|
||||||
|
{
|
||||||
|
FileLog.Write($"chat: decrypted text from {message.PeerServiceId} outgoing={message.Outgoing}");
|
||||||
|
await onMessage(message).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onSync is not null && result.Content?.SyncMessage is { } sync)
|
||||||
|
{
|
||||||
|
FileLog.Write("chat: handling sync message");
|
||||||
|
await onSync(sync).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onReceipt is not null && result.Content?.ReceiptMessage is { } receipt)
|
||||||
|
{
|
||||||
|
FileLog.Write($"chat: {receipt.Type} receipt from {result.Sender} for {receipt.Timestamp.Count} message(s)");
|
||||||
|
await onReceipt(result.Sender, receipt).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onTyping is not null && result.Content?.TypingMessage is { } typing)
|
||||||
|
{
|
||||||
|
FileLog.Write($"chat: typing {typing.Action} from {result.Sender}");
|
||||||
|
await onTyping(result.Sender, typing).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.Message is null && result.Content is null)
|
||||||
|
FileLog.Write($"chat: decrypted, nothing surfaced (type={envelope.Type})");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
FileLog.Dump($"failed-envelope-{frames}.bin", request.Body!.ToByteArray());
|
||||||
|
FileLog.Write($"chat: DECRYPT FAILED type={envelope.Type} (not acked, will redeliver):{Environment.NewLine}{ex}");
|
||||||
|
onError?.Invoke(envelope, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loopCts.Cancel();
|
||||||
|
try { await keepAlive.ConfigureAwait(false); } catch (OperationCanceledException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task KeepAliveLoopAsync(SignalWebSocket socket, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ulong id = 1;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(30), ct).ConfigureAwait(false);
|
||||||
|
await socket.SendKeepAliveAsync(id++, ct).ConfigureAwait(false);
|
||||||
|
FileLog.Write("chat: sent keepalive");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
catch (Exception ex) { FileLog.Write($"chat: keepalive stopped: {ex.GetType().Name}: {ex.Message}"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Wingnal.Service.Protos;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Messaging;
|
||||||
|
|
||||||
|
/// <summary>One parsed contact from a contacts-sync blob: the record plus its inline avatar bytes (if
|
||||||
|
/// any).</summary>
|
||||||
|
public sealed record ContactRecord(ContactDetails Details, byte[]? Avatar);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the decrypted contacts-sync blob. The blob is a flat stream of records, each:
|
||||||
|
/// <c>[varint length][ContactDetails protobuf]</c>, immediately followed by <c>[avatar.length bytes]</c>
|
||||||
|
/// when the record declares an avatar. Mirrors Signal's DeviceContactsInputStream.
|
||||||
|
/// </summary>
|
||||||
|
public static class ContactRecordStream
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<ContactRecord> Parse(byte[] blob)
|
||||||
|
{
|
||||||
|
var result = new List<ContactRecord>();
|
||||||
|
using var stream = new MemoryStream(blob, writable: false);
|
||||||
|
|
||||||
|
while (stream.Position < stream.Length)
|
||||||
|
{
|
||||||
|
ContactDetails details = ContactDetails.Parser.ParseDelimitedFrom(stream);
|
||||||
|
|
||||||
|
byte[]? avatar = null;
|
||||||
|
if (details.Avatar is { } a && a.Length > 0)
|
||||||
|
{
|
||||||
|
avatar = new byte[a.Length];
|
||||||
|
int read = stream.Read(avatar, 0, avatar.Length);
|
||||||
|
if (read != avatar.Length)
|
||||||
|
throw new InvalidDataException("truncated contact avatar in sync blob");
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(new ContactRecord(details, avatar));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace Wingnal.Service.Messaging;
|
||||||
|
|
||||||
|
/// <summary>A decrypted 1:1 text message (incoming, or a synced transcript of one we sent). When the
|
||||||
|
/// message carried media, <see cref="Attachment"/> is the first attachment pointer (download separately).</summary>
|
||||||
|
public sealed record DecryptedMessage(
|
||||||
|
string PeerServiceId,
|
||||||
|
uint SenderDeviceId,
|
||||||
|
string Body,
|
||||||
|
long Timestamp,
|
||||||
|
bool Outgoing)
|
||||||
|
{
|
||||||
|
public Protos.AttachmentPointer? Attachment { get; init; }
|
||||||
|
|
||||||
|
/// <summary>For a group (GroupsV2) message, the lowercase-hex group identifier this belongs to; null for
|
||||||
|
/// a 1:1 message. When set, the conversation is keyed by the group rather than by <see cref="PeerServiceId"/>.</summary>
|
||||||
|
public string? GroupId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>For a group message, the 32-byte group master key (from GroupContextV2) — persisted so the
|
||||||
|
/// group can later be fetched/decrypted from the storage service.</summary>
|
||||||
|
public byte[]? GroupMasterKey { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Wingnal.Protocol.Groups;
|
||||||
|
using Wingnal.Protocol.State;
|
||||||
|
using Wingnal.Protocol.ZkGroup;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Messaging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase G1 (receive-only groups): wires the Sender Key messaging primitive into the receive path. Processes
|
||||||
|
/// an inbound Sender Key Distribution Message (so we can decrypt that sender's future group messages),
|
||||||
|
/// decrypts an inbound group Sender Key message to its plaintext <c>Content</c>, and derives the 32-byte
|
||||||
|
/// group identifier from a group master key (to route the message into the right group thread). No zkgroup
|
||||||
|
/// credential machinery is needed to *receive* — only the group-id derivation (<see cref="GroupSecretParams"/>).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GroupMessageProcessor
|
||||||
|
{
|
||||||
|
private readonly GroupSessionBuilder _builder;
|
||||||
|
private readonly GroupSessionCipher _cipher;
|
||||||
|
|
||||||
|
public GroupMessageProcessor(ISenderKeyStore store)
|
||||||
|
{
|
||||||
|
_builder = new GroupSessionBuilder(store);
|
||||||
|
_cipher = new GroupSessionCipher(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Installs a sender's distribution (their sender key) so their group messages can be decrypted.</summary>
|
||||||
|
public void ProcessDistribution(SignalProtocolAddress sender, byte[] skdmBytes) =>
|
||||||
|
_builder.Process(sender, SenderKeyDistributionMessage.Parse(skdmBytes));
|
||||||
|
|
||||||
|
/// <summary>Decrypts a received group Sender Key message to its (still padding-wrapped) plaintext.</summary>
|
||||||
|
public byte[] DecryptGroupMessage(SignalProtocolAddress sender, byte[] senderKeyMessageBytes) =>
|
||||||
|
_cipher.Decrypt(sender, SenderKeyMessage.Parse(senderKeyMessageBytes));
|
||||||
|
|
||||||
|
/// <summary>The lowercase-hex group identifier derived from a 32-byte group master key
|
||||||
|
/// (<c>GroupContextV2.masterKey</c>), used to key a group conversation.</summary>
|
||||||
|
public static string GroupIdHex(byte[] masterKey) =>
|
||||||
|
Convert.ToHexString(GroupSecretParams.DeriveFromMasterKey(masterKey).GroupIdentifier).ToLowerInvariant();
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Google.Protobuf;
|
||||||
|
using Wingnal.Protocol.Groups;
|
||||||
|
using Wingnal.Protocol.State;
|
||||||
|
using Wingnal.Service.Protos;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Messaging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase G2 (send) crypto assembly: builds the one group ciphertext that every member decrypts. The body is
|
||||||
|
/// encrypted ONCE with our group Sender Key (<see cref="GroupSessionCipher"/>); the caller then fans the
|
||||||
|
/// resulting <c>SenderKeyMessage</c> out to each member device wrapped as sealed-sender (type 7), after
|
||||||
|
/// distributing our <c>SenderKeyDistributionMessage</c> (1:1, sealed) to members who don't have our key yet.
|
||||||
|
/// The fan-out/transport itself lives in the live send path; this type is the offline-testable core.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GroupSendBuilder
|
||||||
|
{
|
||||||
|
private readonly SignalProtocolAddress _self;
|
||||||
|
private readonly Guid _distributionId;
|
||||||
|
private readonly GroupSessionBuilder _builder;
|
||||||
|
private readonly GroupSessionCipher _cipher;
|
||||||
|
|
||||||
|
public GroupSendBuilder(ISenderKeyStore store, SignalProtocolAddress self, Guid distributionId)
|
||||||
|
{
|
||||||
|
_self = self;
|
||||||
|
_distributionId = distributionId;
|
||||||
|
_builder = new GroupSessionBuilder(store);
|
||||||
|
_cipher = new GroupSessionCipher(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates (or recreates) our sender key and returns the distribution message to send 1:1 to
|
||||||
|
/// members so they can decrypt our group messages.</summary>
|
||||||
|
public SenderKeyDistributionMessage CreateDistribution() => _builder.Create(_self, _distributionId);
|
||||||
|
|
||||||
|
/// <summary>Encrypts a group <see cref="Content"/> into the wire <c>SenderKeyMessage</c> bytes that go,
|
||||||
|
/// once, to every member device (the caller attaches the GroupContextV2 to the Content beforehand).</summary>
|
||||||
|
public byte[] EncryptMessage(Content content)
|
||||||
|
{
|
||||||
|
byte[] padded = MessagePadding.Add(content.ToByteArray());
|
||||||
|
return _cipher.Encrypt(_self, _distributionId, padded).Serialize();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
using Wingnal.Protocol.Groups;
|
||||||
|
using Wingnal.Protocol.Messages;
|
||||||
|
using Wingnal.Protocol.Ratchet;
|
||||||
|
using Wingnal.Protocol.State;
|
||||||
|
using Wingnal.Service.Account;
|
||||||
|
using Wingnal.Service.Protos;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Messaging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decrypts an unsealed (authenticated) <see cref="Envelope"/> into a 1:1 text. Handles
|
||||||
|
/// PREKEY_MESSAGE (establishes the session) and DOUBLE_RATCHET envelopes; returns null for envelope
|
||||||
|
/// types or content kinds we don't surface yet (receipts, sealed sender, non-text sync, etc.).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MessageDecryptor
|
||||||
|
{
|
||||||
|
private readonly ISignalProtocolStore _store;
|
||||||
|
private readonly Account.ProfileKeyStore? _profileKeys;
|
||||||
|
private readonly ISenderKeyStore? _senderKeys;
|
||||||
|
|
||||||
|
public MessageDecryptor(ISignalProtocolStore store, Account.ProfileKeyStore? profileKeys = null,
|
||||||
|
ISenderKeyStore? senderKeys = null)
|
||||||
|
{
|
||||||
|
_store = store;
|
||||||
|
_profileKeys = profileKeys;
|
||||||
|
_senderKeys = senderKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The full result of decrypting an envelope: the sender, the parsed <see cref="Content"/>
|
||||||
|
/// (null for envelope types we don't session-decrypt), and the surfaced 1:1 text (if any).</summary>
|
||||||
|
public sealed record Result(string Sender, uint SenderDevice, Content? Content, DecryptedMessage? Message);
|
||||||
|
|
||||||
|
/// <summary>Surfaced-text-only view, kept for callers/tests that just want the chat bubble.</summary>
|
||||||
|
public DecryptedMessage? Decrypt(Envelope envelope) => DecryptEnvelope(envelope).Message;
|
||||||
|
|
||||||
|
public Result DecryptEnvelope(Envelope envelope)
|
||||||
|
{
|
||||||
|
string sender = ResolveServiceId(envelope.SourceServiceId, envelope.SourceServiceIdBinary);
|
||||||
|
uint senderDevice = envelope.SourceDeviceId;
|
||||||
|
byte[] ciphertext = envelope.Content.ToByteArray();
|
||||||
|
bool isPreKey;
|
||||||
|
|
||||||
|
switch (envelope.Type)
|
||||||
|
{
|
||||||
|
case Envelope.Types.Type.PrekeyMessage:
|
||||||
|
isPreKey = true;
|
||||||
|
break;
|
||||||
|
case Envelope.Types.Type.DoubleRatchet:
|
||||||
|
isPreKey = false;
|
||||||
|
break;
|
||||||
|
case Envelope.Types.Type.UnidentifiedSender:
|
||||||
|
// Sealed sender: unwrap to the real sender + inner ciphertext, then decrypt as usual.
|
||||||
|
SealedSenderDecryptor.Unsealed u = SealedSenderDecryptor.Decrypt(
|
||||||
|
ciphertext, _store.GetIdentityKeyPair(), DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||||
|
sender = u.SenderUuid;
|
||||||
|
senderDevice = u.SenderDevice;
|
||||||
|
ciphertext = u.Content;
|
||||||
|
if (u.CiphertextType == 7) // 7 = group sender-key (GroupsV2 G1 receive)
|
||||||
|
return DecryptGroupSenderKey(sender, senderDevice, ciphertext, envelope);
|
||||||
|
isPreKey = u.CiphertextType == 1; // PREKEY_MESSAGE
|
||||||
|
if (u.CiphertextType is not (1 or 2)) // 2 = MESSAGE; 8 = plaintext
|
||||||
|
return new Result(sender, senderDevice, null, null);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return new Result(sender, senderDevice, null, null); // receipts, etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
var address = new SignalProtocolAddress(sender, senderDevice);
|
||||||
|
var cipher = new SessionCipher(_store, _store, _store, _store, _store, address);
|
||||||
|
byte[] padded = isPreKey
|
||||||
|
? cipher.DecryptPreKeyMessage(PreKeySignalMessage.Parse(ciphertext))
|
||||||
|
: cipher.DecryptSignalMessage(SignalMessage.Parse(ciphertext));
|
||||||
|
|
||||||
|
byte[] plaintext = StripPadding(padded);
|
||||||
|
var content = Content.Parser.ParseFrom(plaintext);
|
||||||
|
|
||||||
|
// Capture the sender's profile key so we can later send THEM sealed-sender messages.
|
||||||
|
if (_profileKeys is not null && content.DataMessage?.ProfileKey is { Length: 32 } pk)
|
||||||
|
_profileKeys.Store(sender, pk.ToByteArray());
|
||||||
|
|
||||||
|
// Install any group sender key the peer distributed (so their future group messages decrypt).
|
||||||
|
ProcessDistributionIfPresent(content, sender, senderDevice);
|
||||||
|
|
||||||
|
DecryptedMessage? message = SurfaceText(content, sender, senderDevice, envelope);
|
||||||
|
return new Result(sender, senderDevice, content, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Decrypts a group (Sender Key) message that arrived as a sealed-sender SENDERKEY envelope,
|
||||||
|
/// and surfaces it routed to its group thread. Requires a sender-key store; otherwise the message is
|
||||||
|
/// dropped (we couldn't have its sender key without one).</summary>
|
||||||
|
private Result DecryptGroupSenderKey(string sender, uint senderDevice, byte[] senderKeyMessage, Envelope envelope)
|
||||||
|
{
|
||||||
|
if (_senderKeys is null) return new Result(sender, senderDevice, null, null);
|
||||||
|
var address = new SignalProtocolAddress(sender, senderDevice);
|
||||||
|
byte[] padded = new GroupMessageProcessor(_senderKeys).DecryptGroupMessage(address, senderKeyMessage);
|
||||||
|
var content = Content.Parser.ParseFrom(StripPadding(padded));
|
||||||
|
|
||||||
|
if (_profileKeys is not null && content.DataMessage?.ProfileKey is { Length: 32 } pk)
|
||||||
|
_profileKeys.Store(sender, pk.ToByteArray());
|
||||||
|
|
||||||
|
DecryptedMessage? message = SurfaceText(content, sender, senderDevice, envelope);
|
||||||
|
return new Result(sender, senderDevice, content, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ProcessDistributionIfPresent(Content content, string sender, uint senderDevice)
|
||||||
|
{
|
||||||
|
if (_senderKeys is null || content.SenderKeyDistributionMessage.IsEmpty) return;
|
||||||
|
var address = new SignalProtocolAddress(sender, senderDevice);
|
||||||
|
new GroupMessageProcessor(_senderKeys)
|
||||||
|
.ProcessDistribution(address, content.SenderKeyDistributionMessage.ToByteArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DecryptedMessage? SurfaceText(Content content, string sender, uint senderDevice, Envelope envelope)
|
||||||
|
{
|
||||||
|
// Direct incoming 1:1 message (text, or a placeholder for media/reactions so it still appears).
|
||||||
|
if (content.DataMessage is { } dm)
|
||||||
|
{
|
||||||
|
string? text = Describe(dm);
|
||||||
|
if (text is not null)
|
||||||
|
{
|
||||||
|
long ts = dm.Timestamp != 0 ? (long)dm.Timestamp : (long)envelope.ServerTimestamp;
|
||||||
|
(string? groupId, byte[]? masterKey) = GroupContext(dm.GroupV2);
|
||||||
|
return new DecryptedMessage(sender, senderDevice, text, ts, Outgoing: false)
|
||||||
|
{
|
||||||
|
Attachment = dm.Attachments.Count > 0 ? dm.Attachments[0] : null,
|
||||||
|
GroupId = groupId, GroupMasterKey = masterKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transcript of a message we sent from another device (synced to us).
|
||||||
|
if (content.SyncMessage?.Sent is { } sent && sent.Message is { } sentMsg && Describe(sentMsg) is { } sentText)
|
||||||
|
{
|
||||||
|
string peer = sent.DestinationServiceId ?? sender;
|
||||||
|
long ts = sent.Timestamp != 0 ? (long)sent.Timestamp : (long)envelope.ServerTimestamp;
|
||||||
|
(string? groupId, byte[]? masterKey) = GroupContext(sentMsg.GroupV2);
|
||||||
|
return new DecryptedMessage(peer, senderDevice, sentText, ts, Outgoing: true)
|
||||||
|
{
|
||||||
|
Attachment = sentMsg.Attachments.Count > 0 ? sentMsg.Attachments[0] : null,
|
||||||
|
GroupId = groupId, GroupMasterKey = masterKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Derives the (groupId, masterKey) from a GroupContextV2, or (null, null) for a 1:1 message.</summary>
|
||||||
|
private static (string? groupId, byte[]? masterKey) GroupContext(GroupContextV2? groupV2)
|
||||||
|
{
|
||||||
|
if (groupV2?.MasterKey is { Length: 32 } mk)
|
||||||
|
{
|
||||||
|
byte[] key = mk.ToByteArray();
|
||||||
|
return (GroupMessageProcessor.GroupIdHex(key), key);
|
||||||
|
}
|
||||||
|
return (null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The display text for a DataMessage: the body, else a placeholder for an attachment or
|
||||||
|
/// reaction (so media/reactions show up instead of being silently dropped). Null = nothing to show
|
||||||
|
/// (receipts, typing, empty).</summary>
|
||||||
|
private static string? Describe(DataMessage dm)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(dm.Body)) return dm.Body;
|
||||||
|
|
||||||
|
if (dm.Reaction is { } r && !string.IsNullOrEmpty(r.Emoji))
|
||||||
|
return r.Remove ? "removed a reaction" : $"reacted {r.Emoji}";
|
||||||
|
|
||||||
|
if (dm.Attachments.Count > 0)
|
||||||
|
return DescribeAttachment(dm.Attachments[0], dm.Attachments.Count);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DescribeAttachment(AttachmentPointer a, int count)
|
||||||
|
{
|
||||||
|
string label =
|
||||||
|
(a.Flags & (uint)AttachmentPointer.Types.Flags.VoiceMessage) != 0 ? "🎙 Voice message" :
|
||||||
|
!string.IsNullOrEmpty(a.ContentType) && a.ContentType.StartsWith("image/") ? "📷 Photo" :
|
||||||
|
!string.IsNullOrEmpty(a.ContentType) && a.ContentType.StartsWith("video/") ? "🎥 Video" :
|
||||||
|
!string.IsNullOrEmpty(a.FileName) ? $"📎 {a.FileName}" : "📎 Attachment";
|
||||||
|
return count > 1 ? $"{label} (+{count - 1} more)" : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolves a service id, preferring the string form and falling back to the binary form
|
||||||
|
/// (16-byte ACI UUID, or 1-byte prefix + 16-byte UUID for PNI).</summary>
|
||||||
|
private static string ResolveServiceId(string asString, Google.Protobuf.ByteString binary) =>
|
||||||
|
!string.IsNullOrEmpty(asString) ? asString : ServiceIds.StringFromBinary(binary.Span) ?? string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Removes Signal's PushTransportDetails padding (0x80 terminator + trailing zeros).</summary>
|
||||||
|
private static byte[] StripPadding(byte[] message) => MessagePadding.Strip(message);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
namespace Wingnal.Service.Messaging;
|
||||||
|
|
||||||
|
/// <summary>Signal's PushTransportDetails padding: append a 0x80 terminator, then zero-pad to a 160-byte
|
||||||
|
/// multiple. Applied to a serialized <c>Content</c> before encryption (1:1 and group) and removed after
|
||||||
|
/// decryption. Shared by the 1:1 send/receive path and the group (Sender Key) path.</summary>
|
||||||
|
public static class MessagePadding
|
||||||
|
{
|
||||||
|
public static byte[] Add(byte[] message)
|
||||||
|
{
|
||||||
|
var padded = new byte[PaddedLength(message.Length + 1) - 1];
|
||||||
|
Array.Copy(message, padded, message.Length);
|
||||||
|
padded[message.Length] = 0x80;
|
||||||
|
return padded;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] Strip(byte[] message)
|
||||||
|
{
|
||||||
|
int paddingStart = 0;
|
||||||
|
for (int i = message.Length - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
if (message[i] == 0x80) { paddingStart = i; break; }
|
||||||
|
if (message[i] != 0x00) { paddingStart = message.Length; break; }
|
||||||
|
}
|
||||||
|
return message[..paddingStart];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int PaddedLength(int messageLength)
|
||||||
|
{
|
||||||
|
int withTerminator = messageLength + 1;
|
||||||
|
int parts = withTerminator / 160;
|
||||||
|
if (withTerminator % 160 != 0) parts++;
|
||||||
|
return parts * 160;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
using Google.Protobuf;
|
||||||
|
using Wingnal.Protocol.Curve;
|
||||||
|
using Wingnal.Protocol.Messages;
|
||||||
|
using Wingnal.Protocol.Ratchet;
|
||||||
|
using Wingnal.Protocol.State;
|
||||||
|
using Wingnal.Service.Account;
|
||||||
|
using Wingnal.Service.Diagnostics;
|
||||||
|
using Wingnal.Service.Net;
|
||||||
|
using Wingnal.Service.Protos;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Messaging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends an unsealed (authenticated) 1:1 text. Fetches the recipient's prekey bundles, establishes a
|
||||||
|
/// PQXDH session per device (initiator/Alice — SPQR runs automatically), encrypts a padded
|
||||||
|
/// DataMessage, and PUTs the per-device ciphertexts to /v1/messages. To message your own account
|
||||||
|
/// ("Note to Self"), pass your own ACI as the destination; your own device is skipped.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MessageSender
|
||||||
|
{
|
||||||
|
private readonly SignalAccount _account;
|
||||||
|
private readonly ISignalProtocolStore _store;
|
||||||
|
private readonly SignalRestClient _rest;
|
||||||
|
private readonly Account.ProfileKeyStore? _profileKeys;
|
||||||
|
private byte[]? _senderCertificate; // cached delivery certificate (~24h)
|
||||||
|
|
||||||
|
public MessageSender(SignalAccount account, ISignalProtocolStore store, SignalRestClient rest,
|
||||||
|
Account.ProfileKeyStore? profileKeys = null)
|
||||||
|
{
|
||||||
|
_account = account;
|
||||||
|
_store = store;
|
||||||
|
_rest = rest;
|
||||||
|
_profileKeys = profileKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record SendResult(bool Ok, int DeviceCount, string Detail);
|
||||||
|
|
||||||
|
// Sesame §3.3: if the recipient's device set changed (server 409 mismatched / 410 stale), re-fetch
|
||||||
|
// the authoritative device list and retry, bounded to avoid looping on a malicious/buggy server.
|
||||||
|
private const int MaxSendAttempts = 3;
|
||||||
|
|
||||||
|
public async Task<SendResult> SendTextAsync(string destinationServiceId, string text, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
long timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
var dataMessage = new DataMessage { Body = text, Timestamp = (ulong)timestamp };
|
||||||
|
var content = new Content { DataMessage = dataMessage };
|
||||||
|
SendResult result = await SendContentAsync(destinationServiceId, content, timestamp, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Multi-device: after messaging someone else, sync a "Sent" transcript to our OWN account so the
|
||||||
|
// user's other linked devices show the outgoing message. (Note-to-Self already reaches them, since
|
||||||
|
// that send targets our other devices directly.) Best-effort — never fails the real send.
|
||||||
|
if (result.Ok && !IsSelf(destinationServiceId))
|
||||||
|
await TrySyncSentTranscriptAsync(destinationServiceId, dataMessage, timestamp, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds the <see cref="Content"/> that syncs an outgoing message to our other devices: a
|
||||||
|
/// <c>SyncMessage.Sent</c> carrying the destination, timestamp, and the exact DataMessage we sent.</summary>
|
||||||
|
public static Content BuildSentTranscript(string destinationServiceId, DataMessage message, long timestamp)
|
||||||
|
{
|
||||||
|
var sent = new SyncMessage.Types.Sent
|
||||||
|
{
|
||||||
|
DestinationServiceId = destinationServiceId,
|
||||||
|
Timestamp = (ulong)timestamp,
|
||||||
|
Message = message,
|
||||||
|
};
|
||||||
|
sent.UnidentifiedStatus.Add(new SyncMessage.Types.Sent.Types.UnidentifiedDeliveryStatus
|
||||||
|
{
|
||||||
|
DestinationServiceId = destinationServiceId,
|
||||||
|
Unidentified = false,
|
||||||
|
});
|
||||||
|
return new Content { SyncMessage = new SyncMessage { Sent = sent } };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TrySyncSentTranscriptAsync(string destinationServiceId, DataMessage message, long timestamp, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Content transcript = BuildSentTranscript(destinationServiceId, message, timestamp);
|
||||||
|
SendResult r = await SendContentAsync(_account.Aci, transcript, timestamp, ct).ConfigureAwait(false);
|
||||||
|
FileLog.Write($"send: synced Sent transcript to self ok={r.Ok} detail={r.Detail}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
FileLog.Write($"send: Sent transcript sync FAILED {ex.GetType().Name}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsSelf(string serviceId) =>
|
||||||
|
string.Equals(serviceId, _account.Aci, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best-effort sealed-sender (metadata-minimized) delivery: if we know the recipient's profile key
|
||||||
|
/// and can get a delivery certificate, re-wrap the already-built per-device ciphertexts as sealed
|
||||||
|
/// envelopes and send them WITHOUT our auth credentials, using the recipient's unidentified-access
|
||||||
|
/// key. Returns true only on success; ANY missing prerequisite or failure returns false so the
|
||||||
|
/// caller falls back to the (working) authenticated send. The inner ciphertext is reused, so the
|
||||||
|
/// ratchet is not advanced twice.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<bool> TrySealedAsync(string destinationServiceId, OutgoingMessageList authList, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_profileKeys is null || IsSelf(destinationServiceId)) return false;
|
||||||
|
byte[]? profileKey = _profileKeys.Get(destinationServiceId);
|
||||||
|
if (profileKey is null) return false;
|
||||||
|
|
||||||
|
_senderCertificate ??= await _rest.GetSenderCertificateAsync(_account.BasicAuthToken(), ct).ConfigureAwait(false);
|
||||||
|
byte[] accessKey = Wingnal.Service.Crypto.UnidentifiedAccess.DeriveAccessKey(profileKey);
|
||||||
|
IdentityKeyPair ourIdentity = _account.AciIdentityKeyPair;
|
||||||
|
|
||||||
|
var sealedMessages = new List<OutgoingMessage>(authList.Messages.Length);
|
||||||
|
foreach (OutgoingMessage m in authList.Messages)
|
||||||
|
{
|
||||||
|
IdentityKey? theirIdentity = _store.GetIdentity(new SignalProtocolAddress(destinationServiceId, m.DestinationDeviceId));
|
||||||
|
if (theirIdentity is null) return false; // need their identity to seal
|
||||||
|
|
||||||
|
byte[] inner = DecodeBase64(m.Content);
|
||||||
|
int innerType = m.Type == 3 ? 1 : 2; // PREKEY_MESSAGE / MESSAGE (sealed inner type)
|
||||||
|
byte[] sealedBytes = SealedSenderDecryptor.EncryptWithCertificate(ourIdentity, theirIdentity, _senderCertificate, innerType, inner);
|
||||||
|
sealedMessages.Add(new OutgoingMessage
|
||||||
|
{
|
||||||
|
Type = 6, // UNIDENTIFIED_SENDER
|
||||||
|
DestinationDeviceId = m.DestinationDeviceId,
|
||||||
|
DestinationRegistrationId = m.DestinationRegistrationId,
|
||||||
|
Content = Convert.ToBase64String(sealedBytes),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var sealedList = new OutgoingMessageList
|
||||||
|
{
|
||||||
|
Messages = sealedMessages.ToArray(),
|
||||||
|
Timestamp = authList.Timestamp,
|
||||||
|
Online = authList.Online,
|
||||||
|
Urgent = authList.Urgent,
|
||||||
|
};
|
||||||
|
(bool ok, _, _) = await _rest.SendSealedMessagesAsync(destinationServiceId, sealedList, accessKey, ct).ConfigureAwait(false);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false; // any problem → fall back to authenticated send (never regress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sends our other devices a SyncMessage.Request for each of <paramref name="types"/> (a
|
||||||
|
/// sync Content to our own ACI), asking the primary to push account state (contacts, blocked,
|
||||||
|
/// configuration). GROUPS is intentionally omitted — it was removed from the sync protocol (groups
|
||||||
|
/// now live in the storage service; see docs/GROUPS.md). Best-effort: returns the first failure.</summary>
|
||||||
|
public async Task<SendResult> SendSyncRequestsAsync(IEnumerable<SyncMessage.Types.Request.Types.Type> types,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
SendResult last = new(true, 0, "no requests");
|
||||||
|
foreach (SyncMessage.Types.Request.Types.Type type in types)
|
||||||
|
{
|
||||||
|
long timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
var content = new Content
|
||||||
|
{
|
||||||
|
SyncMessage = new SyncMessage { Request = new SyncMessage.Types.Request { Type = type } },
|
||||||
|
};
|
||||||
|
last = await SendContentAsync(_account.Aci, content, timestamp, ct).ConfigureAwait(false);
|
||||||
|
if (!last.Ok) return last;
|
||||||
|
}
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Encrypts a padded <see cref="Content"/> and sends it per-device (reuse-first, then
|
||||||
|
/// fetch+establish on 409/410). The 1:1 text and sync-request paths both funnel through here.</summary>
|
||||||
|
public async Task<SendResult> SendContentAsync(string destinationServiceId, Content content, long timestamp,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
byte[] padded = AddPadding(content.ToByteArray());
|
||||||
|
string auth = _account.BasicAuthToken();
|
||||||
|
|
||||||
|
// Reuse path (Sesame): if we already have sessions for this recipient's devices, encrypt with
|
||||||
|
// them and send WITHOUT a /v2/keys fetch. Only fall back to fetching when there's no session
|
||||||
|
// (first message) or the server reports the device set changed (409/410).
|
||||||
|
OutgoingMessageList? reuse = BuildFromExistingSessions(destinationServiceId, padded, timestamp);
|
||||||
|
if (reuse is { Messages.Length: > 0 })
|
||||||
|
{
|
||||||
|
if (await TrySealedAsync(destinationServiceId, reuse, ct).ConfigureAwait(false))
|
||||||
|
return new SendResult(true, reuse.Messages.Length, $"sent sealed to {reuse.Messages.Length} device(s) (reused sessions)");
|
||||||
|
(bool ok, var status, string body) = await _rest.SendMessagesAsync(destinationServiceId, reuse, auth, ct).ConfigureAwait(false);
|
||||||
|
if (ok)
|
||||||
|
return new SendResult(true, reuse.Messages.Length, $"sent to {reuse.Messages.Length} device(s) (reused sessions)");
|
||||||
|
if (status is not (System.Net.HttpStatusCode.Conflict or System.Net.HttpStatusCode.Gone))
|
||||||
|
return new SendResult(false, reuse.Messages.Length, $"{(int)status}: {body}");
|
||||||
|
// device set changed — fall through to the fetch+establish path below.
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int attempt = 1; attempt <= MaxSendAttempts; attempt++)
|
||||||
|
{
|
||||||
|
// "*" returns the recipient's current device set, so a re-fetch reconciles both missing
|
||||||
|
// (added) and stale (removed/rotated) devices.
|
||||||
|
PreKeyResponse bundles = await _rest.GetPreKeysAsync(destinationServiceId, "*", auth, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
OutgoingMessageList list = BuildOutgoingList(destinationServiceId, padded, bundles, timestamp);
|
||||||
|
if (list.Messages.Length == 0)
|
||||||
|
return new SendResult(false, 0, "no target devices (only our own device present)");
|
||||||
|
|
||||||
|
if (await TrySealedAsync(destinationServiceId, list, ct).ConfigureAwait(false))
|
||||||
|
return new SendResult(true, list.Messages.Length, $"sent sealed to {list.Messages.Length} device(s)");
|
||||||
|
|
||||||
|
(bool ok, var status, string body) = await _rest.SendMessagesAsync(destinationServiceId, list, auth, ct).ConfigureAwait(false);
|
||||||
|
if (ok)
|
||||||
|
return new SendResult(true, list.Messages.Length, $"sent to {list.Messages.Length} device(s)");
|
||||||
|
|
||||||
|
bool deviceSetChanged = status is System.Net.HttpStatusCode.Conflict or System.Net.HttpStatusCode.Gone;
|
||||||
|
if (!deviceSetChanged || attempt == MaxSendAttempts)
|
||||||
|
return new SendResult(false, list.Messages.Length, $"{(int)status}: {body}");
|
||||||
|
// else: device set changed — loop to re-fetch and retry.
|
||||||
|
}
|
||||||
|
return new SendResult(false, 0, "device set kept changing after retries");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pure (no network): encrypt a padded text DataMessage to every device in the bundle and
|
||||||
|
/// build the per-device outgoing list. Exposed for offline testing.</summary>
|
||||||
|
public OutgoingMessageList BuildOutgoingList(string destinationServiceId, string text, PreKeyResponse bundles, long timestamp)
|
||||||
|
{
|
||||||
|
var content = new Content { DataMessage = new DataMessage { Body = text, Timestamp = (ulong)timestamp } };
|
||||||
|
return BuildOutgoingList(destinationServiceId, AddPadding(content.ToByteArray()), bundles, timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Encrypt already-padded Content bytes to every device in the bundle.</summary>
|
||||||
|
public OutgoingMessageList BuildOutgoingList(string destinationServiceId, byte[] padded, PreKeyResponse bundles, long timestamp)
|
||||||
|
{
|
||||||
|
IdentityKey theirIdentity = IdentityKey.Decode(DecodeBase64(bundles.IdentityKey));
|
||||||
|
var messages = new List<OutgoingMessage>();
|
||||||
|
foreach (PreKeyResponseDevice dev in bundles.Devices)
|
||||||
|
{
|
||||||
|
// Skip our own device when messaging our own account (Note to Self).
|
||||||
|
if (string.Equals(destinationServiceId, _account.Aci, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& dev.DeviceId == (uint)_account.DeviceId)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var address = new SignalProtocolAddress(destinationServiceId, dev.DeviceId);
|
||||||
|
PreKeyBundle bundle = BuildBundle(theirIdentity, dev);
|
||||||
|
|
||||||
|
new SessionBuilder(_store, _store, _store, _store, _store, address).Process(bundle);
|
||||||
|
ICiphertextMessage cipher = new SessionCipher(_store, _store, _store, _store, _store, address).Encrypt(padded);
|
||||||
|
|
||||||
|
int wireType = cipher.Type == CiphertextMessageType.PreKey ? 3 : 1; // PREKEY_MESSAGE / DOUBLE_RATCHET
|
||||||
|
messages.Add(new OutgoingMessage
|
||||||
|
{
|
||||||
|
Type = wireType,
|
||||||
|
DestinationDeviceId = dev.DeviceId,
|
||||||
|
DestinationRegistrationId = dev.RegistrationId,
|
||||||
|
Content = Convert.ToBase64String(cipher.Serialize()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OutgoingMessageList
|
||||||
|
{
|
||||||
|
Messages = messages.ToArray(),
|
||||||
|
Timestamp = timestamp,
|
||||||
|
Online = false,
|
||||||
|
Urgent = true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Encrypt to every device of <paramref name="destinationServiceId"/> that already has a
|
||||||
|
/// session, reusing it (no prekey fetch). Returns null if there are no existing sessions. Public for
|
||||||
|
/// offline testing.</summary>
|
||||||
|
public OutgoingMessageList? BuildFromExistingSessions(string destinationServiceId, string text, long timestamp)
|
||||||
|
{
|
||||||
|
var content = new Content { DataMessage = new DataMessage { Body = text, Timestamp = (ulong)timestamp } };
|
||||||
|
return BuildFromExistingSessions(destinationServiceId, AddPadding(content.ToByteArray()), timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reuse existing sessions to encrypt already-padded Content bytes.</summary>
|
||||||
|
public OutgoingMessageList? BuildFromExistingSessions(string destinationServiceId, byte[] padded, long timestamp)
|
||||||
|
{
|
||||||
|
IReadOnlyList<uint> deviceIds = _store.GetSubDeviceSessions(destinationServiceId);
|
||||||
|
if (deviceIds.Count == 0) return null;
|
||||||
|
|
||||||
|
var messages = new List<OutgoingMessage>();
|
||||||
|
foreach (uint deviceId in deviceIds)
|
||||||
|
{
|
||||||
|
if (string.Equals(destinationServiceId, _account.Aci, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& deviceId == (uint)_account.DeviceId)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var address = new SignalProtocolAddress(destinationServiceId, deviceId);
|
||||||
|
uint registrationId = _store.LoadSession(address).State.RemoteRegistrationId;
|
||||||
|
ICiphertextMessage cipher = new SessionCipher(_store, _store, _store, _store, _store, address).Encrypt(padded);
|
||||||
|
messages.Add(new OutgoingMessage
|
||||||
|
{
|
||||||
|
Type = cipher.Type == CiphertextMessageType.PreKey ? 3 : 1,
|
||||||
|
DestinationDeviceId = deviceId,
|
||||||
|
DestinationRegistrationId = registrationId,
|
||||||
|
Content = Convert.ToBase64String(cipher.Serialize()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OutgoingMessageList
|
||||||
|
{
|
||||||
|
Messages = messages.ToArray(),
|
||||||
|
Timestamp = timestamp,
|
||||||
|
Online = false,
|
||||||
|
Urgent = true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PreKeyBundle BuildBundle(IdentityKey theirIdentity, PreKeyResponseDevice dev)
|
||||||
|
{
|
||||||
|
byte[] signedPreKey = Curve25519.DecodePoint(DecodeBase64(dev.SignedPreKey.PublicKey));
|
||||||
|
byte[]? preKey = dev.PreKey is { } pk ? Curve25519.DecodePoint(DecodeBase64(pk.PublicKey)) : null;
|
||||||
|
byte[]? kyber = dev.PqPreKey is { } qk ? KemKeySerialization.Deserialize(DecodeBase64(qk.PublicKey)) : null;
|
||||||
|
|
||||||
|
return new PreKeyBundle(
|
||||||
|
registrationId: dev.RegistrationId,
|
||||||
|
deviceId: dev.DeviceId,
|
||||||
|
preKeyId: dev.PreKey?.KeyId,
|
||||||
|
preKeyPublic: preKey,
|
||||||
|
signedPreKeyId: dev.SignedPreKey.KeyId,
|
||||||
|
signedPreKeyPublic: signedPreKey,
|
||||||
|
signedPreKeySignature: DecodeBase64(dev.SignedPreKey.Signature),
|
||||||
|
identityKey: theirIdentity,
|
||||||
|
kyberPreKeyId: dev.PqPreKey?.KeyId,
|
||||||
|
kyberPreKeyPublic: kyber,
|
||||||
|
kyberPreKeySignature: dev.PqPreKey is { } q ? DecodeBase64(q.Signature) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal serializes keys/signatures as base64 (sometimes URL-safe and/or without padding).
|
||||||
|
private static byte[] DecodeBase64(string s)
|
||||||
|
{
|
||||||
|
string t = s.Replace('-', '+').Replace('_', '/');
|
||||||
|
switch (t.Length % 4) { case 2: t += "=="; break; case 3: t += "="; break; }
|
||||||
|
return Convert.FromBase64String(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal PushTransportDetails padding (shared with the group path + the receive-side strip).
|
||||||
|
private static byte[] AddPadding(byte[] message) => MessagePadding.Add(message);
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
using System.Runtime.Versioning;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Wingnal.Service.Account;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Messaging;
|
||||||
|
|
||||||
|
/// <summary>A stored 1:1 message. <see cref="MediaPath"/> is the local file of a downloaded attachment
|
||||||
|
/// (null for plain text).</summary>
|
||||||
|
public sealed record StoredMessage(string Peer, string Body, long Timestamp, bool Outgoing)
|
||||||
|
{
|
||||||
|
public string? MediaPath { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One conversation thread, keyed by peer service id, with its most recent message.</summary>
|
||||||
|
public sealed record Conversation(string Peer, string LastBody, long LastTimestamp, bool LastOutgoing);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQLite store for received/sent 1:1 texts (%LOCALAPPDATA%\Wingnal\messages.db). Message BODIES are
|
||||||
|
/// encrypted at rest with <see cref="LocalCipher"/> (peer/timestamp metadata stays plaintext so the
|
||||||
|
/// list/threads can still be queried + sorted). Legacy plaintext rows decrypt-through unchanged.
|
||||||
|
/// </summary>
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
public sealed class MessageStore
|
||||||
|
{
|
||||||
|
private readonly string _connectionString;
|
||||||
|
private readonly LocalCipher _cipher;
|
||||||
|
|
||||||
|
public MessageStore(string? path = null, LocalCipher? cipher = null)
|
||||||
|
{
|
||||||
|
if (path is null)
|
||||||
|
{
|
||||||
|
string dir = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
path = Path.Combine(dir, "messages.db");
|
||||||
|
}
|
||||||
|
_cipher = cipher ?? LocalCipher.Default();
|
||||||
|
_connectionString = $"Data Source={path}";
|
||||||
|
Initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Initialize()
|
||||||
|
{
|
||||||
|
using var conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText =
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
peer TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
outgoing INTEGER NOT NULL,
|
||||||
|
media TEXT
|
||||||
|
);
|
||||||
|
""";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
// Idempotent migration for DBs created before the media column existed.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using SqliteCommand alter = conn.CreateCommand();
|
||||||
|
alter.CommandText = "ALTER TABLE messages ADD COLUMN media TEXT;";
|
||||||
|
alter.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
catch (SqliteException) { /* column already exists */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Add(StoredMessage message)
|
||||||
|
{
|
||||||
|
using var conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText =
|
||||||
|
"INSERT INTO messages (peer, body, timestamp, outgoing, media) VALUES ($peer, $body, $ts, $out, $media);";
|
||||||
|
cmd.Parameters.AddWithValue("$peer", message.Peer);
|
||||||
|
cmd.Parameters.AddWithValue("$body", _cipher.Protect(message.Body)); // encrypted at rest
|
||||||
|
cmd.Parameters.AddWithValue("$ts", message.Timestamp);
|
||||||
|
cmd.Parameters.AddWithValue("$out", message.Outgoing ? 1 : 0);
|
||||||
|
cmd.Parameters.AddWithValue("$media", message.MediaPath is null ? DBNull.Value : _cipher.Protect(message.MediaPath));
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The most recent <paramref name="limit"/> messages for one peer's thread, returned
|
||||||
|
/// oldest-first for display. (Selects the NEWEST N by timestamp DESC then reverses — selecting ASC
|
||||||
|
/// would return the OLDEST N and hide recent history in a long thread.)</summary>
|
||||||
|
public IReadOnlyList<StoredMessage> Recent(string peer, int limit = 500)
|
||||||
|
{
|
||||||
|
using var conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText =
|
||||||
|
"SELECT peer, body, timestamp, outgoing, media FROM messages WHERE peer = $peer ORDER BY timestamp DESC LIMIT $limit;";
|
||||||
|
cmd.Parameters.AddWithValue("$peer", peer);
|
||||||
|
cmd.Parameters.AddWithValue("$limit", limit);
|
||||||
|
|
||||||
|
var result = new List<StoredMessage>();
|
||||||
|
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
result.Add(new StoredMessage(reader.GetString(0), _cipher.Unprotect(reader.GetString(1)),
|
||||||
|
reader.GetInt64(2), reader.GetInt64(3) != 0)
|
||||||
|
{
|
||||||
|
MediaPath = reader.IsDBNull(4) ? null : _cipher.Unprotect(reader.GetString(4)),
|
||||||
|
});
|
||||||
|
result.Reverse(); // chronological (oldest → newest) for the thread view
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One row per peer (the conversation list), most-recently-active first.</summary>
|
||||||
|
public IReadOnlyList<Conversation> Conversations()
|
||||||
|
{
|
||||||
|
using var conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
// Pick each peer's CHRONOLOGICALLY newest message (max TIMESTAMP, not max id — rows are inserted
|
||||||
|
// in import order, not time order). SQLite fills the bare columns from the MAX(timestamp) row.
|
||||||
|
cmd.CommandText =
|
||||||
|
"""
|
||||||
|
SELECT peer, body, MAX(timestamp) AS timestamp, outgoing
|
||||||
|
FROM messages
|
||||||
|
GROUP BY peer
|
||||||
|
ORDER BY timestamp DESC;
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = new List<Conversation>();
|
||||||
|
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
result.Add(new Conversation(reader.GetString(0), _cipher.Unprotect(reader.GetString(1)),
|
||||||
|
reader.GetInt64(2), reader.GetInt64(3) != 0));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A set of identity keys for the messages already stored (peer‖ts‖outgoing‖body), so a
|
||||||
|
/// bulk import can skip ones it already added (idempotent re-import).</summary>
|
||||||
|
public HashSet<string> ExistingKeys()
|
||||||
|
{
|
||||||
|
using var conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT peer, timestamp, outgoing, body FROM messages;";
|
||||||
|
var set = new HashSet<string>();
|
||||||
|
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
set.Add(KeyOf(reader.GetString(0), reader.GetInt64(1), reader.GetInt64(2) != 0,
|
||||||
|
_cipher.Unprotect(reader.GetString(3))));
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The de-dup identity of a message.</summary>
|
||||||
|
public static string KeyOf(string peer, long timestamp, bool outgoing, string body) =>
|
||||||
|
string.Join('|', peer, timestamp, outgoing ? 1 : 0, body);
|
||||||
|
|
||||||
|
/// <summary>Removes exact-duplicate rows (same peer+timestamp+outgoing+body), keeping the earliest.
|
||||||
|
/// Done in code because the body column is now encrypted (non-deterministic), so SQL GROUP BY can't
|
||||||
|
/// see content equality. Returns rows deleted.</summary>
|
||||||
|
public int Deduplicate()
|
||||||
|
{
|
||||||
|
using var conn = Open();
|
||||||
|
var seen = new HashSet<string>();
|
||||||
|
var toDelete = new List<long>();
|
||||||
|
using (SqliteCommand read = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
read.CommandText = "SELECT id, peer, timestamp, outgoing, body FROM messages ORDER BY id;";
|
||||||
|
using SqliteDataReader reader = read.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
string key = KeyOf(reader.GetString(1), reader.GetInt64(2), reader.GetInt64(3) != 0,
|
||||||
|
_cipher.Unprotect(reader.GetString(4)));
|
||||||
|
if (!seen.Add(key)) toDelete.Add(reader.GetInt64(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (long id in toDelete)
|
||||||
|
{
|
||||||
|
using SqliteCommand del = conn.CreateCommand();
|
||||||
|
del.CommandText = "DELETE FROM messages WHERE id = $id;";
|
||||||
|
del.Parameters.AddWithValue("$id", id);
|
||||||
|
del.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
return toDelete.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes all stored messages (used when unlinking).</summary>
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
using var conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "DELETE FROM messages;";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deletes every message in one peer's thread (peer is stored in the clear).</summary>
|
||||||
|
public void DeleteConversation(string peer)
|
||||||
|
{
|
||||||
|
using var conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "DELETE FROM messages WHERE peer = $peer;";
|
||||||
|
cmd.Parameters.AddWithValue("$peer", peer);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deletes one message identified by (peer, timestamp, direction). Body isn't used since it's
|
||||||
|
/// encrypted; the timestamp is effectively unique within a peer + direction.</summary>
|
||||||
|
public void DeleteMessage(string peer, long timestamp, bool outgoing)
|
||||||
|
{
|
||||||
|
using var conn = Open();
|
||||||
|
using SqliteCommand cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "DELETE FROM messages WHERE peer = $peer AND timestamp = $ts AND outgoing = $out;";
|
||||||
|
cmd.Parameters.AddWithValue("$peer", peer);
|
||||||
|
cmd.Parameters.AddWithValue("$ts", timestamp);
|
||||||
|
cmd.Parameters.AddWithValue("$out", outgoing ? 1 : 0);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
private SqliteConnection Open()
|
||||||
|
{
|
||||||
|
var conn = new SqliteConnection(_connectionString);
|
||||||
|
conn.Open();
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Wingnal.Service.Protos;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Messaging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the small "sideband" <see cref="Content"/> messages that ride alongside chat: delivery/read
|
||||||
|
/// receipts (the ✓✓ that tells the sender we got/read their message) and typing notifications. Pure
|
||||||
|
/// (no network), so the wire shape is offline-testable.
|
||||||
|
/// </summary>
|
||||||
|
public static class Receipts
|
||||||
|
{
|
||||||
|
/// <summary>A DELIVERY receipt acknowledging the message(s) with these sent-timestamps arrived.</summary>
|
||||||
|
public static Content Delivery(params long[] sentTimestamps) =>
|
||||||
|
Build(ReceiptMessage.Types.Type.Delivery, sentTimestamps);
|
||||||
|
|
||||||
|
/// <summary>A READ receipt for the message(s) the user has now seen.</summary>
|
||||||
|
public static Content Read(params long[] sentTimestamps) =>
|
||||||
|
Build(ReceiptMessage.Types.Type.Read, sentTimestamps);
|
||||||
|
|
||||||
|
/// <summary>A typing START/STOP notification for a 1:1 thread.</summary>
|
||||||
|
public static Content Typing(bool started, long timestamp) => new()
|
||||||
|
{
|
||||||
|
TypingMessage = new TypingMessage
|
||||||
|
{
|
||||||
|
Action = started ? TypingMessage.Types.Action.Started : TypingMessage.Types.Action.Stopped,
|
||||||
|
Timestamp = (ulong)timestamp,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Content Build(ReceiptMessage.Types.Type type, IEnumerable<long> timestamps)
|
||||||
|
{
|
||||||
|
var receipt = new ReceiptMessage { Type = type };
|
||||||
|
foreach (long t in timestamps) receipt.Timestamp.Add((ulong)t);
|
||||||
|
return new Content { ReceiptMessage = receipt };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Messaging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves user-entered recipient text into a destination service id (ACI UUID). An ACI is accepted
|
||||||
|
/// directly (normalized to lowercase canonical form). A phone number (e164) currently can't be resolved
|
||||||
|
/// client-side: Signal removed the unauthenticated number→ACI lookup, so it requires the Contact
|
||||||
|
/// Discovery Service (CDSI, an SGX-attested enclave) which Wingnal doesn't implement yet — see
|
||||||
|
/// SHORTCUTS.md. Until then, paste the contact's ACI UUID.
|
||||||
|
/// </summary>
|
||||||
|
public static partial class RecipientResolver
|
||||||
|
{
|
||||||
|
public sealed record Result(bool Ok, string? ServiceId, string? Error);
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^\+[1-9]\d{6,14}$")]
|
||||||
|
private static partial Regex E164();
|
||||||
|
|
||||||
|
public static Result Resolve(string? input)
|
||||||
|
{
|
||||||
|
string text = (input ?? string.Empty).Trim();
|
||||||
|
if (text.Length == 0)
|
||||||
|
return new Result(false, null, "Enter a recipient.");
|
||||||
|
|
||||||
|
if (Guid.TryParse(text, out Guid aci))
|
||||||
|
return new Result(true, aci.ToString("D").ToLowerInvariant(), null);
|
||||||
|
|
||||||
|
if (E164().IsMatch(text))
|
||||||
|
return new Result(false, null,
|
||||||
|
"Phone-number lookup needs Signal's Contact Discovery (CDSI), which Wingnal doesn't " +
|
||||||
|
"support yet. Paste the contact's ACI UUID instead.");
|
||||||
|
|
||||||
|
return new Result(false, null, "Not a valid ACI UUID (or +e164 number).");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Google.Protobuf;
|
||||||
|
using Wingnal.Protocol.Crypto;
|
||||||
|
using Wingnal.Protocol.Curve;
|
||||||
|
using Wingnal.Protocol.Messages;
|
||||||
|
using Wingnal.Protocol.State;
|
||||||
|
using Wingnal.Service.Protos.SealedSender;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Messaging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decrypts a Sealed Sender v1 envelope (UNIDENTIFIED_SENDER) to recover the sender and the inner
|
||||||
|
/// ciphertext, which the normal session pipeline then decrypts. This is how messages other people send
|
||||||
|
/// us (and their replies) arrive — modern Signal clients default to sealed sender. Byte-exact with
|
||||||
|
/// libsignal v0.96.1 sealed_sender.rs (v1). Sealed Sender v2 (multi-recipient) is not handled here.
|
||||||
|
///
|
||||||
|
/// NOTE: the sender certificate's server signature is NOT validated against Signal's trust root yet
|
||||||
|
/// (see SHORTCUTS.md). The message is still cryptographically authenticated by the inner Double Ratchet
|
||||||
|
/// session (its MAC binds to the sender's identity key), so a forged certificate can't forge content;
|
||||||
|
/// trust-root validation only adds server-attested sender identity.
|
||||||
|
/// </summary>
|
||||||
|
public static class SealedSenderDecryptor
|
||||||
|
{
|
||||||
|
private const byte SealedSenderV1MajorVersion = 1;
|
||||||
|
private const byte SealedSenderV2MajorVersion = 2;
|
||||||
|
private static readonly byte[] SaltPrefix = "UnidentifiedDelivery"u8.ToArray();
|
||||||
|
|
||||||
|
/// <summary>The unwrapped sealed-sender payload: who sent it + the inner ciphertext to feed the
|
||||||
|
/// session cipher.</summary>
|
||||||
|
public sealed record Unsealed(string SenderUuid, uint SenderDevice, int CiphertextType, byte[] Content);
|
||||||
|
|
||||||
|
public static Unsealed Decrypt(byte[] serialized, IdentityKeyPair ourIdentity, long nowMs,
|
||||||
|
IReadOnlyList<byte[]>? trustRoots = null)
|
||||||
|
{
|
||||||
|
if (serialized.Length < 1) throw new InvalidMessageException("sealed sender message empty");
|
||||||
|
int version = (serialized[0] >> 4) & 0xF;
|
||||||
|
if (version == SealedSenderV2MajorVersion)
|
||||||
|
throw new InvalidMessageException("sealed sender v2 not supported");
|
||||||
|
if (version is not (0 or SealedSenderV1MajorVersion))
|
||||||
|
throw new InvalidMessageException($"unknown sealed sender version {version}");
|
||||||
|
|
||||||
|
UnidentifiedSenderMessage outer = UnidentifiedSenderMessage.Parser.ParseFrom(serialized.AsSpan(1).ToArray());
|
||||||
|
byte[] ephemeralPublic = Curve25519.DecodePoint(outer.EphemeralPublic.Span);
|
||||||
|
byte[] encryptedStatic = outer.EncryptedStatic.ToByteArray();
|
||||||
|
byte[] encryptedMessage = outer.EncryptedMessage.ToByteArray();
|
||||||
|
|
||||||
|
byte[] ourIdPriv = ourIdentity.PrivateKey;
|
||||||
|
byte[] ourIdPub = ourIdentity.PublicKey.Serialize(); // 33-byte DjbECPublicKey
|
||||||
|
|
||||||
|
// Ephemeral keys: HKDF(salt = "UnidentifiedDelivery" || ourPub || ephPub, ikm = ECDH(eph, ourId)).
|
||||||
|
byte[] ephSalt = Concat(SaltPrefix, ourIdPub, Curve25519.EncodePoint(ephemeralPublic));
|
||||||
|
byte[] ephSecret = Curve25519.CalculateAgreement(ephemeralPublic, ourIdPriv);
|
||||||
|
byte[] ephKeys = CryptoPrimitives.Hkdf(ephSecret, ephSalt, info: null, 96);
|
||||||
|
byte[] chainKey = ephKeys.AsSpan(0, 32).ToArray();
|
||||||
|
byte[] ephCipherKey = ephKeys.AsSpan(32, 32).ToArray();
|
||||||
|
byte[] ephMacKey = ephKeys.AsSpan(64, 32).ToArray();
|
||||||
|
|
||||||
|
byte[] senderStaticBytes = DecryptCtrHmac(encryptedStatic, ephCipherKey, ephMacKey);
|
||||||
|
byte[] senderStaticPublic = Curve25519.DecodePoint(senderStaticBytes);
|
||||||
|
|
||||||
|
// Static keys: HKDF(salt = chainKey || encryptedStatic, ikm = ECDH(senderStatic, ourId)); first 32 discarded.
|
||||||
|
byte[] staticSalt = Concat(chainKey, encryptedStatic);
|
||||||
|
byte[] staticSecret = Curve25519.CalculateAgreement(senderStaticPublic, ourIdPriv);
|
||||||
|
byte[] staticKeys = CryptoPrimitives.Hkdf(staticSecret, staticSalt, info: null, 96);
|
||||||
|
byte[] staticCipherKey = staticKeys.AsSpan(32, 32).ToArray();
|
||||||
|
byte[] staticMacKey = staticKeys.AsSpan(64, 32).ToArray();
|
||||||
|
|
||||||
|
byte[] innerBytes = DecryptCtrHmac(encryptedMessage, staticCipherKey, staticMacKey);
|
||||||
|
UnidentifiedSenderMessage.Types.Message inner = UnidentifiedSenderMessage.Types.Message.Parser.ParseFrom(innerBytes);
|
||||||
|
|
||||||
|
// Validate the server-attested sender certificate (trust root → server → sender, not expired)
|
||||||
|
// before believing the claimed sender.
|
||||||
|
var senderCert = SenderCertificate.Parser.ParseFrom(inner.SenderCertificate);
|
||||||
|
SenderCertificateValidator.Validate(senderCert, nowMs, trustRoots ?? SenderCertificateValidator.ProductionTrustRoots);
|
||||||
|
|
||||||
|
(string uuid, uint device) = ExtractSender(senderCert);
|
||||||
|
return new Unsealed(uuid, device, (int)inner.Type, inner.Content.ToByteArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds a Sealed Sender v1 envelope using a real (server-issued) sender certificate.
|
||||||
|
/// Mirrors libsignal sealed_sender_encrypt.</summary>
|
||||||
|
public static byte[] EncryptWithCertificate(IdentityKeyPair senderIdentity, IdentityKey recipientIdentity,
|
||||||
|
byte[] senderCertificate, int ciphertextType, byte[] content)
|
||||||
|
{
|
||||||
|
byte[] recipientPubRaw = recipientIdentity.PublicKey;
|
||||||
|
byte[] recipientPubEnc = recipientIdentity.Serialize();
|
||||||
|
byte[] senderIdPubEnc = senderIdentity.PublicKey.Serialize();
|
||||||
|
|
||||||
|
ECKeyPair ephemeral = Curve25519.GenerateKeyPair();
|
||||||
|
byte[] ephPubEnc = Curve25519.EncodePoint(ephemeral.PublicKey);
|
||||||
|
|
||||||
|
byte[] ephSalt = Concat(SaltPrefix, recipientPubEnc, ephPubEnc);
|
||||||
|
byte[] ephSecret = Curve25519.CalculateAgreement(recipientPubRaw, ephemeral.PrivateKey);
|
||||||
|
byte[] ephKeys = CryptoPrimitives.Hkdf(ephSecret, ephSalt, info: null, 96);
|
||||||
|
byte[] chainKey = ephKeys.AsSpan(0, 32).ToArray();
|
||||||
|
byte[] ephCipherKey = ephKeys.AsSpan(32, 32).ToArray();
|
||||||
|
byte[] ephMacKey = ephKeys.AsSpan(64, 32).ToArray();
|
||||||
|
|
||||||
|
byte[] encryptedStatic = EncryptCtrHmac(senderIdPubEnc, ephCipherKey, ephMacKey);
|
||||||
|
|
||||||
|
byte[] staticSalt = Concat(chainKey, encryptedStatic);
|
||||||
|
byte[] staticSecret = Curve25519.CalculateAgreement(recipientPubRaw, senderIdentity.PrivateKey);
|
||||||
|
byte[] staticKeys = CryptoPrimitives.Hkdf(staticSecret, staticSalt, info: null, 96);
|
||||||
|
byte[] staticCipherKey = staticKeys.AsSpan(32, 32).ToArray();
|
||||||
|
byte[] staticMacKey = staticKeys.AsSpan(64, 32).ToArray();
|
||||||
|
|
||||||
|
var inner = new UnidentifiedSenderMessage.Types.Message
|
||||||
|
{
|
||||||
|
Type = (UnidentifiedSenderMessage.Types.Message.Types.Type)ciphertextType,
|
||||||
|
SenderCertificate = Google.Protobuf.ByteString.CopyFrom(senderCertificate),
|
||||||
|
Content = Google.Protobuf.ByteString.CopyFrom(content),
|
||||||
|
};
|
||||||
|
byte[] encryptedMessage = EncryptCtrHmac(inner.ToByteArray(), staticCipherKey, staticMacKey);
|
||||||
|
|
||||||
|
var outer = new UnidentifiedSenderMessage
|
||||||
|
{
|
||||||
|
EphemeralPublic = Google.Protobuf.ByteString.CopyFrom(ephPubEnc),
|
||||||
|
EncryptedStatic = Google.Protobuf.ByteString.CopyFrom(encryptedStatic),
|
||||||
|
EncryptedMessage = Google.Protobuf.ByteString.CopyFrom(encryptedMessage),
|
||||||
|
};
|
||||||
|
byte[] body = outer.ToByteArray();
|
||||||
|
var result = new byte[1 + body.Length];
|
||||||
|
result[0] = 0x11; // SEALED_SENDER_V1_FULL_VERSION
|
||||||
|
Buffer.BlockCopy(body, 0, result, 1, body.Length);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Test helper: builds a signed cert chain (trustRoot → server → sender) and seals with it.</summary>
|
||||||
|
public static byte[] Encrypt(IdentityKeyPair senderIdentity, IdentityKey recipientIdentity,
|
||||||
|
string senderUuid, uint senderDevice, int ciphertextType, byte[] content, ECKeyPair trustRoot, long expiresMs)
|
||||||
|
{
|
||||||
|
byte[] cert = BuildCertificate(senderIdentity, senderUuid, senderDevice, trustRoot, expiresMs);
|
||||||
|
return EncryptWithCertificate(senderIdentity, recipientIdentity, cert, ciphertextType, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] BuildCertificate(IdentityKeyPair senderIdentity, string senderUuid, uint senderDevice,
|
||||||
|
ECKeyPair trustRoot, long expiresMs)
|
||||||
|
{
|
||||||
|
ECKeyPair serverKey = Curve25519.GenerateKeyPair();
|
||||||
|
var serverInner = new ServerCertificate.Types.Certificate
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Key = Google.Protobuf.ByteString.CopyFrom(Curve25519.EncodePoint(serverKey.PublicKey)),
|
||||||
|
};
|
||||||
|
byte[] serverInnerBytes = serverInner.ToByteArray();
|
||||||
|
var serverCert = new ServerCertificate
|
||||||
|
{
|
||||||
|
Certificate = Google.Protobuf.ByteString.CopyFrom(serverInnerBytes),
|
||||||
|
Signature = Google.Protobuf.ByteString.CopyFrom(
|
||||||
|
XEd25519.CalculateSignature(trustRoot.PrivateKey, serverInnerBytes, RandomNumberGenerator.GetBytes(64))),
|
||||||
|
};
|
||||||
|
var certInner = new SenderCertificate.Types.Certificate
|
||||||
|
{
|
||||||
|
UuidString = senderUuid,
|
||||||
|
SenderDevice = senderDevice,
|
||||||
|
Expires = (ulong)expiresMs,
|
||||||
|
IdentityKey = Google.Protobuf.ByteString.CopyFrom(senderIdentity.PublicKey.Serialize()),
|
||||||
|
Certificate_ = serverCert.ToByteString(),
|
||||||
|
};
|
||||||
|
byte[] certInnerBytes = certInner.ToByteArray();
|
||||||
|
return new SenderCertificate
|
||||||
|
{
|
||||||
|
Certificate = Google.Protobuf.ByteString.CopyFrom(certInnerBytes),
|
||||||
|
Signature = Google.Protobuf.ByteString.CopyFrom(
|
||||||
|
XEd25519.CalculateSignature(serverKey.PrivateKey, certInnerBytes, RandomNumberGenerator.GetBytes(64))),
|
||||||
|
}.ToByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] EncryptCtrHmac(byte[] plaintext, byte[] cipherKey, byte[] macKey)
|
||||||
|
{
|
||||||
|
byte[] ctext = CryptoPrimitives.AesCtr(cipherKey, new byte[16], plaintext);
|
||||||
|
byte[] mac = CryptoPrimitives.HmacSha256(macKey, ctext).AsSpan(0, 10).ToArray();
|
||||||
|
var result = new byte[ctext.Length + 10];
|
||||||
|
Buffer.BlockCopy(ctext, 0, result, 0, ctext.Length);
|
||||||
|
Buffer.BlockCopy(mac, 0, result, ctext.Length, 10);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// aes256_ctr_hmacsha256: data = AES-256-CTR(ct) || HMAC-SHA256(macKey, ct)[..10]. Zero CTR nonce.
|
||||||
|
private static byte[] DecryptCtrHmac(byte[] data, byte[] cipherKey, byte[] macKey)
|
||||||
|
{
|
||||||
|
const int macLen = 10;
|
||||||
|
if (data.Length < macLen) throw new InvalidMessageException("sealed sender ciphertext truncated");
|
||||||
|
int ctLen = data.Length - macLen;
|
||||||
|
byte[] ctext = data.AsSpan(0, ctLen).ToArray();
|
||||||
|
byte[] theirMac = data.AsSpan(ctLen, macLen).ToArray();
|
||||||
|
|
||||||
|
byte[] ourMac = CryptoPrimitives.HmacSha256(macKey, ctext).AsSpan(0, macLen).ToArray();
|
||||||
|
if (!CryptographicOperations.FixedTimeEquals(ourMac, theirMac))
|
||||||
|
throw new InvalidMessageException("sealed sender MAC mismatch");
|
||||||
|
|
||||||
|
return CryptoPrimitives.AesCtr(cipherKey, new byte[16], ctext);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string Uuid, uint Device) ExtractSender(SenderCertificate cert)
|
||||||
|
{
|
||||||
|
var inner = SenderCertificate.Types.Certificate.Parser.ParseFrom(cert.Certificate);
|
||||||
|
|
||||||
|
string uuid = inner.SenderUuidCase switch
|
||||||
|
{
|
||||||
|
SenderCertificate.Types.Certificate.SenderUuidOneofCase.UuidString => inner.UuidString,
|
||||||
|
SenderCertificate.Types.Certificate.SenderUuidOneofCase.UuidBytes => ServiceIds.StringFromBinary(inner.UuidBytes.Span) ?? "",
|
||||||
|
_ => string.Empty,
|
||||||
|
};
|
||||||
|
if (string.IsNullOrEmpty(uuid))
|
||||||
|
throw new InvalidMessageException("sealed sender certificate has no sender uuid");
|
||||||
|
return (uuid.ToLowerInvariant(), inner.SenderDevice);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Concat(params byte[][] parts)
|
||||||
|
{
|
||||||
|
var result = new byte[parts.Sum(p => p.Length)];
|
||||||
|
int o = 0;
|
||||||
|
foreach (byte[] p in parts) { Buffer.BlockCopy(p, 0, result, o, p.Length); o += p.Length; }
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using Wingnal.Protocol.Curve;
|
||||||
|
using Wingnal.Protocol.Messages;
|
||||||
|
using Wingnal.Service.Protos.SealedSender;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Messaging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a sealed-sender <see cref="SenderCertificate"/> against Signal's unidentified-sender trust
|
||||||
|
/// root: the trust root must have signed the embedded <see cref="ServerCertificate"/>, that server key
|
||||||
|
/// must have signed the sender certificate, and the certificate must not have expired. This is what
|
||||||
|
/// proves the claimed sender ACI/device is server-attested (not just self-asserted). Mirrors libsignal
|
||||||
|
/// sealed_sender.rs <c>SenderCertificate::validate</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static class SenderCertificateValidator
|
||||||
|
{
|
||||||
|
/// <summary>Signal's production unidentified-sender trust roots (33-byte DjbECPublicKey).</summary>
|
||||||
|
public static readonly IReadOnlyList<byte[]> ProductionTrustRoots = new[]
|
||||||
|
{
|
||||||
|
Convert.FromBase64String("BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF"),
|
||||||
|
Convert.FromBase64String("BUkY0I+9+oPgDCn4+Ac6Iu813yvqkDr/ga8DzLxFxuk6"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Server certificates that real sender certificates reference by id (field 8) instead of
|
||||||
|
/// embedding (field 5), to save space — keyed by id. From libsignal's <c>KNOWN_SERVER_CERTIFICATES</c>
|
||||||
|
/// (id 2 = staging, id 3 = production). These are the full serialized <see cref="ServerCertificate"/> protobufs.</summary>
|
||||||
|
private static readonly IReadOnlyDictionary<uint, byte[]> KnownServerCertificates = new Dictionary<uint, byte[]>
|
||||||
|
{
|
||||||
|
[2] = Convert.FromHexString(
|
||||||
|
"0a25080212210539450d63ebd0752c0fd4038b9d07a916f5e174b756d409b5ca79f4c97400631e" +
|
||||||
|
"124064c5a38b1e927497d3d4786b101a623ab34a7da3954fae126b04dba9d7a3604ed88cdc8550950f0d4a9134ceb7e19b94139151d2c3d6e1c81e9d1128aafca806"),
|
||||||
|
[3] = Convert.FromHexString(
|
||||||
|
"0a250803122105bc9d1d290be964810dfa7e94856480a3f7060d004c9762c24c575a1522353a5a" +
|
||||||
|
"1240c11ec3c401eb0107ab38f8600e8720a63169e0e2eb8a3fae24f63099f85ea319c3c1c46d3454706ae2a679d1fee690a488adda98a2290b66c906bb60295ed781"),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static void Validate(SenderCertificate cert, long nowMs, IReadOnlyList<byte[]> trustRoots)
|
||||||
|
{
|
||||||
|
SenderCertificate.Types.Certificate inner =
|
||||||
|
SenderCertificate.Types.Certificate.Parser.ParseFrom(cert.Certificate);
|
||||||
|
|
||||||
|
// The signer is either embedded (field 5) or referenced by id (field 8); real Signal certs use the id.
|
||||||
|
byte[] serverCertBytes = inner.SignerCase switch
|
||||||
|
{
|
||||||
|
SenderCertificate.Types.Certificate.SignerOneofCase.Certificate_ => inner.Certificate_.ToByteArray(),
|
||||||
|
SenderCertificate.Types.Certificate.SignerOneofCase.Id when KnownServerCertificates.TryGetValue(inner.Id, out byte[]? c) => c,
|
||||||
|
SenderCertificate.Types.Certificate.SignerOneofCase.Id =>
|
||||||
|
throw new InvalidMessageException($"unknown sealed-sender server certificate id {inner.Id}"),
|
||||||
|
_ => throw new InvalidMessageException("sender certificate has no server certificate"),
|
||||||
|
};
|
||||||
|
if ((long)inner.Expires < nowMs)
|
||||||
|
throw new InvalidMessageException("sender certificate expired");
|
||||||
|
|
||||||
|
// 1) The trust root must have signed the server certificate.
|
||||||
|
ServerCertificate server = ServerCertificate.Parser.ParseFrom(serverCertBytes);
|
||||||
|
bool serverTrusted = trustRoots.Any(root =>
|
||||||
|
XEd25519.VerifySignature(Curve25519.DecodePoint(root),
|
||||||
|
server.Certificate.Span, server.Signature.Span));
|
||||||
|
if (!serverTrusted)
|
||||||
|
throw new InvalidMessageException("server certificate not signed by a trust root");
|
||||||
|
|
||||||
|
// 2) The server key must have signed the sender certificate.
|
||||||
|
ServerCertificate.Types.Certificate serverInner =
|
||||||
|
ServerCertificate.Types.Certificate.Parser.ParseFrom(server.Certificate);
|
||||||
|
byte[] serverKey = Curve25519.DecodePoint(serverInner.Key.Span);
|
||||||
|
if (!XEd25519.VerifySignature(serverKey, cert.Certificate.Span, cert.Signature.Span))
|
||||||
|
throw new InvalidMessageException("sender certificate not signed by the server");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using Google.Protobuf;
|
||||||
|
using Wingnal.Service.Account;
|
||||||
|
using Wingnal.Service.Attachments;
|
||||||
|
using Wingnal.Service.Diagnostics;
|
||||||
|
using Wingnal.Service.Protos;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Messaging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies inbound <see cref="SyncMessage"/>s pushed by the primary device: downloads + imports the
|
||||||
|
/// contacts blob (SyncMessage.Contacts → <see cref="ContactsStore"/>) so the conversation list can show
|
||||||
|
/// names, and records read state (SyncMessage.Read). The contact-import mapping is factored into
|
||||||
|
/// <see cref="ImportContacts"/> so it can be tested offline without the CDN.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SyncProcessor
|
||||||
|
{
|
||||||
|
private readonly ContactsStore _contacts;
|
||||||
|
private readonly AttachmentDownloader _downloader;
|
||||||
|
|
||||||
|
/// <summary>Raised for each read receipt synced from the primary (senderAci, message timestamp).</summary>
|
||||||
|
public event Action<string, long>? ReadReceiptReceived;
|
||||||
|
|
||||||
|
public SyncProcessor(ContactsStore contacts, AttachmentDownloader? downloader = null)
|
||||||
|
{
|
||||||
|
_contacts = contacts;
|
||||||
|
_downloader = downloader ?? new AttachmentDownloader();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ProcessAsync(SyncMessage sync, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (sync.Contacts?.Blob is { } pointer)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
byte[] blob = await _downloader.DownloadAsync(pointer, ct).ConfigureAwait(false);
|
||||||
|
int n = ImportContacts(blob);
|
||||||
|
FileLog.Write($"sync: imported {n} contact(s)");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
FileLog.Write($"sync: contacts import failed: {ex.GetType().Name}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (SyncMessage.Types.Read read in sync.Read)
|
||||||
|
{
|
||||||
|
string? aci = AciOf(read.SenderAci, read.SenderAciBinary);
|
||||||
|
if (aci is not null)
|
||||||
|
ReadReceiptReceived?.Invoke(aci, (long)read.Timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parses a decrypted contacts blob and upserts each contact. Returns the count. Pure
|
||||||
|
/// (no network) — the offline-testable core of contacts sync.</summary>
|
||||||
|
public int ImportContacts(byte[] blob)
|
||||||
|
{
|
||||||
|
int count = 0;
|
||||||
|
foreach (ContactRecord record in ContactRecordStream.Parse(blob))
|
||||||
|
{
|
||||||
|
string? aci = AciOf(record.Details.Aci, record.Details.AciBinary);
|
||||||
|
if (aci is null) continue; // ACI-less contacts (e164-only) can't key a conversation yet
|
||||||
|
|
||||||
|
string? name = string.IsNullOrWhiteSpace(record.Details.Name) ? null : record.Details.Name;
|
||||||
|
string? number = string.IsNullOrWhiteSpace(record.Details.Number) ? null : record.Details.Number;
|
||||||
|
_contacts.Upsert(new Contact(aci, number, name, (int)record.Details.InboxPosition));
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Prefers the string ACI; falls back to the 16-byte binary form. Returns lowercase
|
||||||
|
/// canonical UUID, or null if neither is present/valid.</summary>
|
||||||
|
private static string? AciOf(string asString, ByteString binary)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(asString) && Guid.TryParse(asString, out Guid g))
|
||||||
|
return g.ToString("D").ToLowerInvariant();
|
||||||
|
return ServiceIds.StringFromBinary(binary.Span);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
namespace Wingnal.Service.Net;
|
||||||
|
|
||||||
|
// ── GET /v2/keys/{identifier}/{deviceId} response ──
|
||||||
|
|
||||||
|
public sealed class PreKeyResponse
|
||||||
|
{
|
||||||
|
public string IdentityKey { get; set; } = ""; // base64, 33-byte (0x05‖32)
|
||||||
|
public PreKeyResponseDevice[] Devices { get; set; } = Array.Empty<PreKeyResponseDevice>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PreKeyResponseDevice
|
||||||
|
{
|
||||||
|
public uint DeviceId { get; set; }
|
||||||
|
public uint RegistrationId { get; set; }
|
||||||
|
public PreKeyDto? PreKey { get; set; } // optional one-time EC prekey
|
||||||
|
public SignedPreKeyDto SignedPreKey { get; set; } = new();
|
||||||
|
public SignedPreKeyDto? PqPreKey { get; set; } // ML-KEM (Kyber) signed prekey
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PreKeyDto
|
||||||
|
{
|
||||||
|
public uint KeyId { get; set; }
|
||||||
|
public string PublicKey { get; set; } = ""; // base64, 33-byte (0x05‖32)
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SignedPreKeyDto
|
||||||
|
{
|
||||||
|
public uint KeyId { get; set; }
|
||||||
|
public string PublicKey { get; set; } = ""; // base64 (EC: 0x05‖32; Kyber: 0x08‖1568)
|
||||||
|
public string Signature { get; set; } = ""; // base64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PUT /v1/messages/{destination} request ──
|
||||||
|
|
||||||
|
public sealed class OutgoingMessageList
|
||||||
|
{
|
||||||
|
public OutgoingMessage[] Messages { get; set; } = Array.Empty<OutgoingMessage>();
|
||||||
|
public long Timestamp { get; set; }
|
||||||
|
public bool Online { get; set; }
|
||||||
|
public bool Urgent { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PUT /v2/keys?identity={aci|pni} request (upload one-time prekeys) ──
|
||||||
|
|
||||||
|
public sealed class SetKeysRequest
|
||||||
|
{
|
||||||
|
public PreKeyEntity[]? PreKeys { get; set; } // one-time EC prekeys
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PreKeyEntity
|
||||||
|
{
|
||||||
|
public uint KeyId { get; set; }
|
||||||
|
public string PublicKey { get; set; } = ""; // base64, 33-byte (0x05‖32)
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OutgoingMessage
|
||||||
|
{
|
||||||
|
public int Type { get; set; } // envelope type: 3=PREKEY_MESSAGE, 1=DOUBLE_RATCHET
|
||||||
|
public uint DestinationDeviceId { get; set; }
|
||||||
|
public uint DestinationRegistrationId { get; set; }
|
||||||
|
public string Content { get; set; } = ""; // base64 of the serialized ciphertext
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Net;
|
||||||
|
|
||||||
|
// JSON DTOs for PUT /v1/devices/link. Field names match the Signal service contract.
|
||||||
|
|
||||||
|
public sealed record SignedPreKeyEntity(
|
||||||
|
[property: JsonPropertyName("keyId")] uint KeyId,
|
||||||
|
[property: JsonPropertyName("publicKey")] string PublicKey,
|
||||||
|
[property: JsonPropertyName("signature")] string Signature);
|
||||||
|
|
||||||
|
public sealed record KyberPreKeyEntity(
|
||||||
|
[property: JsonPropertyName("keyId")] uint KeyId,
|
||||||
|
[property: JsonPropertyName("publicKey")] string PublicKey,
|
||||||
|
[property: JsonPropertyName("signature")] string Signature);
|
||||||
|
|
||||||
|
public sealed record AccountAttributes(
|
||||||
|
[property: JsonPropertyName("fetchesMessages")] bool FetchesMessages,
|
||||||
|
[property: JsonPropertyName("registrationId")] uint RegistrationId,
|
||||||
|
[property: JsonPropertyName("pniRegistrationId")] uint PniRegistrationId,
|
||||||
|
[property: JsonPropertyName("name")] string? Name,
|
||||||
|
[property: JsonPropertyName("capabilities")] AccountCapabilities Capabilities);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Linked-device capability flags, serialized as a {name: bool} map. The server keeps the true
|
||||||
|
/// entries with known names. New devices must declare <c>spqr</c> (required for new devices) and must
|
||||||
|
/// not drop downgrade-protected capabilities the account already has (e.g. usernameChangeSyncMessage).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AccountCapabilities(
|
||||||
|
[property: JsonPropertyName("storage")] bool Storage = true,
|
||||||
|
[property: JsonPropertyName("spqr")] bool Spqr = true,
|
||||||
|
[property: JsonPropertyName("usernameChangeSyncMessage")] bool UsernameChangeSyncMessage = true);
|
||||||
|
|
||||||
|
public sealed record LinkDeviceRequest(
|
||||||
|
[property: JsonPropertyName("verificationCode")] string VerificationCode,
|
||||||
|
[property: JsonPropertyName("accountAttributes")] AccountAttributes AccountAttributes,
|
||||||
|
[property: JsonPropertyName("aciSignedPreKey")] SignedPreKeyEntity AciSignedPreKey,
|
||||||
|
[property: JsonPropertyName("pniSignedPreKey")] SignedPreKeyEntity PniSignedPreKey,
|
||||||
|
[property: JsonPropertyName("aciPqLastResortPreKey")] KyberPreKeyEntity AciPqLastResortPreKey,
|
||||||
|
[property: JsonPropertyName("pniPqLastResortPreKey")] KyberPreKeyEntity PniPqLastResortPreKey);
|
||||||
|
|
||||||
|
public sealed record LinkDeviceResponse(
|
||||||
|
[property: JsonPropertyName("uuid")] string Uuid,
|
||||||
|
[property: JsonPropertyName("pni")] string Pni,
|
||||||
|
[property: JsonPropertyName("deviceId")] int DeviceId);
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Net;
|
||||||
|
|
||||||
|
/// <summary>Thin typed HTTP client for the Signal account/keys/messages REST API.</summary>
|
||||||
|
public sealed class SignalRestClient : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions Json = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
|
||||||
|
public SignalRestClient(HttpClient? http = null)
|
||||||
|
{
|
||||||
|
_http = http ?? CreatePinnedClient();
|
||||||
|
_http.DefaultRequestHeaders.UserAgent.ParseAdd(SignalServiceConfig.UserAgent);
|
||||||
|
_http.DefaultRequestHeaders.Add("X-Signal-Agent", SignalServiceConfig.UserAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpClient CreatePinnedClient()
|
||||||
|
{
|
||||||
|
var handler = new SocketsHttpHandler();
|
||||||
|
handler.SslOptions.RemoteCertificateValidationCallback =
|
||||||
|
(sender, cert, chain, errors) => SignalTrust.Validate(sender, cert, chain, errors);
|
||||||
|
return new HttpClient(handler) { BaseAddress = new Uri(SignalServiceConfig.ServiceUrl) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers this client as a new secondary device. Authenticates with Basic(number:password)
|
||||||
|
/// using the password this device generated; returns the assigned aci/pni/deviceId.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<LinkDeviceResponse> LinkDeviceAsync(
|
||||||
|
string number, string password, LinkDeviceRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var msg = new HttpRequestMessage(HttpMethod.Put, "/v1/devices/link")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(request),
|
||||||
|
};
|
||||||
|
string token = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{number}:{password}"));
|
||||||
|
msg.Headers.Authorization = new AuthenticationHeaderValue("Basic", token);
|
||||||
|
|
||||||
|
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||||
|
throw new HttpRequestException(
|
||||||
|
$"device link failed: {(int)response.StatusCode} {response.ReasonPhrase} {body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.Content.ReadFromJsonAsync<LinkDeviceResponse>(ct).ConfigureAwait(false)
|
||||||
|
?? throw new InvalidOperationException("empty link response");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fetches prekey bundles for a recipient. <paramref name="deviceId"/> may be "*" for all
|
||||||
|
/// of the recipient's devices. <paramref name="authToken"/> is Basic {aci.deviceId:password}.</summary>
|
||||||
|
public async Task<PreKeyResponse> GetPreKeysAsync(string serviceId, string deviceId, string authToken, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var msg = new HttpRequestMessage(HttpMethod.Get, $"/v2/keys/{serviceId}/{deviceId}");
|
||||||
|
msg.Headers.Authorization = new AuthenticationHeaderValue("Basic", authToken);
|
||||||
|
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||||
|
throw new HttpRequestException($"get prekeys failed: {(int)response.StatusCode} {response.ReasonPhrase} {body}");
|
||||||
|
}
|
||||||
|
return await response.Content.ReadFromJsonAsync<PreKeyResponse>(Json, ct).ConfigureAwait(false)
|
||||||
|
?? throw new InvalidOperationException("empty prekey response");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sends a list of per-device encrypted messages to a destination. Returns the raw body on
|
||||||
|
/// a device-mismatch (409) / stale-devices (410) so the caller can react; throws on other failures.</summary>
|
||||||
|
public async Task<(bool Ok, HttpStatusCode Status, string Body)> SendMessagesAsync(
|
||||||
|
string serviceId, OutgoingMessageList messages, string authToken, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var msg = new HttpRequestMessage(HttpMethod.Put, $"/v1/messages/{serviceId}")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(messages, options: Json),
|
||||||
|
};
|
||||||
|
msg.Headers.Authorization = new AuthenticationHeaderValue("Basic", authToken);
|
||||||
|
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||||
|
string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||||
|
if (response.StatusCode is HttpStatusCode.Conflict or HttpStatusCode.Gone)
|
||||||
|
return (false, response.StatusCode, body);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
throw new HttpRequestException($"send failed: {(int)response.StatusCode} {response.ReasonPhrase} {body}");
|
||||||
|
return (true, response.StatusCode, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Uploads prekeys for an identity (PUT /v2/keys?identity={aci|pni}). Used to register
|
||||||
|
/// one-time prekeys after linking. <paramref name="authToken"/> is Basic {aci.deviceId:password}.</summary>
|
||||||
|
public async Task UploadPreKeysAsync(string identity, SetKeysRequest request, string authToken, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var msg = new HttpRequestMessage(HttpMethod.Put, $"/v2/keys?identity={identity}")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(request, options: Json),
|
||||||
|
};
|
||||||
|
msg.Headers.Authorization = new AuthenticationHeaderValue("Basic", authToken);
|
||||||
|
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||||
|
throw new HttpRequestException($"upload prekeys failed: {(int)response.StatusCode} {response.ReasonPhrase} {body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Long-polls <c>GET /v1/devices/transfer_archive</c> for the link'n'sync message-history archive
|
||||||
|
/// the primary uploads after linking. Returns the descriptor (cdn/key, or an error), or null on a
|
||||||
|
/// 204 timeout (poll again). Auth: Basic {aci.deviceId:password}. SCAFFOLD — see docs/SYNC.md; the
|
||||||
|
/// download+import side isn't built yet.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<TransferArchiveDescriptor?> WaitForTransferArchiveAsync(string authToken, int timeoutSeconds, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var msg = new HttpRequestMessage(HttpMethod.Get, $"/v1/devices/transfer_archive?timeout={timeoutSeconds}");
|
||||||
|
msg.Headers.Authorization = new AuthenticationHeaderValue("Basic", authToken);
|
||||||
|
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||||
|
if (response.StatusCode == HttpStatusCode.NoContent)
|
||||||
|
return null; // long-poll elapsed with no archive yet
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||||
|
throw new HttpRequestException($"wait transfer archive failed: {(int)response.StatusCode} {response.ReasonPhrase} {body}");
|
||||||
|
}
|
||||||
|
return await response.Content.ReadFromJsonAsync<TransferArchiveDescriptor>(Json, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fetches a sealed-sender delivery certificate (GET /v1/certificate/delivery). Returns the
|
||||||
|
/// raw SenderCertificate bytes, valid ~24h; callers should cache it.</summary>
|
||||||
|
public async Task<byte[]> GetSenderCertificateAsync(string authToken, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var msg = new HttpRequestMessage(HttpMethod.Get, "/v1/certificate/delivery");
|
||||||
|
msg.Headers.Authorization = new AuthenticationHeaderValue("Basic", authToken);
|
||||||
|
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||||
|
throw new HttpRequestException($"get delivery certificate failed: {(int)response.StatusCode} {body}");
|
||||||
|
}
|
||||||
|
var dto = await response.Content.ReadFromJsonAsync<DeliveryCertificateDto>(Json, ct).ConfigureAwait(false)
|
||||||
|
?? throw new InvalidOperationException("empty certificate response");
|
||||||
|
return Convert.FromBase64String(dto.Certificate ?? throw new InvalidOperationException("no certificate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sends sealed-sender messages: NO account auth, just the recipient's unidentified-access
|
||||||
|
/// key header. Metadata-minimized. Returns the same (ok/status/body) shape as the authenticated send
|
||||||
|
/// so callers can fall back on rejection.</summary>
|
||||||
|
public async Task<(bool Ok, HttpStatusCode Status, string Body)> SendSealedMessagesAsync(
|
||||||
|
string serviceId, OutgoingMessageList messages, byte[] accessKey, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var msg = new HttpRequestMessage(HttpMethod.Put, $"/v1/messages/{serviceId}")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(messages, options: Json),
|
||||||
|
};
|
||||||
|
msg.Headers.Add("Unidentified-Access-Key", Convert.ToBase64String(accessKey));
|
||||||
|
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||||
|
string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||||
|
return (response.IsSuccessStatusCode, response.StatusCode, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DeliveryCertificateDto { public string? Certificate { get; set; } }
|
||||||
|
|
||||||
|
public void Dispose() => _http.Dispose();
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
namespace Wingnal.Service.Net;
|
||||||
|
|
||||||
|
/// <summary>Endpoints and identifiers for talking to the Signal production service.</summary>
|
||||||
|
public static class SignalServiceConfig
|
||||||
|
{
|
||||||
|
/// <summary>HTTPS base for the chat/account REST API.</summary>
|
||||||
|
public const string ServiceUrl = "https://chat.signal.org";
|
||||||
|
|
||||||
|
/// <summary>WSS base for the authenticated and provisioning WebSockets.</summary>
|
||||||
|
public const string WebSocketUrl = "wss://chat.signal.org";
|
||||||
|
|
||||||
|
/// <summary>HTTPS base for the group storage service (GroupsV2). Separate host from chat, but chains to
|
||||||
|
/// the same bundled Signal CA (<c>SignalTrust</c>), so the existing pin applies.</summary>
|
||||||
|
public const string StorageUrl = "https://storage.signal.org";
|
||||||
|
|
||||||
|
/// <summary>Unauthenticated socket used during secondary-device linking.</summary>
|
||||||
|
public const string ProvisioningWebSocketPath = "/v1/websocket/provisioning/";
|
||||||
|
|
||||||
|
/// <summary>Authenticated chat socket (used post-link to send/receive messages).</summary>
|
||||||
|
public const string ChatWebSocketPath = "/v1/websocket/";
|
||||||
|
|
||||||
|
/// <summary>Sent as the User-Agent / X-Signal-Agent on requests.</summary>
|
||||||
|
public const string UserAgent = "Wingnal";
|
||||||
|
|
||||||
|
/// <summary>CDN base URL for an AttachmentPointer's <c>cdnNumber</c>. 0/absent = legacy cdn0; 2/3 are
|
||||||
|
/// the current attachment/backup CDNs. (cdn1 was retired.)</summary>
|
||||||
|
public static string CdnUrl(uint cdnNumber) => cdnNumber switch
|
||||||
|
{
|
||||||
|
0 => "https://cdn.signal.org",
|
||||||
|
2 => "https://cdn2.signal.org",
|
||||||
|
3 => "https://cdn3.signal.org",
|
||||||
|
_ => "https://cdn2.signal.org",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Net.Security;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Net;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Certificate pinning for Signal's service. chat.signal.org is served from Signal's own private
|
||||||
|
/// root CA (not a publicly trusted authority), so the OS trust store rejects it. This validates the
|
||||||
|
/// server chain against the bundled Signal root only — and trusts nothing else.
|
||||||
|
/// </summary>
|
||||||
|
public static class SignalTrust
|
||||||
|
{
|
||||||
|
private static readonly X509Certificate2 SignalRootCa = LoadRootCa();
|
||||||
|
|
||||||
|
/// <summary>Validation callback for <see cref="System.Net.Http.SocketsHttpHandler"/> and
|
||||||
|
/// <see cref="System.Net.WebSockets.ClientWebSocket"/>: accept only chains anchored at the
|
||||||
|
/// pinned Signal root with a matching hostname.</summary>
|
||||||
|
public static bool Validate(object sender, X509Certificate? certificate, X509Chain? _, SslPolicyErrors errors)
|
||||||
|
{
|
||||||
|
// The hostname is checked by the TLS stack before this callback; never accept a mismatch.
|
||||||
|
if ((errors & SslPolicyErrors.RemoteCertificateNameMismatch) != 0)
|
||||||
|
return false;
|
||||||
|
if (certificate is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
using var leaf = new X509Certificate2(certificate);
|
||||||
|
using var chain = new X509Chain();
|
||||||
|
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||||
|
chain.ChainPolicy.CustomTrustStore.Add(SignalRootCa);
|
||||||
|
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||||
|
return chain.Build(leaf);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static X509Certificate2 LoadRootCa()
|
||||||
|
{
|
||||||
|
Assembly assembly = typeof(SignalTrust).Assembly;
|
||||||
|
const string resource = "Wingnal.Service.Resources.signal-ca.pem";
|
||||||
|
using Stream stream = assembly.GetManifestResourceStream(resource)
|
||||||
|
?? throw new InvalidOperationException($"embedded resource {resource} not found");
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
return X509Certificate2.CreateFromPem(reader.ReadToEnd());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using System.Net.WebSockets;
|
||||||
|
using Google.Protobuf;
|
||||||
|
using Wingnal.Service.Protos;
|
||||||
|
|
||||||
|
namespace Wingnal.Service.Net;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wraps a <see cref="ClientWebSocket"/> with Signal's request/response framing: every binary frame
|
||||||
|
/// is a <see cref="WebSocketMessage"/>. Incoming REQUEST frames are surfaced to the caller; the
|
||||||
|
/// caller answers them with <see cref="SendResponseAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SignalWebSocket : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ClientWebSocket _socket = new();
|
||||||
|
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||||
|
|
||||||
|
public async Task ConnectAsync(Uri uri, IReadOnlyDictionary<string, string>? headers, CancellationToken ct)
|
||||||
|
{
|
||||||
|
_socket.Options.AddSubProtocol("binary");
|
||||||
|
_socket.Options.RemoteCertificateValidationCallback = SignalTrust.Validate;
|
||||||
|
_socket.Options.SetRequestHeader("X-Signal-Agent", SignalServiceConfig.UserAgent);
|
||||||
|
if (headers is not null)
|
||||||
|
foreach ((string name, string value) in headers)
|
||||||
|
_socket.Options.SetRequestHeader(name, value);
|
||||||
|
|
||||||
|
await _socket.ConnectAsync(uri, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the next inbound REQUEST frame, or null if the socket closed.</summary>
|
||||||
|
public async Task<WebSocketRequestMessage?> ReadRequestAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
WebSocketMessage? message = await ReadMessageAsync(ct).ConfigureAwait(false);
|
||||||
|
if (message is null)
|
||||||
|
return null;
|
||||||
|
if (message.Type == WebSocketMessage.Types.Type.Request && message.Request is not null)
|
||||||
|
return message.Request;
|
||||||
|
// RESPONSE / keepalive frames are ignored for the provisioning flow.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SendResponseAsync(ulong id, uint status, string message, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var frame = new WebSocketMessage
|
||||||
|
{
|
||||||
|
Type = WebSocketMessage.Types.Type.Response,
|
||||||
|
Response = new WebSocketResponseMessage { Id = id, Status = status, Message = message },
|
||||||
|
};
|
||||||
|
return SendMessageAsync(frame, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sends a keepalive request (GET /v1/keepalive) to keep the server from dropping us.</summary>
|
||||||
|
public Task SendKeepAliveAsync(ulong id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var frame = new WebSocketMessage
|
||||||
|
{
|
||||||
|
Type = WebSocketMessage.Types.Type.Request,
|
||||||
|
Request = new WebSocketRequestMessage { Id = id, Verb = "GET", Path = "/v1/keepalive" },
|
||||||
|
};
|
||||||
|
return SendMessageAsync(frame, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Describes why the last read ended (close status/description, or the live socket state).</summary>
|
||||||
|
public string CloseReason =>
|
||||||
|
$"state={_socket.State} status={_socket.CloseStatus} desc={_socket.CloseStatusDescription}";
|
||||||
|
|
||||||
|
private async Task<WebSocketMessage?> ReadMessageAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
var chunk = new byte[8192];
|
||||||
|
WebSocketReceiveResult result;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
result = await _socket.ReceiveAsync(chunk, ct).ConfigureAwait(false);
|
||||||
|
if (result.MessageType == WebSocketMessageType.Close)
|
||||||
|
return null;
|
||||||
|
buffer.Write(chunk, 0, result.Count);
|
||||||
|
}
|
||||||
|
while (!result.EndOfMessage);
|
||||||
|
|
||||||
|
return WebSocketMessage.Parser.ParseFrom(buffer.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendMessageAsync(WebSocketMessage message, CancellationToken ct)
|
||||||
|
{
|
||||||
|
byte[] bytes = message.ToByteArray();
|
||||||
|
await _sendLock.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _socket.SendAsync(bytes, WebSocketMessageType.Binary, endOfMessage: true, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_sendLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_socket.Dispose();
|
||||||
|
_sendLock.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace Wingnal.Service.Net;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The descriptor the server returns from <c>GET /v1/devices/transfer_archive</c> once the primary has
|
||||||
|
/// uploaded the link'n'sync message-history archive (Signal-Server <c>RemoteAttachment</c>). The archive
|
||||||
|
/// itself is fetched from the CDN and decrypted with keys derived from the provisioning
|
||||||
|
/// <c>ephemeralBackupKey</c>. See docs/SYNC.md.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TransferArchiveDescriptor
|
||||||
|
{
|
||||||
|
public int Cdn { get; set; }
|
||||||
|
public string? Key { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Set instead of cdn/key when the primary reported it couldn't produce an archive
|
||||||
|
/// (Signal-Server <c>RemoteAttachmentError</c>: e.g. CONTINUE_WITHOUT_UPLOAD / RELINK_REQUESTED).</summary>
|
||||||
|
public string? Error { get; set; }
|
||||||
|
|
||||||
|
public bool IsError => !string.IsNullOrEmpty(Error);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user