Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

ChatCarriageWhat operators see
plaintext DM / grouptext + plain controleverything
encrypted DMdm-e2ee-v1 envelope in text: p2pmes:dm-e2ee-v1: + base64envelope only
groupMLS application message in text: p2pmes:mls-app-v1: + base64ciphertext 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

DataWhereLost if device dies?
account private keyplatform secure storage (Keystore / Secure Enclave-backed storage)user's backup problem -- this IS the identity
Ed25519 seed + derived privatesapp storage; re-derivable from the account key at any timeno -- re-derive
peer pins addr -> (gen, ts, ed_pub)local DByes -- re-pin on first contact (accept newer bundles)
MLS ClientState per grouplocal DB, updated after every MLS operationyes -- see state backup & restore
DM replay set (seen signature hashes per chat)local DByes -- worst case old messages re-display once
message cache (for previews)local DByes -- 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

NeedJS/TSRustNotes
keccak256 + secp256k1 recoverableviem / ethers / @noble/curvesk256 + tiny-keccaksigning MUST be RFC 6979 deterministic (all listed are)
Ed25519 / X25519@noble/curves/ed25519ed25519-dalek + curve25519-daleknoble ships toMontgomery / toMontgomerySecret
HKDF-SHA256, AES-256-GCMWebCryptohkdf, aes-gcmGCM tag appended to ciphertext (the default everywhere)
SHA-256, SHA-512WebCrypto / @noble/hashessha2
BLAKE3 (chat ids)blake3blake3
CBORcbor-x / cbor2ciborium / serde_cbor with serde_bytesprofile payloads use real byte strings (major type 2) -- see the encoding gotcha in the guide
MLS (RFC 9420)ts-mls (pinned 1.6.2)openmlsciphersuite 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:

  1. 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.
  2. v = 27/28 not normalized. The seed then differs across libraries.
  3. Hashing the hex string instead of the bytes. keccak256 inputs above are raw bytes: the UTF-8 of the domain string, the 4-byte big-endian gen, the 65 signature bytes.
  4. 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:

OutcomeMeaningAction
valid bundlekey authentic (chained to the address)proceed; update pin
"legacy"32-byte raw key, cannot be verifiedTOFU at most; prefer refusing E2EE bootstrap
forged / parse failnode served garbage or an attackrefuse E2EE loudly; do NOT silently fall back to plaintext
stale (older than pin)node replayed a rotated-away bundlekeep the pinned key; warn
404peer never publishedno 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):

  1. text does not start with p2pmes:dm-e2ee-v1: -> not this profile. Otherwise strip the prefix, base64-decode the rest, and CBOR-decode the envelope.
  2. Find the wrap where addr == myAddr; derive the same kek from your static X25519 private and eph_pub; unwrap content_key; decrypt ct.
  3. Determine the expected author from the decoded message's sender field (in a DM it is either the peer or you). Verify sig over "p2p-mes:dm-sig:v1" || chat_id || m against that party's verified bundle key -- your own ed_pub for echoes of your messages.
  4. Reject on any failure. On success, check the replay set (hash of sig); duplicates are replays, drop them. Display using the signed inner ts.
  5. Dispatch on t: 0 text, 10 attachments (continue in the attachments receive flow), 100..255 your 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 info strings 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 sender just 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 MultiRemoteAttachment CBOR as control with msg_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 same InnerContent map as the DM carriage (normative, see 8.3) -- and send the encrypted result through the plain group endpoint with the p2pmes: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

ChoiceValueWhy
ciphersuiteMLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 (id 1)matches the identity key family; smallest, best-supported suite
credentialbasic, identity = UTF-8 of the lowercase 0x-prefixed address (42 bytes)ties the MLS leaf to the protocol identity; verified via the bundle
group idthe 32-byte group chat_idone id everywhere: HTTP path, gossip, MLS
capabilitiesversions ["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_typePayload (control, CBOR)Sent to
20 MLS Welcome{"welcome": bstr, "tree": bstr} -- welcome = TLS-encoded MLSMessage (mls_welcome); tree = TLS-encoded RFC 9420 ratchet_treethe 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_type 11/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 to text). The MLS handshake speaks 20/21 with the TLS payloads above, application messages ride text with the p2pmes: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):

  1. Deduplicate by msg_id (nodes may deliver duplicates after sync).
  2. Dispatch: control msg_type = 20 -> if it is addressed to you and you are not yet in the group, join (joinGroup with the bundled tree); control msg_type = 21 -> processMessage, then persist the new state before acking anything to the UI; a text starting with p2pmes:mls-app-v1: -> strip the prefix, base64-decode, decrypt as an application message, then CBOR-decode the plaintext as InnerContent (see 8.3) and dispatch on its t exactly as in the DM receive flow (0 text, 10 attachments).
  3. 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.
  4. 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 ClientState after every successful operation (send or receive). Treat it like a database, not a cache.
  • encodeGroupState does not include your config. In ts-mls, ClientState = GroupState & { clientConfig }, and the state codec covers only the GroupState part. After decodeGroupState, reattach your clientConfig ({ ...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:

  1. Snapshot + replay: fetch and decrypt the latest snapshot, then process all group messages with HLC after it. Complete recovery.
  2. 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.
  3. 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):

DivergenceMeaningRepair
in members, not in rosterAdd op landed, Welcome/Commit lostthe adder re-issues; any admin may re-add if the adder is gone
in roster, not in membersRemove op landed, Commit lostany 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 decryptyou missed commitsrecovery 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 text with a format prefix.
  • Don't reuse control msg_type slots 10/20/21 (or the retired 11/12/22) for anything else; keep app-private control types in 100-255 and mark app-private text formats 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):

ValueExpected
Alice seed (gen 0)6cccccc86d8b15e7ea7fc0a817235593f6631018d0e28e810677184dcee01dde
Alice ed_pub2d41d2645fed8100c458f8442301dd92e504d1e16bf258272d7fa9b8738bd45d
Alice x25519_pubfb1a6a847fbee0fb7f4119c602a8d0e6a9aa8e2cd785f0c580de3ec5b2e6f705
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_digestf0775ac2e1fd2096683a1ac3a338d56e92c051647ad0f4297c2477d6d964f277

Also replay the signing vectors in test-vectors.json first -- E2EE bugs are unreachable while request signing is broken.

10. Troubleshooting

SymptomLikely causeFix
derived seed differs between runsnon-deterministic signeruse an RFC 6979 library path; never a wallet popup that re-signs
seed differs between two librariesv not normalized to 0/1, or hex-string hashed instead of bytesnormalize; hash raw bytes
bundle verify fails on your own bundlepreimage field order / u32be vs u64be mixed uprebuild preimage exactly: domain, gen(4), ts(8), ed_pub, kp
DM decrypt fails, wrap not foundcomparing hex string to raw 20 bytes in addrcompare bytes
DM decrypt fails at unwrapwrong HKDF info, or salt not emptyinfo = "p2p-mes:dm-wrap:v1", salt empty
DM signature never verifiessigned the decoded object re-encoded, or missing chat_id in contextsign/verify the exact m bytes; context = domain || chat_id || m
control payload rejected by nodeover 32 KiB decoded, or not valid base64check size before send; split albums
text rejected by nodeover 45056 bytes (44 KiB) -- an enveloped payload over ~33 KBsend big payloads by reference via the attachments pipeline; never chunk across messages
CBOR decodes to arrays of numbersreading profile payloads with the msg_cbor quirk assumptionsprofile payloads use real byte strings; only the outer msg_cbor uses arrays
Welcome join failsratchet tree missing/stale, or KeyPackage in bundle rotated after the Welcome was sentalways ship tree with the Welcome; re-add on rotation
TLS decode of commit/tree/KeyPackage returns nothing, yet signatures verifycapabilities built with numeric enum idsuse string names: versions: ["mls10"], ciphersuite/credential names (8.1)
restored state crashes on paddingConfigdecodeGroupState yields a state without clientConfigreattach your config after decode (8.6)
commit fails to applyepoch skew (missed a commit) or competing commit lost the racereplay history in HLC order; rebase your commit
group message decrypts for others but not a new membermember added to the node list but Welcome lostreconciliation table above