E2EE Cookbook
This page is the practical companion to Building a Client. The guide defines the normative wire formats (bundle, DM envelope, attachments) and ships the test vectors; this page explains how to implement them correctly: which keys exist, in what order to derive and verify things, how to prepare MLS for this protocol, what to persist, and how to recover when something is lost. If this page and the guide ever disagree, the guide wins.
The intended reader is a client developer who is comfortable calling crypto libraries but is not a cryptographer. Every constant, byte order, and domain string is pinned exactly, and each recipe ends with checkpoint values -- if your intermediate output does not match a checkpoint byte-for-byte, stop and fix that step before moving on.
1. The mental model
A client holds exactly one long-term secret: the secp256k1 account key. It already authenticates every HTTP request; everything below is derived from it deterministically, so a user who restores the account key on a new device recovers their entire cryptographic identity.
secp256k1 account key (the ONE secret; signs HTTP requests,
| membership ops, and the bundle)
| deterministic RFC 6979 signature of a fixed string
v
Ed25519 seed (32 bytes) (never leaves the device; cached)
|
+-- Ed25519 keypair -> bundle "ed_pub", DM message
| signatures, MLS signature key
+-- X25519 static keypair -> DM key wraps, MLS initKey
| pub = toMontgomery(ed_pub) (same point, two encodings)
| priv = clamp(SHA512(seed)[0..32])
+-- MLS leaf encryption key -> X25519(SHA256(seed || "mls-enc-key"))
+-- state backup key -> HKDF-SHA256(seed,
info="p2p-mes:state-backup:v1")
Confidentiality is decided by the carriage, not by the payload format:
| Chat | Carriage | What operators see |
|---|---|---|
| plaintext DM / group | text + plain control | everything |
| encrypted DM | dm-e2ee-v1 envelope in text: p2pmes:dm-e2ee-v1: + base64 | envelope only |
| group | MLS application message in text: p2pmes:mls-app-v1: + base64 | ciphertext only |
The rule that picks the carriage: text is the only transport for user
content -- literal or encrypted, a ciphertext envelope is still a string
-- while control (msg_type + payload) carries only protocol /
service structures (attachment metadata, MLS handshake). A client marks
its envelope formats with a prefix inside text (see
text prefixes);
the node interprets neither field and enforces only size caps.
Attachments are the same MultiRemoteAttachment CBOR in all three cases --
sent as plain control (msg_type = 10), as dm-e2ee inner content
(t = 10), or inside an MLS application message. The blob encryption never
changes; only where the metadata rides does.
2. What to store on the device
| Data | Where | Lost if device dies? |
|---|---|---|
| account private key | platform secure storage (Keystore / Secure Enclave-backed storage) | user's backup problem -- this IS the identity |
| Ed25519 seed + derived privates | app storage; re-derivable from the account key at any time | no -- re-derive |
peer pins addr -> (gen, ts, ed_pub) | local DB | yes -- re-pin on first contact (accept newer bundles) |
MLS ClientState per group | local DB, updated after every MLS operation | yes -- see state backup & restore |
| DM replay set (seen signature hashes per chat) | local DB | yes -- worst case old messages re-display once |
| message cache (for previews) | local DB | yes -- refetch from the node |
The seed is "hot" (in app memory) while the account key can stay behind the platform's biometric gate: derivation needs one signature at first launch, after which the app never touches the account key except to sign HTTP requests.
3. Libraries
| Need | JS/TS | Rust | Notes |
|---|---|---|---|
| keccak256 + secp256k1 recoverable | viem / ethers / @noble/curves | k256 + tiny-keccak | signing MUST be RFC 6979 deterministic (all listed are) |
| Ed25519 / X25519 | @noble/curves/ed25519 | ed25519-dalek + curve25519-dalek | noble ships toMontgomery / toMontgomerySecret |
| HKDF-SHA256, AES-256-GCM | WebCrypto | hkdf, aes-gcm | GCM tag appended to ciphertext (the default everywhere) |
| SHA-256, SHA-512 | WebCrypto / @noble/hashes | sha2 | |
| BLAKE3 (chat ids) | blake3 | blake3 | |
| CBOR | cbor-x / cbor2 | ciborium / serde_cbor with serde_bytes | profile payloads use real byte strings (major type 2) -- see the encoding gotcha in the guide |
| MLS (RFC 9420) | ts-mls (pinned 1.6.2) | openmls | ciphersuite id 1; see interop status |
ts-mls pins its peer dependencies to exact versions (no semver
ranges): on 1.6.2 that is @noble/curves@2.0.1, @noble/ciphers@2.1.1,
and four @hpke/* packages. If your app (or another dependency) wants any
other @noble/curves, the package manager reports a peer conflict. Align
your direct dependency to the exact pinned version, or resolve it with
your package manager's overrides/resolutions -- do not force-install
two copies of a crypto library.
4. Recipe: derive your keys
Steps (full spec: Identity key bundle):
// 1. One deterministic signature of a fixed message. gen starts at 0 and
// only changes when the user rotates their identity key.
const msgHash = keccak256(utf8("p2p-mes:id-seed:v1") || u32be(gen));
const sig65 = secpSignRecoverable(accountKey, msgHash); // r||s||v, v in {0,1}
// If your library returns v = 27/28, subtract 27 BEFORE hashing.
// 2. The seed is the hash of the signature bytes.
const seed = keccak256(sig65); // 32 bytes
// 3. Everything else is derived, never stored as a second secret.
const edPriv = seed; // Ed25519 private key
const edPub = ed25519.getPublicKey(seed);
const xPub = ed25519.utils.toMontgomery(edPub); // X25519 public
const xPriv = ed25519.utils.toMontgomerySecret(seed); // X25519 private
Checkpoints for accountKey = 0x11 * 32, gen = 0:
seed = 6cccccc86d8b15e7ea7fc0a817235593f6631018d0e28e810677184dcee01dde
ed_pub = 2d41d2645fed8100c458f8442301dd92e504d1e16bf258272d7fa9b8738bd45d
x_pub = fb1a6a847fbee0fb7f4119c602a8d0e6a9aa8e2cd785f0c580de3ec5b2e6f705
Pitfalls, in the order people hit them:
- Randomized signatures. A signer that adds entropy (some hardware wallets, some HSM APIs) derives a different seed every call. Test: derive twice, compare. If unequal, your signer is not RFC 6979.
- v = 27/28 not normalized. The seed then differs across libraries.
- Hashing the hex string instead of the bytes.
keccak256inputs above are raw bytes: the UTF-8 of the domain string, the 4-byte big-endiangen, the 65 signature bytes. - Using the account key directly for DH. Never: the secp256k1 key signs; all encryption flows through the derived 25519 keys.
5. Recipe: publish and verify bundles
Publish once at first launch and on every rotation (gen bump):
const ts = Date.now();
const preimage = utf8("p2p-mes:idbundle:v2") || u32be(gen) || u64be(ts)
|| edPub || (kpBytes ?? empty);
const bundle = cbor({ v: 2, gen, ts, ed_pub: edPub,
...(kpBytes && { kp: kpBytes }),
sig: secpSignRecoverable(accountKey, keccak256(preimage)) });
await PUT("/identity", { identity: base64(bundle) });
Verify every bundle you read, before using any key from it:
function verifyBundle(addr: bytes20, blob: bytes): Bundle | "legacy" | null {
if (blob.length === 32) return "legacy"; // bare Ed25519, unauthenticated
const b = cborDecode(blob);
if (b.v !== 2) return null;
const preimage = utf8("p2p-mes:idbundle:v2") || u32be(b.gen) || u64be(b.ts)
|| b.ed_pub || (b.kp ?? empty);
if (ecrecover(b.sig, keccak256(preimage)) !== addr) return null; // forged
const pin = pins.get(addr);
if (pin && (b.gen < pin.gen || (b.gen === pin.gen && b.ts < pin.ts)))
return null; // stale replay -- keep the pin
pins.set(addr, { gen: b.gen, ts: b.ts, ed_pub: b.ed_pub });
return b;
}
What to do on each outcome:
| Outcome | Meaning | Action |
|---|---|---|
| valid bundle | key authentic (chained to the address) | proceed; update pin |
"legacy" | 32-byte raw key, cannot be verified | TOFU at most; prefer refusing E2EE bootstrap |
| forged / parse fail | node served garbage or an attack | refuse E2EE loudly; do NOT silently fall back to plaintext |
| stale (older than pin) | node replayed a rotated-away bundle | keep the pinned key; warn |
| 404 | peer never published | no E2EE possible yet; plaintext with an explicit UI marker |
Checkpoint: Alice's bundle for gen = 0, ts = 1700000000000, no kp,
hashes to 7e12d9e1...24a2f6b1 and recovers to
0x19e7e376e7c213b7e7e7e46cc70a5dd086daff2a (full vector in the guide).
6. Recipe: encrypted DMs
Full wire format: dm-e2ee-v1. The shape to remember: sign, then encrypt, then wrap the key twice.
async function sendDm(peerAddr: bytes20, content: InnerContent) {
const peer = verifyBundle(peerAddr, await GET(`/identity/${hex(peerAddr)}`));
if (peer === "legacy" || peer === null) throw new NoE2ee();
const chatId = blake3(utf8("p2p-mes:chat:dm:v1:")
|| min(myAddr, peerAddr) || max(myAddr, peerAddr));
const m = cbor({ t: content.t, ts: Date.now(), ...content.fields });
const sig = ed25519.sign(utf8("p2p-mes:dm-sig:v1") || chatId || m, seed);
const contentKey = random(32);
const eph = x25519.keygen();
const ct = aesGcmEncrypt(contentKey, random(12), cbor({ m, sig }));
const wraps = [ [peerAddr, toMontgomery(peer.ed_pub)],
[myAddr, xPub] ] // self-wrap: your
.map(([addr, staticPub]) => { // own history stays
const kek = hkdfSha256(x25519.dh(eph.priv, staticPub),
/*salt*/ empty, /*info*/ utf8("p2p-mes:dm-wrap:v1"));
return { addr, nonce: random(12),
key: aesGcmEncrypt(kek, nonce, contentKey) };
});
await POST(`/dialogs/${hex(peerAddr)}/messages`, {
text: "p2pmes:dm-e2ee-v1:"
+ base64(cbor({ eph_pub: eph.pub, wraps, nonce: ct.nonce, ct: ct.bytes })),
});
}
Receiving (after the standard msg_cbor decode):
textdoes not start withp2pmes:dm-e2ee-v1:-> not this profile. Otherwise strip the prefix, base64-decode the rest, and CBOR-decode the envelope.- Find the wrap where
addr == myAddr; derive the samekekfrom your static X25519 private andeph_pub; unwrapcontent_key; decryptct. - Determine the expected author from the decoded message's
senderfield (in a DM it is either the peer or you). Verifysigover"p2p-mes:dm-sig:v1" || chat_id || magainst that party's verified bundle key -- your owned_pubfor echoes of your messages. - Reject on any failure. On success, check the replay set (hash of
sig); duplicates are replays, drop them. Display using the signed innerts. - Dispatch on
t:0text,10attachments (continue in the attachments receive flow),100..255your app's private types.
Pitfalls:
- Nonce reuse is fatal in GCM. Every
random(12)above is fresh: per-wrap and per-message. Never derive nonces from counters you might reset. - The
infostrings are load-bearing."p2p-mes:dm-wrap:v1"(HKDF) and"p2p-mes:dm-sig:v1"(signature context) must match byte-for-byte. - Sign
m, the CBOR bytes -- not the decoded object. Serialize once, sign those bytes, ship those bytes. Re-encoding on the other side may produce different bytes and a false verification failure. - Don't skip verification for your own echoes. A malicious node can
forge
senderjust as easily.
Checkpoint (vector in the guide): with the fixed test inputs, Bob's kek
is 9bef6660...69e955b3 and the full envelope is 398 bytes.
7. Recipe: attachments in each carriage
The attachment pipeline (encrypt -> upload -> metadata) is fully specified in the guide's Attachments section, with its own vector. Composition rules:
- Plaintext chat: send the
MultiRemoteAttachmentCBOR ascontrolwithmsg_type = 10. Operators can read the metadata and decrypt blobs -- same trust level as plaintext text. - Encrypted DM: put the same fields into the dm-e2ee inner content with
t = 10({ t: 10, ts, attachments: [...], caption? }). Operators see nothing. - MLS group: put the same fields into the application-message framing
with
t = 10--{ t: 10, ts, attachments: [...], caption? }, the sameInnerContentmap as the DM carriage (normative, see 8.3) -- and send the encrypted result through the plain group endpoint with thep2pmes:mls-app-v1:prefix. Operators see nothing.
The plaintext case is deliberately unchanged by the text-carriage
migration: MultiRemoteAttachment is a protocol structure -- its schema
is fixed by the protocol, a client only renders it -- so it legitimately
rides the control channel. This section is the reference example of what
control is for; user content never claims a control type.
The blob-side work (fresh secret/salt/nonce per file, HKDF -> AES-256-GCM, SHA-256 digest, content-addressed upload) is identical in all three -- write it once. On receive, always run the security checklist in order: scheme check -> size cap while streaming -> digest verify -> decrypt -> render.
8. Recipe: MLS for p2p-mes
RFC 9420 explains MLS itself; read it (or its many summaries) separately. This section is only about the choices that make MLS run on p2p-mes and the operational rules that keep it healthy.
8.1 Fixed choices
| Choice | Value | Why |
|---|---|---|
| ciphersuite | MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 (id 1) | matches the identity key family; smallest, best-supported suite |
| credential | basic, identity = UTF-8 of the lowercase 0x-prefixed address (42 bytes) | ties the MLS leaf to the protocol identity; verified via the bundle |
| group id | the 32-byte group chat_id | one id everywhere: HTTP path, gossip, MLS |
| capabilities | versions ["mls10"], ciphersuites ["MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519"], credentials ["basic"] | minimum viable; extend only network-wide |
Capabilities hold enum names, not numeric ids. In ts-mls the
capability lists carry string keys ("mls10", the ciphersuite name,
"basic"); numeric registry ids are looked up at encode time. Passing
numbers (versions: [1]) encodes garbage silently: signatures still
verify, because every party encodes the same garbage, but any TLS decode
of a leaf node, ratchet tree, or commit that embeds such capabilities
returns nothing. This one mistake is what historically looked like
"ts-mls cannot round-trip its own encodings" -- see
interop status.
Verification rule: when you receive a KeyPackage (from a bundle or a
Welcome), check the chain before trusting it: the bundle signature proves
ed_pub belongs to the address; the KeyPackage must be signed by that same
ed_pub; the credential inside must spell that same address. Any mismatch
-> reject the member.
8.2 Deterministic keys and the KeyPackage
MLS normally wants fresh, single-use KeyPackages published to a delivery service. p2p-mes deliberately deviates: keys are deterministic from the seed, and one last-resort KeyPackage lives in the identity bundle. That buys offline adds (invite a user who has never been online at the same time as you) and Welcome recovery from the bare seed, at the accepted cost that a seed compromise exposes recorded Welcomes.
Construction (mirror this exactly):
const kp = await generateKeyPackageWithKey(credential, capabilities,
defaultLifetime, [], { signKey: seed, publicKey: edPub }, cs, []);
// Override the random HPKE keys with the deterministic ones:
kp.publicPackage.initKey = toMontgomery(edPub);
kp.privatePackage.initPrivateKey = toMontgomerySecret(seed);
const enc = x25519Keypair(sha256(seed || utf8("mls-enc-key"))); // leaf key,
kp.publicPackage.leafNode.hpkePublicKey = enc.pub; // MUST differ
kp.privatePackage.hpkePrivateKey = enc.priv; // from initKey
// The keys changed, so both signatures must be redone, leaf first:
kp.publicPackage.leafNode = await signLeafNodeKeyPackage(leafTbs, seed, cs.signature);
kp.publicPackage = await signKeyPackage(kpTbs, seed, cs.signature);
Serialize publicPackage (TLS encoding) into the bundle's kp field. Both
sides can rebuild the same KeyPackage from the same seed, which is what
makes state-loss recovery possible.
8.3 Transport mapping
The MLS handshake rides group control messages
(POST /groups/{chat_id}/messages/control, payloads base64) -- these are
protocol structures, which is what control is for:
| msg_type | Payload (control, CBOR) | Sent to |
|---|---|---|
20 MLS Welcome | {"welcome": bstr, "tree": bstr} -- welcome = TLS-encoded MLSMessage (mls_welcome); tree = TLS-encoded RFC 9420 ratchet_tree | the new member (recipients: [addr]) |
21 MLS Commit | {"commit": bstr} -- TLS-encoded MLSMessage (mls_public_message) | all members |
Application messages are the group's user content, so they are not a
control type: send the TLS-encoded MLSMessage (mls_private_message)
through the plain group endpoint, prefixed and base64-encoded in text
-- no CBOR wrapper around the MLS bytes:
await POST(`/groups/${hex(chatId)}/messages`, {
text: "p2pmes:mls-app-v1:" + base64(encodeMlsMessage(privateMessage)),
});
All these payloads (welcome, tree, commit, and the application
MLSMessage) are standard TLS presentation-language
encodings (RFC 9420 wire structures) -- nothing library-specific. In
ts-mls terms: commit is encodeMlsMessage(result.commit) (what
createCommit returns in commit already is a complete MLSMessage --
do not wrap it again), welcome is encodeMlsMessage({version: "mls10", wireformat: "mls_welcome", welcome: result.welcome}), and tree is
encodeRatchetTree(state.ratchetTree) / decodeRatchetTree. Note the
tree codec is not re-exported from the ts-mls package root -- deep-import
it from ts-mls/dist/src/ratchetTree.js.
The ratchet tree rides with the Welcome instead of relying on the
optional ratchet_tree GroupInfo extension, so a joiner never depends on
the committer having embedded it. If welcome + tree outgrows
the 32 KiB control cap (roughly 150+ members), send
{"ref": RemoteAttachmentInfo} instead: upload the CBOR as an encrypted
blob via the attachments pipeline and let the joiner fetch it.
Application-message framing (normative). The plaintext handed to MLS
(createApplicationMessage) is the same InnerContent CBOR map as
dm-e2ee-v1:
plaintext = InnerContent = {
"t": uint, ; 0 = text ("text": tstr)
"ts": uint, ; sender wall-clock ms, authenticated display time
... ; 10 = attachments ("attachments" / "caption")
} ; 100-255 = app-private
There is no SignedContent wrapper and no extra signature here: MLS
already authenticates the sender (leaf signature key) and binds the
message to the group and epoch. ts rides inside the AEAD, so it is the
one display time a relay cannot forge -- same rationale as in the DM
profile (hlc/origin_wall_ts are not client-authenticated). This
framing is what makes two independent clients read each other's groups:
a byte-compatible MLS layer is not enough if the plaintext layout
differs, so do not invent a private one.
Membership change = one compound call. Never send the protocol-level op and the MLS messages separately; bundle them so they propagate together:
// Adding Bob: fetch + verify his bundle, take kp from it, then:
const { newState, welcomeBytes, commitBytes, ratchetTree } =
await addMlsMember(state, bobKeyPackage);
await POST(`/groups/${hex(chatId)}/ops`, {
ops: [ { op_type: "add", target: hex(bobAddr), role: 0, sig: opSig } ],
messages: [
{ msg_type: 21, control: b64(cbor({ commit: commitBytes })), recipients: [] },
{ msg_type: 20, control: b64(cbor({ welcome: welcomeBytes,
tree: encodeRatchetTree(ratchetTree) })),
recipients: [hex(bobAddr)] },
],
});
persist(newState); // AFTER the POST succeeded
recipients: [] fans out to all members; the Welcome targets only the
joiner's inbox. Removal is the same shape with a remove op and a Commit
carrying the remove proposal. There is still no atomicity between the
ops and the messages (see the guide's operational notes) -- which is why
reconciliation below exists.
Two earlier carriages are superseded: an early demo used msg_type 11/12
for Welcome/Commit and shipped application ciphertext hex-encoded in
text; a later revision moved dm-e2ee envelopes and MLS application
messages into control as msg_type 11 and 22. Today user content --
including ciphertext -- rides text with a format prefix, and control
carries protocol structures only (10, 20, 21). Do not reuse 11, 12, or 22.
8.4 Interop status (read before mixing stacks)
The bstr payloads in 8.3 are plain RFC 9420 TLS encodings, and on the
pinned ts-mls 1.6.2 they are verified to round-trip: MLSMessage for
Welcome and Commit decodes and re-encodes byte-identically, a member that
only ever saw the wire bytes processes the Commit, and a joiner joins from
wire-decoded welcome + tree alone. Nothing in the convention is
ts-mls-specific; a non-ts-mls client (e.g. openmls) interoperates by
producing the same TLS structures.
Two legacy traps, kept here because both fail confusingly:
- "TLS decode is broken" is a capabilities bug, not a library bug. Earlier revisions of this section claimed only the Welcome round-trips in TLS and prescribed library-internal CBOR for commit and tree. The real cause was capabilities built with numeric enum ids (see 8.1): they encode silently into garbage that still signature-verifies, and then every TLS decode of a leaf, tree, or commit returns nothing. Use string enum names and TLS round-trips fine.
- An early internal demo used
msg_type11/12 for Welcome/Commit, with the commit bytes as library-internal CBOR of the ts-mls object. That format is superseded and must not be implemented: 11, 12, and 22 are unassigned today (11 and 22 briefly carried dm-e2ee envelopes and MLS application ciphertext as control types before user content moved totext). The MLS handshake speaks 20/21 with the TLS payloads above, application messages ridetextwith thep2pmes:mls-app-v1:prefix, and there are no wire-compatibility obligations to the old formats.
8.5 The receiving loop
For each group, poll GET /groups/{chat_id}/messages and process in
HLC order (that is the order the range endpoint returns):
- Deduplicate by
msg_id(nodes may deliver duplicates after sync). - Dispatch: control
msg_type = 20-> if it is addressed to you and you are not yet in the group, join (joinGroupwith the bundled tree); controlmsg_type = 21->processMessage, then persist the new state before acking anything to the UI; atextstarting withp2pmes:mls-app-v1:-> strip the prefix, base64-decode, decrypt as an application message, then CBOR-decode the plaintext asInnerContent(see 8.3) and dispatch on itstexactly as in the DM receive flow (0text,10attachments). - An application message or commit from an epoch ahead of yours means you missed a commit: buffer it, keep reading history forward -- the missing commit is earlier in the range. If you reach the tail and the gap remains (retention ate it), go to recovery.
- An epoch behind yours: a duplicate or a competing commit that lost the race; drop it. Two concurrent commits on one epoch are resolved by order: the first one in HLC order wins, the second fails to apply -- its author must rebase (re-issue on the new epoch).
8.6 State: persist, backup, restore
MLS state is the one thing that is not derivable from the seed: it ratchets with every message. Rules:
- Persist the serialized
ClientStateafter every successful operation (send or receive). Treat it like a database, not a cache. encodeGroupStatedoes not include your config. Ints-mls,ClientState = GroupState & { clientConfig }, and the state codec covers only theGroupStatepart. AfterdecodeGroupState, reattach yourclientConfig({ ...decoded, clientConfig }) before first use; a bare decoded state crashes on its first send/receive (Cannot read properties of undefined (reading 'paddingConfig')). Keep the config next to your snapshots -- it never travels inside them.- Encrypted backup (recommended): after every commit (debounced),
encrypt a state snapshot with the backup key
(
HKDF-SHA256(seed, info = "p2p-mes:state-backup:v1"), AES-256-GCM, fresh nonce) and upload it via the attachments pipeline (content-addressed blob). Keep the latest(digest, url)pointer locally and, optionally, as a self-addressed encrypted DM so it survives the device.
Restore paths, in order of preference:
- Snapshot + replay: fetch and decrypt the latest snapshot, then process all group messages with HLC after it. Complete recovery.
- Welcome replay: your Welcome (
msg_type 20) is still in the stored history for retention-window days, and its decryption key derives from the seed. Re-join from it, then replay every later commit in order. - Re-add: history no longer contains what you need. Ask any admin (encrypted DM) to remove and re-add you -- new epoch, forward-only. History before the re-add stays unreadable; that is forward secrecy working as intended, not a bug to fix.
8.7 Reconciliation: membership list vs. MLS roster
The node's member list (GET /groups/{chat_id}/members) and the MLS roster
can diverge because ops and MLS messages are not atomic. Run this check on
every group open (cheap: one HTTP call + local compare):
| Divergence | Meaning | Repair |
|---|---|---|
| in members, not in roster | Add op landed, Welcome/Commit lost | the adder re-issues; any admin may re-add if the adder is gone |
| in roster, not in members | Remove op landed, Commit lost | any admin issues the remove Commit; until then treat the member as removed (do not encrypt to them: check the members list before sending) |
| you are in members, but your state cannot decrypt | you missed commits | recovery paths above |
Convention: repairs are idempotent -- a stale repair Commit simply fails the epoch check and is dropped, so multiple admins racing to fix the same divergence is safe.
8.8 Do / Don't
- Do verify the full chain (bundle sig ->
ed_pub-> KeyPackage sig -> credential address) for every KeyPackage you accept. - Do persist state after every MLS operation, before updating the UI.
- Do send membership ops and their MLS messages in one compound call.
- Don't encrypt application messages to a roster the members list says is stale (removed member still in your tree) -- reconcile first.
- Don't process MLS messages out of HLC order.
- Don't put user content on the control channel -- ciphertext is still
user content and rides
textwith a format prefix. - Don't reuse control
msg_typeslots 10/20/21 (or the retired 11/12/22) for anything else; keep app-private control types in 100-255 and mark app-privatetextformats with your own prefix. - Don't ship the account key or the seed into your MLS library's storage; hand it only the derived keys it needs.
9. Consolidated checkpoints
All from the guide's vectors (fixed test keys 0x11*32 / 0x22*32):
| Value | Expected |
|---|---|
| Alice seed (gen 0) | 6cccccc86d8b15e7ea7fc0a817235593f6631018d0e28e810677184dcee01dde |
Alice ed_pub | 2d41d2645fed8100c458f8442301dd92e504d1e16bf258272d7fa9b8738bd45d |
Alice x25519_pub | fb1a6a847fbee0fb7f4119c602a8d0e6a9aa8e2cd785f0c580de3ec5b2e6f705 |
| bundle keccak (gen 0, ts 1700000000000, no kp) | 7e12d9e103fa16043608ce20fb211bf5b80b64334219c8149ab1dc3d24a2f6b1 |
DM chat_id (Alice, Bob) | a91602ff4fbe6b4ff0555945932d5367db2b815cbcb6d05cdf3c399c6fa9e30f |
DM kek for Bob (vector inputs) | 9bef6660bff2a7b28246abb7010774affd36764e34f75baa58f268de69e955b3 |
| DM envelope size (vector) | 398 bytes |
DM text carriage (p2pmes:dm-e2ee-v1: + base64 of the envelope) | 18 + 532 = 550 chars |
| attachment derived key (attachments vector) | 6b8121baa9b21c516029bf185397a0f193ff7297a22ae6292f92259961a461a0 |
attachment content_digest | f0775ac2e1fd2096683a1ac3a338d56e92c051647ad0f4297c2477d6d964f277 |
Also replay the signing vectors in
test-vectors.json first -- E2EE bugs are unreachable
while request signing is broken.
10. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| derived seed differs between runs | non-deterministic signer | use an RFC 6979 library path; never a wallet popup that re-signs |
| seed differs between two libraries | v not normalized to 0/1, or hex-string hashed instead of bytes | normalize; hash raw bytes |
| bundle verify fails on your own bundle | preimage field order / u32be vs u64be mixed up | rebuild preimage exactly: domain, gen(4), ts(8), ed_pub, kp |
| DM decrypt fails, wrap not found | comparing hex string to raw 20 bytes in addr | compare bytes |
| DM decrypt fails at unwrap | wrong HKDF info, or salt not empty | info = "p2p-mes:dm-wrap:v1", salt empty |
| DM signature never verifies | signed the decoded object re-encoded, or missing chat_id in context | sign/verify the exact m bytes; context = domain || chat_id || m |
| control payload rejected by node | over 32 KiB decoded, or not valid base64 | check size before send; split albums |
| text rejected by node | over 45056 bytes (44 KiB) -- an enveloped payload over ~33 KB | send big payloads by reference via the attachments pipeline; never chunk across messages |
| CBOR decodes to arrays of numbers | reading profile payloads with the msg_cbor quirk assumptions | profile payloads use real byte strings; only the outer msg_cbor uses arrays |
| Welcome join fails | ratchet tree missing/stale, or KeyPackage in bundle rotated after the Welcome was sent | always ship tree with the Welcome; re-add on rotation |
| TLS decode of commit/tree/KeyPackage returns nothing, yet signatures verify | capabilities built with numeric enum ids | use string names: versions: ["mls10"], ciphersuite/credential names (8.1) |
restored state crashes on paddingConfig | decodeGroupState yields a state without clientConfig | reattach your config after decode (8.6) |
| commit fails to apply | epoch skew (missed a commit) or competing commit lost the race | replay history in HLC order; rebase your commit |
| group message decrypts for others but not a new member | member added to the node list but Welcome lost | reconciliation table above |