Expand description
PAIRED-CHANNELS Sprint E: end-to-end encryption for LCT pair messages.
Composes the Sprint A ECDH primitive (KeyPair::ecdh_with_peer)
with HKDF-SHA256 (session-key derivation) + ChaCha20-Poly1305 AEAD
(payload encryption + authentication). This is the moment “the hub
cannot read content” stops being aspirational — the seal/open
happens entirely at endpoints; the hub stores opaque ciphertext.
§Key derivation
shared_secret = ECDH(my_x25519_secret, peer_x25519_public)
session_key = HKDF-SHA256(
salt = pair_id_bytes, // pair-distinguishing salt
ikm = shared_secret,
info = "web4-paired-channel-v1",
L = 32 bytes,
)The salt being pair_id addresses the open question in the PRD
(§8.1): two LCTs can have multiple distinct pairs without session-key
reuse — the same shared secret produces a different session key per
pair. Both endpoints know pair_id from the hub (it’s metadata),
so they derive identical session keys without coordination.
§Wire format
sealed = nonce_12_bytes || ciphertext_n_bytesChaCha20-Poly1305 uses a 12-byte nonce. The ciphertext includes
the 16-byte Poly1305 authentication tag at the end (AEAD-attached,
per the standard). At the wire layer (over JSON to the hub), the
sealed bytes are base64-encoded — that part’s the caller’s
responsibility (Sealed::to_base64 / Sealed::from_base64 are
provided as convenience).
§Nonce strategy (Sprint E MVP)
Random 12-byte nonce per message. With 2^96 possible nonces and ChaCha20-Poly1305’s birthday-bound at 2^48 messages per key, collision probability is negligible at any practical message volume. Sprint F adds proper per-session counter nonces + ephemeral ratchet keys for forward secrecy; Sprint E is the static-key baseline.
§What this gives you
- Confidentiality: hub stores opaque ciphertext; only the two pair participants can decrypt.
- Integrity / authenticity-of-payload: AEAD detects tampering (the Poly1305 tag fails if a byte flips). Authenticity-of-sender still rides the envelope signature at the REST layer — the AEAD only proves “whoever knew the session key wrote this,” which the envelope signature pins to a specific LCT.
§What this does NOT give you (deferred)
- Forward secrecy — Sprint F. If an LCT’s static key is later compromised, an attacker who captured past ciphertexts can derive the session key and decrypt them. Sprint F’s ephemeral-key ratchet closes this.
- Future secrecy / post-compromise security — full Signal double-ratchet, deferred per the PRD §6 out-of-scope list.
- Group channels — 2-party only.
Structs§
- Ephemeral
KeyPair - An ephemeral X25519 keypair for one pair-session. Caller must persist the secret locally (we never put it on the wire) and publish the public via PairingRequested / PairingConfirmed.
- Sealed
- A sealed pair message: 12-byte nonce prefix + ciphertext (with trailing 16-byte Poly1305 tag). Caller transports the raw bytes (typically base64 over JSON to the hub).
- Session
Key - A 32-byte session key derived from an ECDH shared secret + pair_id salt. Carries a redacted Debug like SharedSecret.
Functions§
- decrypt
- Decrypt a
Sealedblob undersession_key. Returns plaintext on success; errors (AEAD authentication failed) if the ciphertext was tampered or the wrong session key was used. - derive_
session_ key - Derive the per-pair session key from an ECDH shared secret + the pair’s identifier. Both endpoints can do this independently from public information (peer’s LCT pubkey, pair_id from the hub) + their own private key.
- derive_
session_ key_ v2 - V2 session-key derivation. Mixes the LCT-static ECDH and the per-session ephemeral ECDH. Both endpoints derive the same key from their own secret + the peer’s two publics + pair_id.
- encrypt
- Encrypt
plaintextundersession_keywith a fresh random nonce. Output isnonce || ciphertext_with_tagwrapped inSealed. - ephemeral_
public_ from_ hex - Decode a 32-byte X25519 public from hex. Used when reading the peer’s ephemeral pub from a PairingRequested / PairingConfirmed event in the ledger.
- open
- End-to-end convenience: given my LCT keypair, the peer’s LCT public key, the pair_id, and a sealed blob — recover the plaintext.
- open_fs
- Symmetric inverse of
seal_fs. - seal
- End-to-end convenience: given my LCT keypair, the peer’s LCT
public key, the pair_id, and a plaintext — produce a sealed blob
the recipient can
openusing the symmetric inverse path. - seal_fs
- End-to-end seal with forward secrecy. Caller supplies their own LCT keypair + ephemeral keypair + peer’s LCT public + peer’s ephemeral public + pair_id + plaintext.