diff --git a/.gitignore b/.gitignore index 841d3a5..21f39a9 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ ignition/deployments/chain-84532 .vercel/ /tests/scan-engine.test.ts /tests/scan-test-report.md + + +#Ignore vscode AI rules +.github/instructions/codacy.instructions.md diff --git a/apps/docs/docs/concepts/handshake.md b/apps/docs/docs/concepts/handshake.md index 98f8f19..bbaf159 100644 --- a/apps/docs/docs/concepts/handshake.md +++ b/apps/docs/docs/concepts/handshake.md @@ -45,7 +45,7 @@ For a deeper analysis of forward secrecy, post-compromise security, and post-qua 1 A planned **Contact KEM** integration will allow recipients to publish ML-KEM keys on-chain, enabling hybrid-encrypted first-message payloads. Under the hood the 2-step handshake flow remains unchanged, but first contact will be protected, hence simulating an offline initiation. -2 Only ephemeral keys participate in key derivation: `SK = KDF(DH(EKa, EKb) || KEM_SS)`. Even if all long-term keys are later compromised, past sessions are unrecoverable because both ephemeral secrets were deleted after use. (Differently, in Signal, if both Bob's identity key and signed prekey are compromised, all sessions established under that prekey are recoverable, and althogh this is mitigated via prekey rotation, there is always an active window.) +2 Only ephemeral keys participate in key derivation: `SK = KDF(DH(EKa, EKb) || KEM_SS)`. Even if all long-term keys are later compromised, past sessions are unrecoverable because both ephemeral secrets were deleted after use. (Differently, in Signal, if both Bob's identity key and signed prekey are compromised, all sessions established under that prekey are recoverable, and although this is mitigated via prekey rotation, there is always an active window.) @@ -53,4 +53,4 @@ For a deeper analysis of forward secrecy, post-compromise security, and post-qua - [Protocol Flow](../how-it-works/protocol-flow.md) — the full step-by-step exchange, on-chain events, and code - [Double Ratchet](./ratchet/double-ratchet.md) — what happens after the handshake -- [Wire Format](../how-it-works/wire-format.md) — how messages are encoded on-chain +- [Wire Formats](../how-it-works/wire-formats.md) — how messages are encoded on-chain diff --git a/apps/docs/docs/concepts/identity.md b/apps/docs/docs/concepts/identity.md index 1567ecc..90ed4f5 100644 --- a/apps/docs/docs/concepts/identity.md +++ b/apps/docs/docs/concepts/identity.md @@ -5,7 +5,7 @@ title: Identity # Identity -In decentralized systems, identity is not as straightforward as it may seem. On blockchains, users are represented by addresses, but messaging protocols rely on encryption and signing keys that may exist indipendently. +In decentralized systems, identity is not as straightforward as it may seem. On blockchains, users are represented by addresses, but messaging protocols rely on encryption and signing keys that may exist independently. Verbeth binds cryptographic messaging keys to Ethereum addresses. A single wallet signature produces all keys, and a signed proof ties them to your address. @@ -57,10 +57,11 @@ A single wallet signature seeds the entire key hierarchy: * The X25519 identity key is a stable, long-term public key bound to the EOA. The double ratchet uses ephemeral keys for forward secrecy, but this key serves as part of the published identity (like a prekey) - for initial key agreement, session reset re-encryption, and any protocol - extension needing an address-bound X25519 key. + for future contact key discovery, session reset re-encryption, and any + protocol extension needing an address-bound X25519 key. ** The session key is always derived but only used by apps that delegate - transactions to a Safe module (e.g. gasless messaging). + transactions to a Safe module. The session signer submits a normal + transaction and pays gas, or a relay service can sponsor it. ``` Properties: @@ -162,4 +163,4 @@ signature = Ed25519.sign_detached(header ‖ ciphertext, ed25519_secret_key) The recipient independently reconstructs the signed blob from the header and ciphertext, then verifies. -See [Wire Format](../how-it-works/wire-format.md) for the full binary layout and verification order. +See [Wire Formats](../how-it-works/wire-formats.md) for the full binary layout and verification order. diff --git a/apps/docs/docs/concepts/ratchet/double-ratchet.md b/apps/docs/docs/concepts/ratchet/double-ratchet.md index d84410d..816dad9 100644 --- a/apps/docs/docs/concepts/ratchet/double-ratchet.md +++ b/apps/docs/docs/concepts/ratchet/double-ratchet.md @@ -22,9 +22,9 @@ The algorithm combines two ratcheting mechanisms: v v v ┌────────┐ ┌────────┐ ┌────────┐ | CK 0 | | CK 0 | | CK 0 | - | → MK₀ | | → MK₀ | | → MK₀ | <-- Symmetric chain ratchet - | → MK₁ | | → MK₁ | | → MK₁ | (per message) - | → MK₂ | | → ... | | → ... | + | → MK_0| | → MK_0| | → MK_0| <-- Symmetric chain ratchet + | → MK_1| | → MK_1| | → MK_1| (per message) + | → MK_2| | → ... | | → ... | └────────┘ └────────┘ └────────┘ ``` @@ -55,7 +55,7 @@ Each ratchet session tracks: | `skippedKeys` | Stored keys for out-of-order messages | | Topic fields | Current, next, and previous topics per direction | -**Session state must be persisted after every encrypt/decrypt operation.** Rolling back to stale state creates duplicate message keys and breaks security guarantees. See [Ratchet Internals](../../how-it-works/ratchet-internals.md) for the full TypeScript interface. +**Session state must be persisted after every encrypt/decrypt operation.** Rolling back to stale state creates duplicate message keys and breaks security guarantees. See [Ratcheting](../../how-it-works/ratcheting.md) for the full TypeScript interface. ### Duplicate message replay @@ -76,5 +76,5 @@ So, these bounds prevent DoS via malicious skip counts while tolerating real-wor ## Next steps - [Topic Ratcheting](./topic-ratcheting.md) -- how conversation topics evolve for metadata privacy -- [Ratchet Internals](../../how-it-works/ratchet-internals.md) -- KDFs, code, full session state -- [Wire Format](../../how-it-works/wire-format.md) -- binary layout of ratchet messages +- [Ratcheting](../../how-it-works/ratcheting.md) -- KDFs, code, full session state +- [Wire Formats](../../how-it-works/wire-formats.md) -- binary layout of ratchet messages diff --git a/apps/docs/docs/concepts/ratchet/topic-ratcheting.md b/apps/docs/docs/concepts/ratchet/topic-ratcheting.md index 9feea5e..3264015 100644 --- a/apps/docs/docs/concepts/ratchet/topic-ratcheting.md +++ b/apps/docs/docs/concepts/ratchet/topic-ratcheting.md @@ -5,17 +5,17 @@ title: Topic Ratcheting # Topic Ratcheting -Topics are like the the on-chain addresses of a given conversation. Every `MessageSent` event is indexed by a `topic` field, and this is how clients find messages meant for them. Topics are derived from shared secrets, so only participants know which topics belong to their conversation. +Topics are like the on-chain addresses of a given conversation. Every `MessageSent` event is indexed by a `topic` field, and this is how clients find messages meant for them. Topics are derived from shared secrets, so only participants know which topics belong to their conversation. The reason for this is that verbeth cares about forward secrecy of metadata, not just content. Each DH ratchet step produces new topics, breaking the link between conversation epochs: ``` -Epoch 0 (Handshake) Epoch 1 (Alice ratchets) Epoch 2 (Bob ratchets) -─────────────────── ────────────────────── ───────────────────── -topicOut_A = H(rk₀, dh₀) topicOut_A = H(rk₁, dh₁) topicOut_A = H(rk₂, dh₂) -topicIn_A = H(rk₀, dh₀) topicIn_A = H(rk₁, dh₁) topicIn_A = H(rk₂, dh₂) - ↑ ↑ ↑ - unlinkable unlinkable unlinkable +Epoch 0 (Handshake) Epoch 1 (Alice ratchets) Epoch 2 (Bob ratchets) +──────────────────────────────────────── ──────────────────────────────────────── ────────────────────── +topicOut_A = H(rk_0, dh_0, "outbound") topicOut_A = H(rk_1, dh_1, "outbound") topicOut_A = H(rk_2, dh_2, "outbound") +topicIn_A = H(rk_0, dh_0, "inbound") topicIn_A = H(rk_1, dh_1, "inbound") topicIn_A = H(rk_2, dh_2, "inbound") + ↑ ↑ ↑ + unlinkable unlinkable unlinkable ``` >An observer watching the blockchain sees messages appear on different topics over time with no way to connect them across epochs. Within a single epoch, consecutive messages from the same party share the same topic, but this reveals nothing beyond the fact that the same party sent multiple messages in the same conversation, which is already implied by the topic existing at all. @@ -28,7 +28,7 @@ Each DH ratchet step feeds the new DH output and current root key into a topic d rootKey (PQ-secure salt) + dhOutput | v - HKDF(dhOutput, rootKey, "verbeth:topic-{direction}:v3", 32) + HKDF(dhOutput, rootKey, "verbeth:topic-{direction}", 32) | v keccak256 → topic (bytes32) @@ -36,7 +36,7 @@ rootKey (PQ-secure salt) + dhOutput The root key acts as HKDF salt. Because the root key descends from the [Handshake](../handshake.md)'s hybrid secret (X25519 + ML-KEM), topic derivation is quantum-resistant, meaning that even if X25519 is broken, topics from different epochs cannot be linked without the root key. -Direction is either `outbound` or `inbound`, so each party sends and listens on different topics. See [Ratchet Internals](../../how-it-works/ratchet-internals.md#topic-derivation) for the exact KDF code. +Direction is either `outbound` or `inbound`, so each party sends and listens on different topics. See [Ratcheting](../../how-it-works/ratcheting.md) for the exact KDF code. ## Grace period @@ -48,5 +48,5 @@ To handle this, the SDK retains the previous inbound topic after a topic transit ## Next steps - [Double Ratchet](./double-ratchet.md) -- the algorithm that drives topic evolution -- [Ratchet Internals](../../how-it-works/ratchet-internals.md) -- KDF code and full session state +- [Ratcheting](../../how-it-works/ratcheting.md) -- KDF code and full session state - [Metadata Privacy](../security/metadata-privacy.md) -- threat analysis including metadata privacy diff --git a/apps/docs/docs/concepts/security/cryptographic-guarantees.md b/apps/docs/docs/concepts/security/cryptographic-guarantees.md index 1e8311c..faf1d87 100644 --- a/apps/docs/docs/concepts/security/cryptographic-guarantees.md +++ b/apps/docs/docs/concepts/security/cryptographic-guarantees.md @@ -54,6 +54,14 @@ This is an honest limitation shared by all major double-ratchet protocols today. For how PQ security compares across protocols, see [here](../handshake.md#other-pq-secure-handshake-protocols). +### Symmetric Encryption: XSalsa20-Poly1305 + +After each ratchet step, the derived message key is used to encrypt the payload with XSalsa20-Poly1305 (`nacl.secretbox`). The message key is 32 bytes (256-bit), produced by HMAC-SHA256 over the current chain key. + +XSalsa20-Poly1305 is post-quantum safe for symmetric encryption. Grover's algorithm, the most relevant quantum attack against symmetric ciphers, provides at most a quadratic speedup, reducing the effective key strength from 256 to 128 bits. 128-bit post-quantum security is above the accepted security threshold. + +This means that even if a future quantum adversary records ciphertext today, they cannot brute-force message keys derived from the ratchet chain, the symmetric layer remains secure regardless of quantum advances. + ### Limitations recap - After the hybrid handshake, ongoing ratchet re-keying uses X25519 only. So Verbeth stays HNDL-resistant against passive recording, because later keys still descend from the original PQ-secure root key. But it does not provide full post-quantum PCS after a live state compromise, since recovery would rely on new X25519 ratchet steps rather than a fresh PQ exchange. diff --git a/apps/docs/docs/concepts/security/metadata-privacy.md b/apps/docs/docs/concepts/security/metadata-privacy.md index f889da9..6f997e5 100644 --- a/apps/docs/docs/concepts/security/metadata-privacy.md +++ b/apps/docs/docs/concepts/security/metadata-privacy.md @@ -27,7 +27,7 @@ Let's say 0x123 emits a `Handshake` whose `recipientHash` is `hash(0x789)`, and **Against a quantum adversary**: A quantum computer could solve the X25519 DH from the public keys. But the tag derivation *also* requires the ML-KEM shared secret. The ML-KEM ciphertext is inside the encrypted response payload which cannot be decrypted without 0x123's ephemeral secret. So even a quantum adversary cannot link the handshake to its response. -For the exact tag computation, see [Protocol Flow](../../how-it-works/protocol-flow.md#hybrid-tag-computation). +For the exact tag computation, see [Protocol Flow](../../how-it-works/protocol-flow.md#two-keypairs-in-the-response). ## Handshake-to-Message Unlinkability @@ -56,4 +56,8 @@ When you query for messages, the RPC sees which topics you subscribe to, when yo - **Decoy queries**: noise injection to obscure real interest patterns - **Query aggregation**: batching queries across multiple topics to reduce per-topic signal -A future integration of [Labeled Private Set Intersection](../../roadmap/metadata-privacy-psi.md) will allow clients to query an untrusted indexer without revealing which topics or recipients they are interested in. The indexer sees only opaque, homomorphically encrypted queries, making topic-level correlation infeasible even for a fully malicious provider. \ No newline at end of file +A future integration of will allow clients to query an untrusted indexer without revealing which topics or recipients they are interested in. The indexer sees only opaque, homomorphically encrypted queries, making topic-level correlation infeasible even for a fully malicious provider. + +## Ciphertext Length Analysis + +On-chain ciphertext lengths are visible to everyone. Without mitigation, message length alone can reveal information (e.g. distinguishing a "yes" from a paragraph). See [here](../../how-it-works/ratcheting.md#ciphertext-padding) how Verbeth pads all plaintext into fixed-size buckets before encryption. \ No newline at end of file diff --git a/apps/docs/docs/how-it-works/message-store.md b/apps/docs/docs/how-it-works/message-store.md new file mode 100644 index 0000000..534a20c --- /dev/null +++ b/apps/docs/docs/how-it-works/message-store.md @@ -0,0 +1,62 @@ +--- +sidebar_position: 5 +title: Message Store +--- + +# Message Store + +The SDK does not prescribe a storage backend. It defines two interfaces that applications implement to connect `VerbethClient` to whatever persistence layer they choose. This page explains what those interfaces require and where the boundary sits between SDK logic and app logic. + +## SessionStore + +The `SessionStore` interface (`client/types.ts`) has three methods. + +```typescript +interface SessionStore { + get(conversationId: string): Promise; + getByInboundTopic(topic: string): Promise; + save(session: RatchetSession): Promise; +} +``` + +`get()` retrieves a session by its primary key, which is the `conversationId` (a `keccak256` hash of the sorted topic pair). + +`getByInboundTopic()` is the critical query. When an on-chain message event arrives, the SDK knows only the topic. It needs to find the corresponding session. This lookup is the app's responsibility. The SDK's internal `SessionManager` (`client/SessionManager.ts`) provides caching and topic promotion logic, but it delegates the actual database query to this method. + +The store must be able to find a session where the given topic matches any of `currentTopicInbound`, `nextTopicInbound`, or `previousTopicInbound`. This typically means indexing the session table on all three fields. + +`save()` persists the session state after every encrypt or decrypt operation. Failing to persist means the ratchet can roll back to stale state, which breaks forward secrecy and can create duplicate message keys. + +## PendingStore + +The `PendingStore` interface (`client/types.ts`) manages the lifecycle of outbound messages. + +```typescript +interface PendingStore { + save(pending: PendingMessage): Promise; + get(id: string): Promise; + getByTxHash(txHash: string): Promise; + updateStatus(id: string, status: PendingStatus, txHash?: string): Promise; + delete(id: string): Promise; + getByConversation(conversationId: string): Promise; +} +``` + +A `PendingMessage` moves through two active states: `preparing` (created before tx submission) and `submitted` (tx broadcast, txHash known). On confirmation the record is deleted, so there is no persistent `confirmed` state. On failure, `markFailed()` handles submit-time errors (tx never broadcast), while `revertTx()` handles post-broadcast reverts, so both remove the pending record. The `getByTxHash` index allows matching on-chain events back to pending records. See [VerbethClient](./verbeth-client.md) for the two-phase commit pattern that uses this store. + +## Serialization + +`RatchetSession` contains binary fields: `rootKey`, `dhMySecretKey`, `dhMyPublicKey`, `dhTheirPublicKey`, `sendingChainKey`, `receivingChainKey`, and the `messageKey` inside each `SkippedKey` entry. These are all `Uint8Array` values that most storage backends cannot persist directly. + +Applications must handle the conversion between binary arrays and whatever format their database supports. Common approaches include base64 encoding for IndexedDB, hex encoding for SQL, or binary columns where available. The important thing is that the round-trip is lossless and that sensitive fields (root key, chain keys, DH secret keys, message keys) are treated with the same care as any cryptographic secret. + + +## Ephemeral state + +Some state lives outside the two store interfaces and requires separate attention. + +**Pending handshakes.** Between sending a handshake and receiving its response, the ephemeral secret key and the KEM secret key must be stored somewhere. The SDK returns them from `sendHandshake()` and expects them back when `createInitiatorSession()` or `createInitiatorSessionFromHsr()` is called. The app is responsible for persisting these across sessions. In an app build with Verbeth, they live in the `pendingHandshakes` table alongside the contact address and timestamp. + +**Session cache.** `SessionManager` maintains an in-memory `Map` that avoids repeated database reads for the same session. The cache is invalidated when `invalidateSessionCache()` is called, or cleared entirely with `clearSessionCache()`. This cache is ephemeral and rebuilt on demand. + +**Topic transition windows.** The `previousTopicInbound` and its expiry timestamp are part of the persisted `RatchetSession`, not ephemeral. But the decision to listen on that topic is made by the `SessionManager` during topic lookup. The store just needs to return sessions where any of the three topic fields match. diff --git a/apps/docs/docs/how-it-works/protocol-flow.md b/apps/docs/docs/how-it-works/protocol-flow.md index 84acbfc..5b77d81 100644 --- a/apps/docs/docs/how-it-works/protocol-flow.md +++ b/apps/docs/docs/how-it-works/protocol-flow.md @@ -1,124 +1,132 @@ --- -sidebar_position: 2 -title: Protocol Flow (wip) +sidebar_position: 1 +title: Protocol Flow --- -# Protocol Flow (PAGE WIP...) +# Protocol Flow -This page details the full handshake exchange — the sequence of on-chain events, cryptographic operations, and key derivations that establish an encrypted channel. For the conceptual overview, see [Handshake](../concepts/handshake.md). +This page walks through the full handshake exchange and the post-handshake messaging lifecycle, including how topics evolve across ratchet epochs. -## Handshake Sequence +## Handshake sequence + +The [handshake](../concepts/handshake.md) turns two strangers into a pair sharing a post-quantum root key. Everything happens through two on-chain events. ``` -Alice (Initiator) Bob (Responder) -───────────────── ─────────────── +Alice (Initiator) Bob (Responder) +───────────────── ─────────────── 1. Generate ephemeral X25519 keypair (a, A) 2. Generate ML-KEM-768 keypair (kemPk, kemSk) -3. Create identity proof - - ─────── Handshake Event ───────► - │ recipientHash: H(bob_addr) │ - │ ephemeralPubKey: A │ - │ kemPublicKey: kemPk │ - │ identityProof: {...} │ - └───────────────────────────────┘ - - 4. Generate ephemeral keypair (r, R) - 5. Compute X25519: x_ss = ECDH(r, A) - 6. Encapsulate KEM: (ct, kem_ss) = Encap(kemPk) - 7. Compute hybrid tag: - tag = HKDF(x_ss || kem_ss, "verbeth:hsr-hybrid:v1") - 8. Encrypt response to A - - ◄───── HandshakeResponse ────── - │ inResponseTo: tag │ - │ responderEphemeralR: R │ - │ ciphertext: Enc(A, response) │ - └───────────────────────────────┘ - -9. Decrypt response, extract R, ct -10. Compute X25519: x_ss = ECDH(a, R) -11. Decapsulate KEM: kem_ss = Decap(ct, kemSk) -12. Verify tag matches -13. Derive root key from hybrid secret - - ═══════ Channel Established ═══════ +3. Create identity binding proof (ECDSA) + + ──────────── Handshake event ────────────► + │ recipientHash: keccak256("contact:" + bob) + │ pubKeys: [0x01 ‖ X25519_id ‖ Ed25519_id] + │ ephemeralPubKey: [A ‖ kemPk] (1216 bytes) + │ plaintextPayload: { plaintextPayload, identityProof } + └───────────────────────────────────────── + + 4. Generate tag keypair (r, R) + 5. Generate ratchet keypair (rk_s, rk_p) + 6. ECDH: x_ss = DH(r, A) + 7. KEM encapsulate: (ct, kem_ss) = Encap(kemPk) + 8. Compute hybrid tag from x_ss and kem_ss + 9. Encrypt response payload to A using rk_s + + ◄────────── HandshakeResponse event ────────── + │ inResponseTo: hybrid_tag + │ responderEphemeralR: R (tag pubkey, not rk_p) + │ ciphertext: NaCl.box(response, A, rk_s) + └───────────────────────────────────────────── + +10. Decrypt response, extract rk_p and ct +11. ECDH: x_ss = DH(a, R) +12. KEM decapsulate: kem_ss = Decap(ct, kemSk) +13. Verify hybrid tag matches inResponseTo +14. Derive hybrid root key from x_ss ‖ kem_ss + + ═══════════ Channel established ═══════════ ``` -## Hybrid Tag Computation +### Two keypairs in the response + +The responder generates two separate X25519 keypairs. The tag keypair `(r, R)` is used only for the hybrid tag computation. `R` goes on-chain as `responderEphemeralR`. The ratchet keypair `(rk_s, rk_p)` goes inside the encrypted payload and becomes the first DH key in the double ratchet session. + +This separation matters because without it, the on-chain `R` would equal the first message's DH header key, allowing an observer to link the `HandshakeResponse` to the subsequent conversation. -The `inResponseTo` tag links a response to its handshake using the hybrid secret. This prevents on-chain observers from correlating the two events: +>**Hybrid tag computation**: +The `inResponseTo` tag combines both classical and post-quantum shared secrets so that neither a classical nor a quantum adversary can link the response to its handshake. The computation in `crypto.ts` works as follows. -```typescript -function computeHybridTag( - ecdhSecret: Uint8Array, // X25519 shared secret - kemSecret: Uint8Array // ML-KEM shared secret -): `0x${string}` { - const okm = hkdf(sha256, kemSecret, ecdhSecret, "verbeth:hsr-hybrid:v1", 32); - return keccak256(okm); -} ``` +ecdhShared = X25519(r, A) +okm = HKDF-SHA256(ikm=kemSecret, salt=ecdhShared, info="verbeth:hsr-hybrid:v1", len=32) +tag = keccak256(okm) +``` + +The initiator repeats this with their own private key `a` and the on-chain `R` to verify the match. Without both secrets, the tag is computationally indistinguishable from random. See [Metadata Privacy](../concepts/security/metadata-privacy.md#handshake-response-unlinkability) for the full threat analysis. -Observers cannot link `HandshakeResponse` to its `Handshake` without the shared secrets. See [Metadata Privacy](../concepts/security/metadata-privacy.md#handshake-response-unlinkability) for detailed analysis against classical and quantum adversaries. -## Root Key Derivation +### Root key derivation -The initial root key for the [Double Ratchet](../concepts/ratchet/double-ratchet.md) combines both secrets: +Once both parties hold the X25519 shared secret and the ML-KEM shared secret, they combine them into a single hybrid root key (see `ratchet/kdf.ts`). -```typescript -function hybridInitialSecret( - x25519Secret: Uint8Array, - kemSecret: Uint8Array -): Uint8Array { - const combined = concat([x25519Secret, kemSecret]); - return hkdf(sha256, combined, zeros(32), "VerbethHybrid", 32); -} +``` +combined = x25519Secret ‖ kemSecret +hybridRoot = HKDF-SHA256(ikm=combined, salt=zeros(32), info="VerbethHybrid", len=32) ``` -This root key is post-quantum secure. All subsequent ratchet keys derive from it, propagating PQ security through the entire conversation. +All subsequent ratchet keys descend from this root. Because it incorporates ML-KEM, the entire session is post-quantum secure from message zero. See [Ratcheting](./ratcheting.md) for the formal derivation chain. -## On-Chain Events +## Session bootstrapping -### Handshake Event +The initiator and responder initialize their ratchet sessions differently (see `ratchet/session.ts`). -```solidity -event Handshake( - bytes32 indexed recipientHash, - address indexed sender, - bytes ephemeralPubKey, // 32 bytes X25519 - bytes kemPublicKey, // 1184 bytes ML-KEM-768 - bytes plaintextPayload // Identity proof + note -); -``` +**Responder (Bob)** computes `DH(rk_s, A)` and derives `(RK_0, CK_0_send)` from the hybrid root key. Bob can send immediately but has no receiving chain yet. That gets established when Alice's first message arrives carrying a new DH public key. + +**Initiator (Alice)** derives the same `(RK_0, CK_0)` that Bob did, then immediately performs one DH ratchet step. Alice generates a fresh keypair, computes `DH(sk_1, rk_p)`, and derives `(RK_1, CK_1_send)`. She sets `CK_0` as her receiving chain key so she can decrypt Bob's messages right away. Alice also pre-computes epoch 1 topics at this point (see `session.ts`), so she already knows where to listen before any message is sent. + +## Post-handshake messaging and topic lifecycle -### HandshakeResponse Event +After the handshake, messages flow through ratchet topics. Topics are bytes32 values derived from the root key and DH output at each ratchet step, and they serve as the on-chain address of the conversation (see [Topic Ratcheting](../concepts/ratchet/topic-ratcheting.md) for the rationale). -```solidity -event HandshakeResponse( - bytes32 indexed inResponseTo, // Hybrid tag - address indexed responder, - bytes responderEphemeralR, // 32 bytes X25519 - bytes ciphertext // Encrypted response (includes KEM ciphertext) -); +The topic lifecycle is more nuanced than "topics change at every DH ratchet step." It involves pre-computation, promotion, grace windows, and convergence. + +``` +Epoch 0 (handshake) Epoch 1 (Alice sends) Epoch 2 (Bob sends) +─────────────────── ───────────────────── ──────────────────── + +Alice inits session: Alice's first message: Bob receives, ratchets: + currentTopic = epoch0 emitted on epoch0 topic sees new DH key from Alice + nextTopic = epoch1 (precomp) (outbound not yet promoted) computes epoch2 topics + promotes epoch1 → current +Bob inits session: Bob receives: retains epoch1 as previous + currentTopic = epoch0 message arrives on epoch0 (5 min grace window) + nextTopic = none or possibly on pre-computed + nextTopic if timing overlaps ``` -## Gas Considerations +>The diagram above shows the typical flow where Alice sends first, but this ordering is not required as shown [here](./ratcheting.md#initiator-vs-responder-asymmetry). + +### How topic transitions work + +1. **Pre-computation.** When the initiator bootstraps, epoch 1 topics are already computed and stored as `nextTopicOutbound` / `nextTopicInbound` (see `session.ts`). During a DH ratchet step, the same happens for the next epoch (see `decrypt.ts`). -| Component | Size | Notes | -|-----------|------|-------| -| X25519 ephemeral | 32 bytes | Minimal | -| ML-KEM public key | 1184 bytes | Dominates handshake cost | -| ML-KEM ciphertext | 1088 bytes | In encrypted response | -| Identity proof | ~500 bytes | Signature + message | +2. **Promotion.** When a message arrives on `nextTopicInbound`, the `SessionManager` promotes it to `currentTopicInbound`. The old current topic moves to `previousTopicInbound`. See `SessionManager.ts`. -Handshake initiation costs more due to the KEM public key. The response is encrypted, so the KEM ciphertext is hidden in the blob. +3. **Grace window.** The previous inbound topic is retained with a `TOPIC_TRANSITION_WINDOW_MS` (5 minutes) expiry timestamp. This handles messages that were sent before the ratchet step but are still in the mempool or delayed by block reordering. -## Executor Abstraction +4. **Convergence.** When the next DH ratchet step occurs, `previousTopicInbound` is overwritten with whatever was current at that point. The old previous topic is discarded. So at most three inbound topics are active at any time (current, next, previous). -Handshake transactions can be sent via: +This is important for blockchain delivery because messages can arrive out of order across block boundaries. The combination of pre-computed next topics, a grace window for old topics, and the skip key mechanism (see [Ratcheting](./ratcheting.md)) ensures no legitimate messages are lost during topic transitions. -- **EOA**: Direct wallet transaction -- **Safe Module**: Session key authorized by Safe +### Message flow within an epoch + +Within a single DH epoch, all messages from the same sender share the same topic. The symmetric chain ratchet advances the chain key for each message, producing a unique message key every time. The on-chain event carries the sender address, topic, and the encrypted binary payload described in [Wire Formats](./wire-formats.md). + +``` +sender: 0xAlice +topic: epoch1_outbound +payload: [version ‖ Ed25519_sig ‖ header{dh, pn, n} ‖ ciphertext] +``` -The identity proof's `ExecutorAddress` field specifies which address will send the transaction, enabling verification regardless of executor type. +The recipient finds the message by filtering for their active inbound topics, verifies the Ed25519 signature over header and ciphertext, then feeds the header into the ratchet for decryption. If the header carries a new DH public key, a DH ratchet step is triggered, which advances the epoch and derives new topics. diff --git a/apps/docs/docs/how-it-works/ratchet-internals.md b/apps/docs/docs/how-it-works/ratchet-internals.md deleted file mode 100644 index f3dc1b3..0000000 --- a/apps/docs/docs/how-it-works/ratchet-internals.md +++ /dev/null @@ -1,186 +0,0 @@ ---- -sidebar_position: 3 -title: Ratchet Internals (wip) ---- - -# Ratchet Internals (PAGE WIP...) - -Implementation details of the Double Ratchet. For the conceptual overview, see [Double Ratchet](../concepts/ratchet/double-ratchet.md). - -## Session initialization - -Initiator and responder initialize differently: - -**Responder** (Bob): receives Alice's ephemeral DH public key from the `Handshake` event, computes the DH output, and derives the first receiving chain key. Bob's initial state has a `receivingChainKey` but no `sendingChainKey` -- that comes when Bob sends his first message and performs a DH ratchet step. - -**Initiator** (Alice): receives Bob's ephemeral from the `HandshakeResponse`, computes the DH output, and derives the first sending chain key. Alice also pre-computes epoch 1 topics (the topics that will be active after Bob's first DH ratchet step), so she can listen on them immediately. - -Both derive the root key from the hybrid secret: `hybridInitialSecret(x25519Secret, kemSecret)`. See [Protocol Flow](./protocol-flow.md) for the full handshake sequence. - -## Key derivation functions - -### Root key derivation - -When a DH ratchet step occurs, the new DH output and current root key produce a fresh root key and chain key: - -```typescript -function kdfRootKey(rootKey: Uint8Array, dhOutput: Uint8Array) { - // HKDF-SHA256: dhOutput as IKM, rootKey as salt - const output = hkdf(sha256, dhOutput, rootKey, 'VerbethRatchet', 64); - return { - rootKey: output.slice(0, 32), // New root key - chainKey: output.slice(32, 64), // New chain key - }; -} -``` - -### Chain key derivation - -For each message in a chain, HMAC-SHA256 derives a unique message key and advances the chain: - -```typescript -function kdfChainKey(chainKey: Uint8Array) { - return { - messageKey: hmac(sha256, chainKey, [0x01]), // Encrypt/decrypt this message - chainKey: hmac(sha256, chainKey, [0x02]), // Next chain key - }; -} -``` - -### Hybrid initial secret - -Combines X25519 and ML-KEM shared secrets into the initial root key: - -```typescript -function hybridInitialSecret(x25519Secret: Uint8Array, kemSecret: Uint8Array) { - const combined = concat([x25519Secret, kemSecret]); - return hkdf(sha256, combined, zeros(32), 'VerbethHybrid', 32); -} -``` - -### Topic derivation - -Each DH ratchet step derives new topics using the root key as PQ-secure salt: - -```typescript -function deriveTopic( - rootKey: Uint8Array, - dhOutput: Uint8Array, - direction: 'outbound' | 'inbound' -): `0x${string}` { - const info = `verbeth:topic-${direction}:v3`; - const okm = hkdf(sha256, dhOutput, rootKey, info, 32); - return keccak256(okm); -} -``` - -## Encrypt flow - -1. If no `sendingChainKey` exists, perform a DH ratchet step: generate new DH keypair, compute DH output with their public key, derive new root key + sending chain key, derive new topics -2. Derive `messageKey` and advance `sendingChainKey` via `kdfChainKey` -3. Encrypt plaintext with `nacl.secretbox` using `messageKey` -4. Build header: `{ dh: myDHPublicKey, pn: previousChainLength, n: sendingMsgNumber }` -5. Sign `header || ciphertext` with Ed25519 -6. Encode as binary payload (see [Wire Format](./wire-format.md)) -7. Return updated session state -- **caller must persist it** - -## Decrypt flow - -1. Parse binary payload, extract version + signature + header + ciphertext -2. **Signature-first verification**: verify Ed25519 signature over `header || ciphertext` before any ratchet operations (DoS protection -- O(1) rejection of invalid messages) -3. Check skipped keys: if header's DH key + message number matches a stored skip key, decrypt with that key and remove it -4. If header's DH key differs from `dhTheirPublicKey`, perform a DH ratchet step: store skip keys for remaining messages in current receiving chain, compute new root key + receiving chain key from new DH output -5. Skip forward in current chain if `header.n > receivingMsgNumber` -6. Derive `messageKey` via `kdfChainKey`, decrypt ciphertext -7. Return updated session state -- **caller must persist it** - -## DH ratchet step - -When a new DH public key arrives in a message header: - -1. Store skip keys for any unreceived messages in the current receiving chain (up to `MAX_SKIP_PER_MESSAGE`) -2. Compute `dhOutput = X25519(dhMySecretKey, newTheirPublicKey)` -3. `kdfRootKey(rootKey, dhOutput)` → new `rootKey` + `receivingChainKey` -4. Generate fresh DH keypair for sending -5. Compute `dhOutput = X25519(newMySecretKey, newTheirPublicKey)` -6. `kdfRootKey(rootKey, dhOutput)` → new `rootKey` + `sendingChainKey` -7. Derive new topics from step 5's DH output (outbound/inbound labels swap relative to the peer) -8. Set grace period on previous inbound topic (`TOPIC_TRANSITION_WINDOW_MS`) -9. Reset `sendingMsgNumber = 0`, `receivingMsgNumber = 0`, save `previousChainLength` - -## Skip key management - -Stored as an array of `SkippedKey` entries: - -```typescript -interface SkippedKey { - dhPubKeyHex: string; // DH epoch identifier - msgNumber: number; // Message number in that epoch - messageKey: Uint8Array; // Derived message key (32 bytes) - createdAt: number; // Timestamp for TTL pruning -} -``` - -**Pruning strategy:** -- Before storing new skip keys, prune entries older than `MAX_SKIPPED_KEYS_AGE_MS` (24h) -- If storage exceeds `MAX_STORED_SKIPPED_KEYS` (1,000), drop the oldest entries -- A single message cannot request more than `MAX_SKIP_PER_MESSAGE` (100,000) skips - -## DoS protection - -Ratchet state is expensive to mutate (DH computations, chain key derivations). To prevent attackers from triggering these operations with invalid messages: - -1. Ed25519 signature is verified **before** any ratchet processing -2. Verification is O(1) and cheap compared to ratchet operations -3. Invalid messages are rejected without touching session state - -See [Wire Format](./wire-format.md) for the binary layout that enables signature-first parsing. - -## Full session state - -The complete `RatchetSession` interface from the SDK: - -```typescript -interface RatchetSession { - // Conversation Identity - conversationId: string; // keccak256(sort([topicOut, topicIn])) - topicOutbound: `0x${string}`; // Original handshake-derived outbound topic - topicInbound: `0x${string}`; // Original handshake-derived inbound topic - myAddress: string; - contactAddress: string; - - // Root Ratchet - rootKey: Uint8Array; // 32 bytes, PQ-secure - - // DH Ratchet Keys - dhMySecretKey: Uint8Array; // My current DH secret (32 bytes) - dhMyPublicKey: Uint8Array; // My current DH public (32 bytes) - dhTheirPublicKey: Uint8Array; // Their last DH public (32 bytes) - - // Sending Chain - sendingChainKey: Uint8Array | null; // null until first DH ratchet as sender - sendingMsgNumber: number; // Ns - - // Receiving Chain - receivingChainKey: Uint8Array | null; // null until first message received - receivingMsgNumber: number; // Nr - - // Skip Handling - previousChainLength: number; // PN header field - skippedKeys: SkippedKey[]; - - // Topic Ratcheting - currentTopicOutbound: `0x${string}`; // May differ from original after ratcheting - currentTopicInbound: `0x${string}`; - nextTopicOutbound?: `0x${string}`; // Pre-computed for next DH step - nextTopicInbound?: `0x${string}`; - previousTopicInbound?: `0x${string}`; // Grace period for late messages - previousTopicExpiry?: number; // Date.now() + TOPIC_TRANSITION_WINDOW_MS - topicEpoch: number; - - // Metadata - createdAt: number; - updatedAt: number; - epoch: number; // Increments on session reset -} -``` diff --git a/apps/docs/docs/how-it-works/ratcheting.md b/apps/docs/docs/how-it-works/ratcheting.md new file mode 100644 index 0000000..c1b5df1 --- /dev/null +++ b/apps/docs/docs/how-it-works/ratcheting.md @@ -0,0 +1,172 @@ +--- +sidebar_position: 2 +title: Ratcheting +--- + +# Ratcheting + +This page covers the encryption and decryption flows in detail, the formal key derivation math, and the bootstrapping process that makes the entire chain post-quantum secure. For forward secrecy and post-compromise security, see this [conceptual overview](../concepts/ratchet/double-ratchet.md). + +## Encrypt flow + +``` +prepareMessage(conversationId, plaintext) + │ + ▼ + Load RatchetSession from store + │ + ▼ + sendingChainKey exists? + ┌─────┴──────┐ + NO YES + │ │ + error ▼ + kdfChainKey(CK) + ├── messageKey = HMAC-SHA256(CK, 0x01) + └── newChainKey = HMAC-SHA256(CK, 0x02) + │ + ▼ + Pad plaintext (bucket scheme) + │ + ▼ + XSalsa20-Poly1305 encrypt with messageKey + (24-byte random nonce prepended) + │ + ▼ + Build header { dh: myDHPublicKey, pn, n } + │ + ▼ + Ed25519.sign(header ‖ ciphertext, signingSecretKey) + │ + ▼ + Package binary payload (see Wire Formats) + │ + ▼ + Persist updated session immediately + (forward secrecy: key is gone) + │ + ▼ + Return PreparedMessage +``` + +Session state is committed before the transaction is submitted. If the transaction fails, the ratchet slot is "burned" and the receiver handles the gap through skip keys. See [VerbethClient](./verbeth-client.md) for the two-phase commit pattern. + +## Decrypt flow + +``` +decryptMessage(topic, payload, senderSigningKey) + │ + ▼ + Find session by inbound topic + (current → next → previous) + │ + ▼ + Parse binary payload + ├── signature = bytes[1..65] + ├── header = bytes[65..105] + └── ciphertext = bytes[105..] + │ + ▼ + Ed25519.verify(signature, header ‖ ciphertext, senderSigningKey) + │ + ┌────┴────┐ + FAIL PASS + │ │ + drop ▼ + silently Check skipped keys for (dhPubHex, n) + ┌────┴────┐ + FOUND NOT FOUND + │ │ + decrypt ▼ + with it header.dh ≠ dhTheirPublicKey? + ┌────┴────┐ + YES NO + │ │ + ▼ ▼ + DH ratchet Skip forward if n > Nr + step │ + │ ▼ + ▼ kdfChainKey → messageKey + kdfChainKey → messageKey + │ + ▼ + XSalsa20-Poly1305 decrypt + │ + ▼ + Unpad plaintext + │ + ▼ + Persist updated session +``` + +Signature verification happens first, before any ratchet state is touched. This is O(1) and rejects invalid or malicious messages without risking state corruption. + + +## Formal key derivation + +### DH ratchet step + +``` +(RK_i+1, CK_i+1) ← HKDF-SHA256( ikm = dh_i+1, salt = RK_i ) +``` + +where `dh_i+1 = X25519(sk_new, pk_their)` is the new DH shared secret. The HKDF info string is `"VerbethRatchet"` and the output length is 64 bytes, split into a 32-byte root key and a 32-byte chain key (see `ratchet/kdf.ts`). + +### Symmetric ratchet step + +``` +MK_i = HMAC-SHA256(CK_i, 0x01) +CK_i+1 = HMAC-SHA256(CK_i, 0x02) +``` + +`MK_i` is the per-message encryption key fed into XSalsa20-Poly1305. `CK_i+1` replaces `CK_i` and is used for the next message in the same epoch. + +### Bootstrapping from the hybrid shared secret + +``` +(RK_0, CK_0) ← HKDF-SHA256( ikm = ss_hyb, salt = 0 [no prior RK] ) +``` + +The initial root key and chain key are derived from the hybrid shared secret (in `hybridInitialSecret()`) with a zero salt (no prior root key exists). The hybrid secret itself combines both key exchange outputs: + +``` +ss_hyb = HKDF-SHA256( ikm = X25519_ss ‖ KEM_ss, salt = 0_32, info = "VerbethHybrid" ) +``` + +### Initiator vs responder asymmetry + +The **responder** (Bob) derives `(RK_0, CK_0_send)` directly from the bootstrapping step. Bob can send messages right away using `CK_0_send`, but has no receiving chain key yet. + +The **initiator** (Alice) derives the same `(RK_0, CK_0)`, then immediately performs one DH ratchet step with a fresh keypair. + +``` +dh_1 = X25519(sk_1, pk_Bob) + +(RK_1, CK_1_send) ← HKDF-SHA256( ikm = dh_1, salt = RK_0 ) +``` + +Alice sets `CK_0` as her receiving chain key (matching Bob's sending chain), and uses `CK_1_send` for her own messages. She also pre-computes epoch 1 topics from `RK_1` and `dh_1` at this point (see `ratchet/session.ts`). + +When Bob receives Alice's first message, he sees a new DH public key in the header, triggers his own DH ratchet step, and the protocol enters steady-state alternation. + +## Skip key management + +Blockchain delivery does not guarantee message ordering. When a message with number `n` arrives but the receiver expected `m` (where `m < n`), the ratchet pre-derives and stores message keys for positions `m` through `n-1`. + +| Constant | Value | Purpose | +|----------|-------|---------| +| `MAX_SKIP_PER_MESSAGE` | 100,000 | Reject messages requesting excessive skips | +| `MAX_STORED_SKIPPED_KEYS` | 1,000 | Prune oldest entries when storage exceeds this | +| `MAX_SKIPPED_KEYS_AGE_MS` | 24 hours | TTL for stored skip keys | + +Each stored skip key records the DH epoch identifier (hex of their public key), message number, derived message key, and creation timestamp. Expired-key pruning is not performed inline during `skipMessages()` but it is a separate helper (`pruneExpiredSkippedKeys()`) that applications call at their discretion (e.g. applying it on session load). If the total exceeds 1,000 after pruning, the oldest entries are dropped (see `ratchet/decrypt.ts`). + +Message keys are single-use. After successful decryption, the key is removed from the store (or the chain has advanced past it). Replaying the same ciphertext fails because no matching key exists. This guarantee depends on persisting session state after every decrypt. + +## Ciphertext padding + +Before encryption, plaintext is padded into fixed-size buckets to reduce metadata leakage from on-chain ciphertext lengths. The scheme in `ratchet/padding.ts` works as follows. + +The plaintext is framed with a 1-byte marker (`0x00`) and a 4-byte big-endian length prefix, then placed into a bucket. Bucket selection uses power-of-2 sizes from a minimum of 64 bytes up to 16,384 bytes. Above that threshold, buckets grow in linear steps of 4,096 bytes. On top of the bucket size, a random jitter of up to `bucket / 8` bytes is added. + +The padding bytes are filled with `nacl.randomBytes`, making them indistinguishable from ciphertext after encryption. An observer sees the total encrypted blob size, which reveals at most O(log n) bits about the plaintext length within the exponential range. diff --git a/apps/docs/docs/how-it-works/verbeth-client.md b/apps/docs/docs/how-it-works/verbeth-client.md new file mode 100644 index 0000000..47b5390 --- /dev/null +++ b/apps/docs/docs/how-it-works/verbeth-client.md @@ -0,0 +1,87 @@ +--- +sidebar_position: 3 +title: Client +--- + +# Verbeth Client + +`VerbethClient.ts` is the high-level API that ties together handshakes, session management, message encryption, and transaction submission. + +## Data model + +VerbethClient holds references to an executor, an identity keypair, an identity proof, a signer, and the user's address. On top of that, it wraps two internal coordinators that connect it to the app's persistence layer. + +**SessionManager** (`client/SessionManager.ts`) provides an in-memory cache over the `SessionStore` interface. It handles topic-based routing by checking inbound topics in order (current, then next, then previous) and automatically promotes `nextTopicInbound` to `currentTopicInbound` when a message arrives on it. The cache is write-through, meaning every `save()` updates both cache and store. + +**PendingManager** (`client/PendingManager.ts`) tracks the lifecycle of outbound messages through two active states: `preparing` → `submitted`. On confirmation the pending record is deleted (there is no `confirmed` state). On failure there are two paths: `markFailed()` at submit time if the transaction was never broadcast, or `revertTx()` after broadcast if the on-chain transaction reverts. Both clean up the pending record. It wraps the `PendingStore` interface and handles creation, status updates, and stale record cleanup. + +Both managers are optional. If only encryption and decryption are needed (without full send/confirm lifecycle), only `SessionStore` is required. `PendingStore` is needed for `sendMessage()`, `confirmTx()`, and `revertTx()`. + +## Two-phase commit + +Message sending follows a two-phase commit pattern to preserve forward secrecy: + +1. `prepareMessage()` encrypts the plaintext, advances the ratchet chain, and immediately persists the new session state. At this point the message key is gone and cannot be recovered. + +2. The transaction is submitted via the executor. If it succeeds, `confirmTx()` cleans up the pending record. If it fails, `revertTx()` does the same cleanup, but the ratchet slot is already burned. The receiver handles the resulting gap through the skip key mechanism described [here](./ratcheting.md). + +This design means session state is always ahead of on-chain reality. A crash between step 1 and step 2 is safe because the pending record survives in the store, and the receiver's skip keys handle the missing message number. + +## Signer path vs execution path + +The client separates the **signer** as the entity that authorizes the transaction (signs it or holds the session key) from the **executor** as the mechanism that delivers the transaction to the VerbethV1 contract. The `IExecutor` interface (`executor.ts`) defines three methods: `sendMessage`, `initiateHandshake`, and `respondToHandshake`. + +| Signer | Executor | When to use | +|--------|----------|-------------| +| EOA wallet (ethers `Signer`) | `EOAExecutor` | Direct wallet transaction. Simplest path. | +| `SafeSessionSigner` | `EOAExecutor` | Safe module with session key. Preferred for UX and gas cost. | +| `wallet_sendCalls` provider | `BaseSmartAccountExecutor` | Base smart accounts with optional paymaster (EIP-5792). | +| AA bundler client | `UserOpExecutor` | ERC-4337 bundler path. | + +`DirectEntryPointExecutor` also exists but is meant only for local Anvil testing. + +## Safe path + +`SafeSessionSigner` (`utils/safeSessionSigner.ts`) is a Signer adapter, not an executor. It extends the ethers `AbstractSigner` and routes all transactions through a `SessionModule` enabled on a Safe. The execution itself still goes through `EOAExecutor` with a contract instance connected to the `SafeSessionSigner`. + +The session key used here is derived during identity setup from the same wallet signature seed that produces the X25519 and Ed25519 keys (the secp256k1 branch in the key derivation hierarchy described [here](../concepts/identity.md)). + +Two support contracts enable this path: + +**SessionModule** (`contracts/SessionModule.sol`) is a singleton that authorizes session signers to call specific target contracts on behalf of any Safe that has enabled it. It manages per-signer expiry and per-target allowlisting. The `execute()` function checks both before forwarding the call through `ISafe.execTransactionFromModule()`. + +**ModuleSetupHelper** (`contracts/ModuleSetupHelper.sol`) is a deployment helper called via `delegatecall` during `Safe.setup()`. It enables the SessionModule and configures the session in a single transaction through `enableModuleWithSession()`. Without it, enabling the module and authorizing the session signer would require two separate Safe owner transactions. + +>The Safe path avoids the overhead of bundlers and paymasters entirely, because the session signer submits a normal EOA transaction that the Safe module forwards. The gas cost is comparable to a direct EOA call plus a fixed module overhead. + +## AA and smart account path + +`BaseSmartAccountExecutor` and `UserOpExecutor` are two transports for using a smart account to send transactions on behalf of the user. + +`BaseSmartAccountExecutor` uses `wallet_sendCalls` (EIP-5792) and supports an optional paymaster service URL for gas sponsorship. It is designed primarily for Base smart accounts via Coinbase Smart Wallet or similar providers. + +`UserOpExecutor` wraps the full ERC-4337 flow: building a `PackedUserOperation`, signing it through a smart account client, and sending it to a bundler. The gas overhead from UserOp validation, bundler fees, and the EntryPoint contract makes this path significantly more expensive per message than the EOA or Safe paths. For a messaging protocol where users might send hundreds of messages per day, this cost difference compounds. EIP-7702 may eventually provide a lighter alternative by allowing EOAs to adopt smart account behavior without the bundler infrastructure. + +## Computational overhead + +Most client operations are fast. The bottleneck `HandshakeResponse` matching, which scales linearly with the number of pending contacts which is argubly going to be relatively low. + +### O(1) operations + +| Operation | Time | +|-----------|------| +| Handshake lookup (by recipientHash) | ~0.01 ms | +| Message topic recomputation | ~0.02 ms | +| DH ratchet step with topic update | ~1.85 ms | + +### O(P) operations + +Matching a `HandshakeResponse` event to its initiating handshake requires iterating through all pending contacts (see `client/hsrMatcher.ts`). For each candidate, the matcher attempts a NaCl box decryption, an ML-KEM decapsulation, and an HKDF response tag computation. It stops on the first match. + +| Pending contacts (P) | Time | +|-----------------------|------| +| 10 | ~7.7 ms | +| 50 | ~32.7 ms | +| 100 | ~61.0 ms | + +These measurements are from a TypeScript benchmark on a modern laptop. In practice, P stays small for most users because pending contacts are cleared once responses arrive. diff --git a/apps/docs/docs/how-it-works/verbeth-contract.md b/apps/docs/docs/how-it-works/verbeth-contract.md new file mode 100644 index 0000000..3c579b6 --- /dev/null +++ b/apps/docs/docs/how-it-works/verbeth-contract.md @@ -0,0 +1,79 @@ +--- +sidebar_position: 4 +title: Contract +--- + +# Smart Contract + +This page covers the main on-chain component of the protocol, that is one single transport contract. + +## VerbethV1 + +VerbethV1 (`contracts/VerbethV1.sol`) is a UUPS-upgradeable implementation contract built on OpenZeppelin (deployed behind a separate ERC1967Proxy). This contract exists only to emit events. There is no message storage, no access control on messaging functions, no user registry, and no state beyond upgrade governance. All cryptographic validation happens client-side. + +### Messaging functions + +Three functions, each a thin wrapper around an event emission. + +**`sendMessage(bytes ciphertext, bytes32 topic, uint256 timestamp, uint256 nonce)`** emits a `MessageSent` event indexed by `sender` and `topic`. Clients filter for their active inbound topics to find messages addressed to them. + +**`initiateHandshake(bytes32 recipientHash, bytes pubKeys, bytes ephemeralPubKey, bytes plaintextPayload)`** emits a `Handshake` event indexed by `recipientHash` and `sender`. Recipients filter by `keccak256("contact:" + their_lowercase_address)` to discover handshakes directed at them. + +**`respondToHandshake(bytes32 inResponseTo, bytes32 responderEphemeralR, bytes ciphertext)`** emits a `HandshakeResponse` event indexed by `inResponseTo` and `responder`. The initiator matches this to their pending handshake by recomputing the hybrid tag from their stored ephemeral secrets. + +### Event indexing strategy + +Each event uses two `indexed` parameters, which translates to two EVM log topics available for `eth_getLogs` filtering. + +`MessageSent` indexes `sender` and `topic`. The `topic` parameter is the conversation topic derived from the ratchet (not the Solidity event topic). This allows clients to subscribe to exactly the topics they care about. + +`Handshake` indexes `recipientHash` and `sender`. The recipient hash is a one-way derivation from the recipient's address, so observers cannot reverse it to learn who the handshake targets, though they can confirm a guess. + +`HandshakeResponse` indexes `inResponseTo` (the hybrid tag) and `responder`. The tag is derived from shared secrets and is unlinkable to the original `Handshake` event without private key material. See [Protocol Flow](./protocol-flow.md) for the tag computation. + +### Upgrade governance + +The contract uses a 2-day timelock for upgrades (`UPGRADE_DELAY = 2 days`). The owner calls `proposeUpgrade(newImplementation)`, which records the target address and the earliest eligible timestamp. After the delay, the standard UUPS `upgradeToAndCall` path checks that the proposed address matches and the timelock has expired. The owner can cancel a pending upgrade at any time via `cancelUpgrade()`. + +A 48-slot storage gap (`__gap`) reserves space for future state variables without disrupting the storage layout of derived contracts. + +### Design philosophy + +The contract deliberately does not validate messages, enforce rate limits, maintain access lists, or store any user data. There are no admin functions that can censor individual users or filter specific topics. The only privileged operation is the upgrade mechanism, which is timelocked and transparent. + +>A planned [extension](../roadmap/metadata-privacy-psi.md) will add an on-chain registry to VerbethV1, allowing users to publish ML-KEM-768 public keys so that first-message payloads can be hybrid-encrypted before any handshake response. The registry would be a simple `address → bytes` mapping with a `setContactKemKey()` setter and a free `getContactKemKey()` view function. + +## Safe support contracts + +These contracts are not part of the core transport. They enable the Safe session key path where a derived secp256k1 key can send messages on behalf of a Safe without requiring the Safe owner to sign every transaction. + + +## Gas considerations + +Gas costs on VerbethV1 are dominated by calldata. The contract logic is trivial, so almost all gas goes toward the intrinsic cost of submitting bytes to the chain. + +| Operation | Gas | Dominant cost factor | +|-----------|-----|---------------------| +| Request handshake | ~85,790 | ML-KEM-768 public key (1,184 bytes in calldata) | +| Respond to handshake | ~129,110 | Encrypted payload containing KEM ciphertext (1,088 bytes inside blob) | +| Smallest msg (145 byte blob) | ~29,700 | Base event emission with minimal calldata | +| Large msg (1,450 byte blob) | ~81,850 | Calldata scaling | + +>On L2s like Base, the gas numbers above reflect execution cost, but the USD cost of a transaction depends heavily on L1 conditions at the time. + +### Block capacity + +As of early 2026, Base L2 blocks have a gas limit of 375M (up from the original 30M target, and well above Ethereum L1's recent increase to 60M). Given that limit: + +| Operation | Approx. per block | +|-----------|-------------------| +| Handshake initiation (~86k gas) | ~4,360 | +| Handshake response (~129k gas) | ~2,900 | +| Small message (~30k gas) | ~12,500 | +| Large message (~82k gas) | ~4,570 | + +These are theoretical maximums assuming the entire block is Verbeth transactions, which never happens. In practice, shared blockspace and variable L1 costs mean significantly fewer operations per block. That said, continued gas limit increases are favorable for Verbeth's scalability. + +### Message size and gas + +The ciphertext padding scheme (described [here](./wire-formats.md#ciphertext-padding)) means even a single-byte message is padded to the 64-byte minimum bucket, resulting in a minimum payload of ~145 bytes (padded plaintext + 105-byte binary header: version, signature, DH key, counters, nonce). Longer messages grow proportionally through larger padding buckets. Since calldata dominates the gas cost, message size directly affects the per-message cost. diff --git a/apps/docs/docs/how-it-works/wire-format.md b/apps/docs/docs/how-it-works/wire-format.md deleted file mode 100644 index 99c5240..0000000 --- a/apps/docs/docs/how-it-works/wire-format.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -sidebar_position: 1 -title: Wire Format (wip) ---- - -# Wire Format (PAGE WIP...) - -This page describes how ratchet messages are packed into bytes for on-chain submission, and how recipients process them. - -## Binary Layout - -Every ratchet message is a single binary blob emitted as an Ethereum event: - -``` -Offset Size Field -────────────────────────────────────────── -0 1 Version (0x01) -1 64 Ed25519 signature (detached) -65 32 DH ratchet public key -97 4 pn (uint32 big-endian) -101 4 n (uint32 big-endian) -105 var Ciphertext (24-byte nonce + XSalsa20-Poly1305 output) -────────────────────────────────────────── -Minimum total: 105 + ciphertext bytes -``` - -The **signature comes first** by design. This lets recipients verify authenticity before touching any ratchet state — a cheap check that provides DoS protection. - -## Receive & Verify - -When a message arrives, the recipient processes it in strict order: - -``` -Receive event from chain - │ - ▼ -Parse binary payload - ├─ signature = bytes[1..65] - ├─ header = bytes[65..105] (DH key, pn, n) - └─ ciphertext = bytes[105..] - │ - ▼ -Ed25519.verify(signature, header ‖ ciphertext, sender_ed25519_pk) - │ - ┌────┴────┐ - │ │ - FAIL PASS - │ │ - drop ratchet decrypt - message (advance state, derive message key, decrypt) -``` - -If the signature is invalid, the message is silently dropped. No ratchet state is modified, no decryption is attempted. This prevents an attacker from corrupting a session by sending garbage to a known topic. - -## Handshake Payloads - -Handshake messages use a different format — they carry the identity proof and ephemeral keys as a JSON-serialized `HandshakeContent` in the event's `plaintextPayload` field. The handshake response is encrypted using NaCl box and includes the responder's identity proof, ratchet keys, and KEM ciphertext inside the encrypted blob. - -See [Protocol Flow](./protocol-flow.md) for the full exchange flow. - ---- - -*Last validated against SDK source: 2026-03-05* diff --git a/apps/docs/docs/how-it-works/wire-formats.md b/apps/docs/docs/how-it-works/wire-formats.md new file mode 100644 index 0000000..30923ce --- /dev/null +++ b/apps/docs/docs/how-it-works/wire-formats.md @@ -0,0 +1,155 @@ +--- +sidebar_position: 6 +title: Wire Formats +--- + +# Wire Formats + +This page catalogs every binary layout, serialization format, and encoding convention used in the SDK. + +## Ratchet message payload + +Every post-handshake message is a single binary blob emitted as an Ethereum event. The signature comes first so recipients can verify authenticity before touching any ratchet state. See `ratchet/codec.ts`. + +``` +Offset Size Field +────────────────────────────────────────────────── +0 1 Version (0x01) +1 64 Ed25519 signature (detached) +65 32 DH ratchet public key +97 4 pn (uint32, big-endian) +101 4 n (uint32, big-endian) +105 24 XSalsa20-Poly1305 nonce +129 var XSalsa20-Poly1305 ciphertext +────────────────────────────────────────────────── +Minimum total: 129 + ciphertext bytes +``` + +The signature covers bytes 65 through the end (header + nonce + ciphertext). Verification is `Ed25519.verify(signature, payload[65..], senderSigningPublicKey)`. + +`pn` is the number of messages sent in the previous DH epoch (used by the receiver to pre-derive skipped keys). `n` is the message number in the current epoch. + +## Unified public key format + +Long-term public keys are encoded as a versioned 65-byte blob. Used in the `pubKeys` field of the `Handshake` event. See `payload.ts`. + +``` +Byte 0 Bytes 1..32 Bytes 33..64 +┌────────┬──────────────────┬──────────────────┐ +│ 0x01 │ X25519 (32 B) │ Ed25519 (32 B) │ +└────────┴──────────────────┴──────────────────┘ +``` + +## Extended ephemeral key format + +The `ephemeralPubKey` field in the `Handshake` event concatenates the X25519 ephemeral key with the ML-KEM-768 public key. See `handshake.ts`. + +``` +Bytes 0..31 Bytes 32..1215 +┌────────────────┬─────────────────────────┐ +│ X25519 (32 B) │ ML-KEM-768 pk (1184 B) │ +└────────────────┴─────────────────────────┘ +Total: 1216 bytes +``` + +The responder splits on offset 32 to extract both keys. + +## Handshake plaintext payload + +The `plaintextPayload` field in the `Handshake` event is a UTF-8 encoded JSON string. It is not encrypted because the identity proof is intended to be publicly verifiable. See `payload.ts`. + +```json +{ + "plaintextPayload": "Hello, want to chat?", + "identityProof": { + "message": "VerbEth Key Binding v1\nAddress: 0x...\n...", + "signature": "0x..." + } +} +``` + +## Handshake response encrypted payload + +The `ciphertext` field in the `HandshakeResponse` event is a UTF-8 encoded JSON envelope wrapping a NaCl box. The box is encrypted with X25519 using the ratchet keypair (not the tag keypair). See `crypto.ts` and `payload.ts`. + +### Envelope format + +```json +{ + "v": 1, + "epk": "", + "n": "", + "ct": "", + "sig": "" +} +``` + +### Decrypted inner content + +After `NaCl.box.open(ct, n, epk, initiatorEphemeralSecret)`, the resulting JSON contains: + +```json +{ + "unifiedPubKeys": "", + "ephemeralPubKey": "", + "kemCiphertext": "", + "note": "Hey, accepted!", + "identityProof": { ... } +} +``` + +The `ephemeralPubKey` here is the responder's ratchet DH public key (not the tag key `R` that appears on-chain). The `kemCiphertext` is the ML-KEM-768 encapsulation that the initiator decapsulates to recover the KEM shared secret. + +## Ciphertext padding + +Before encryption, plaintext is framed and padded to reduce length-based metadata leakage. See `ratchet/padding.ts`. + +``` +┌────────┬──────────────────────┬───────────┬──────────────────┐ +│ 0x00 │ plaintext length │ plaintext │ random padding │ +│ marker │ (4 bytes, big-endian)│ │ │ +└────────┴──────────────────────┴───────────┴──────────────────┘ +``` + +Bucket selection follows this scheme. + +| Framed size | Bucket rule | +|-------------|-------------| +| ≤ 64 bytes | 64 (minimum) | +| 65 to 16,384 bytes | Next power of 2 | +| > 16,384 bytes | Next multiple of 4,096 | + +On top of the bucket size, a random jitter of up to `floor(bucket / 8)` bytes is added using `nacl.randomBytes`. The padding bytes are cryptographically random and become indistinguishable from ciphertext after XSalsa20-Poly1305 encryption. The overall effect is that ciphertext sizes reveal at most O(log n) bits about plaintext length in the exponential range. + +## Topic format + +Topics are bytes32 values derived from HKDF output and then hashed with keccak256. See `ratchet/kdf.ts`. + +``` +okm = HKDF-SHA256(ikm=dhOutput, salt=rootKey, info="verbeth:topic-{direction}:v3", len=32) +topic = keccak256(okm) +``` + +## Recipient hash + +The `recipientHash` field in the `Handshake` event identifies the intended recipient without revealing their address directly. See `handshake.ts`. + +``` +recipientHash = keccak256(utf8("contact:" + lowercase(recipientAddress))) +``` + +## Identity proof binding message + +The structure is defined in [Identity](../concepts/identity.md). For wire format reference, the message is a newline-delimited plaintext string. + +``` +VerbEth Key Binding v1 +Address: 0xabc... +PkEd25519: 0x123... +PkX25519: 0x456... +ExecutorAddress: 0xdef... +ChainId: 8453 +RpId: my-app +``` + +This message is ECDSA-signed by the wallet and included in the `identityProof` field of both handshake and handshake response payloads. diff --git a/apps/docs/docs/overview.md b/apps/docs/docs/overview.md index 2217d5a..afba98f 100644 --- a/apps/docs/docs/overview.md +++ b/apps/docs/docs/overview.md @@ -33,7 +33,7 @@ Vitalik Buterin proposed a simple litmus test for decentralized systems: Verbeth is designed toward passing this test. The reader of these docs can decide herself if this in fact the case. -> To get all the nice [cryptographic guarantees](concepts/security/cryptographic-guarantees.md), ratchet sessions, pending messages, and contact metadata live in app-managed storage via [`SessionStore`](how-it-works/wire-format.md) and [`PendingStore`](how-it-works/wire-format.md) interfaces that each application implements. If an app disappears without exporting that state, the on-chain ciphertext is preserved but users lose the keys to decrypt it. This is because the protocol does not prescribe where you store your state, only that you must store it. +> To get all the nice [cryptographic guarantees](concepts/security/cryptographic-guarantees.md), ratchet sessions, pending messages, and contact metadata live in app-managed storage via [`SessionStore`](how-it-works/message-store.md#sessionstore) and [`PendingStore`](how-it-works/message-store.md#pendingstore) interfaces that each application implements. If an app disappears without exporting that state, the on-chain ciphertext is preserved but users lose the keys to decrypt it. This is because the protocol does not prescribe where you store your state, only that you must store it. A companion question matters just as much: "If the original team wanted to interfere, could they stop the system from working or selectively prevent people from using it?" Again, the answer is left to the readers. Suffice to say that Verbeth bets everything on [full-stack openness](https://vitalik.eth.limo/general/2025/09/24/openness_and_verifiability.html). diff --git a/apps/docs/docs/roadmap/metadata-privacy-psi.md b/apps/docs/docs/roadmap/metadata-privacy-psi.md index afc15b6..9dc8605 100644 --- a/apps/docs/docs/roadmap/metadata-privacy-psi.md +++ b/apps/docs/docs/roadmap/metadata-privacy-psi.md @@ -8,13 +8,3 @@ title: Private Append-Only Mailbox Sync (wip) :::note Planned feature This page describes a planned extension that is not yet implemented. ::: - -## Problem - -When a client calls `eth_getLogs` to fetch messages, the RPC provider learns: - -- which topic hashes the client subscribes to -- polling frequency and timing -- IP address (unless routed through Tor/VPN) - -Even though topic hashes are not directly reversible to identities, a malicious or compromised provider can correlate query patterns with message emission timing to probabilistically link senders and recipients. diff --git a/apps/docs/sidebars.ts b/apps/docs/sidebars.ts index 6bdd0f7..c20ee77 100644 --- a/apps/docs/sidebars.ts +++ b/apps/docs/sidebars.ts @@ -41,9 +41,12 @@ const sidebars: SidebarsConfig = { collapsed: false, collapsible: true, items: [ - 'how-it-works/wire-format', 'how-it-works/protocol-flow', - 'how-it-works/ratchet-internals', + 'how-it-works/ratcheting', + 'how-it-works/verbeth-client', + 'how-it-works/verbeth-contract', + 'how-it-works/message-store', + 'how-it-works/wire-formats', ], }, {