Add project files.

This commit is contained in:
micro
2026-06-20 09:31:59 -05:00
parent 9bb3f00d86
commit 4baa4ce8c0
346 changed files with 55751 additions and 0 deletions
+495
View File
@@ -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 68.
- **"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;
}
}
+86
View File
@@ -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;
}
}
+435
View File
@@ -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;
}
}
+125
View File
@@ -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();
}
}
+55
View File
@@ -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);
}
+701
View File
@@ -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]));
}
}
}
+152
View File
@@ -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 &lt; 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;
}
}
+133
View File
@@ -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;
}
}
+23
View File
@@ -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;
}
+83
View File
@@ -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));
}
}
+102
View File
@@ -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;
}
}
+46
View File
@@ -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);
}
+18
View File
@@ -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;
}
}
+27
View File
@@ -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);
}
}
+139
View File
@@ -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;
}
}
+186
View File
@@ -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;
}
}
+85
View File
@@ -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;
}
}
+296
View File
@@ -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) { }
}
+66
View File
@@ -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})";
}
+735
View File
@@ -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;
}
}
+254
View File
@@ -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;
}
}
}
+499
View File
@@ -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; } }
}
+202
View File
@@ -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);
}
}
+21
View File
@@ -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);
}
}
+265
View File
@@ -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;
}
}
+121
View File
@@ -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;
}
}
+54
View File
@@ -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;
}
}
+200
View File
@@ -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);
}
}
+62
View File
@@ -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
{
}
+18
View File
@@ -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;
}
}
+44
View File
@@ -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..]));
}
}
+91
View File
@@ -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^((p5)/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();
}
+52
View File
@@ -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.14.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;
}
}
+192
View File
@@ -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;
}
+114
View File
@@ -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&lt;u8&gt;(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();
}
+48
View File
@@ -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);
}
}
+124
View File
@@ -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;
}
}
+79
View File
@@ -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;
}
}
+78
View File
@@ -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();
}
}
+46
View File
@@ -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.
}
}
}
+14
View File
@@ -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());
}
+52
View File
@@ -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;
}
}
+115
View File
@@ -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);
+100
View File
@@ -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;
}
+39
View File
@@ -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;
}
}
+153
View File
@@ -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,
};
}
+138
View File
@@ -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;
}
}
+335
View File
@@ -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);
}
+215
View File
@@ -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;
}
}
+36
View File
@@ -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);
}
}
+62
View File
@@ -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
}
+45
View File
@@ -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);
+170
View File
@@ -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",
};
}
+44
View File
@@ -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());
}
}
+104
View File
@@ -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