Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

Plugin: ecdh-envelope

Role: One-shot sender→recipient confidentiality. The sender derives a single AEAD key from ECDH(sender_priv, recipient_op_pub) and seals the message in a self-contained envelope. An optional inner sub-encryption under a distinct domain separator carries a secondary handoff secret (e.g. a group's bootstrap epoch). No ratchet, no rotation, no shared state.

Reference consumer: enclaves/personal.md — confidentiality for the notice content event (cross-enclave addressed messages: group invites, DM invites, future kinds). Any future enclave that takes one-shot writes from external identities and needs to seal them to the owner (drop-box submissions, anonymous tips, signed acknowledgments, file uploads addressed to a recipient) is a candidate consumer.

Slot type (suggested): EcdhEnvelopeCryptoFn (client-side). Implementations MAY alias to their app-specific slot name (the reference impl uses PersonalNoticeCryptoFn).

The most important use today is group invitations — the sender pairs a Move(OUTSIDER→MEMBER) in the source group with an envelope write into the invitee's Personal enclave carrying the group's current root_secret so the invitee can decrypt the group's CT without first joining a side-channel.

This document is the complete wire-and-key contract. Two compliant implementations MUST agree byte-for-byte on the envelope and the handoff payload for any input set.


Table of Contents

  1. 1. Primitives (fixed; no negotiation)
  2. 2. Domain separators (exhaustive)
  3. 3. Outer envelope (content only)
  4. 4. Payload field contract
  5. 5. Inner handoff sub-encryption (group-invite path secret)
  6. 6. Cross-enclave reconciliation (recipient client behavior)
  7. 7. Error & rejection rules (normative)
  8. 8. Security properties
  9. 9. Compliance vectors (required in plugin tests)
  10. 10. Versioning

1. Primitives (fixed; no negotiation)

The primitives below are normative; implementations MUST use the concrete choice specified for every row.

PrimitiveConcrete choice
AEADXChaCha20-Poly1305 — 24-byte nonce, 16-byte Poly1305 tag
KDFHKDF-SHA-256 — salt = b"" (empty), info = ASCII domain separator, L = 32
ECDHsecp256k1; shared = 32-byte x-coordinate; x-only inputs extended with 0x02 per BIP-340 even-y convention
CSPRNGglobalThis.crypto.getRandomValues
Pubkey encodinglowercase hex, 32 bytes x-only
Outer envelope encoding{ ciphertext: <hex>, nonce: <hex> }two separate hex fields (not combined base64)
handoff entry encoding{ recipient, ecdh_pub, ciphertext, nonce } — all lowercase hex

2. Domain separators (exhaustive)

Each line below is a normative HKDF info literal; implementations MUST emit and accept the case-sensitive ASCII bytes verbatim, and the two MUST differ — a leaked outer-envelope key MUST NOT decrypt a handoff and vice versa.

enc:personal:notice          # outer envelope key (tags + content)
enc:personal:notice:epoch    # inner sub-encryption for `handoff` (group-invite path-secret)

No other separators are used by this plugin.


3. Outer envelope (content only)

The notice event's content is sealed end-to-end from the sender to the Personal-enclave OWNER. The node sees an opaque envelope object (JSON-stringified) — it never sees the plaintext payload. The event's tags MUST NOT be sealed by this plugin; they remain the protocol-standard [[name, value, …], …] array-of-strings shape (see spec.md §Tags) and MAY carry plaintext routing values.

3.1 Key derivation

The outer envelope_key MUST be derived by the code block below.

shared       = ECDH(sender_op_priv, recipient_op_pub)         # 32 bytes
envelope_key = HKDF(IKM=shared, info="enc:personal:notice", L=32)

sender_op_priv is the sender's operating private key: the parent identity_priv for an ECDH-capable wallet, or the deterministic sub_priv for an ECDH-incapable wallet (notably MetaMask). The matching sender_op_pub is published on the wire so the receiver can derive the same shared secret.

recipient_op_pub is the OWNER's published operating key: their reg_identity.sub_pub when present, otherwise their id_pub. Senders MUST consult Registry first; only if the recipient has no distinct sub-key published do they target the parent.

3.2 Envelope formation (sender)

The notice's structured payload is JSON-encoded to a single utf-8 string, then AEAD-sealed once. There is no separate tags/content split inside the AEAD — the whole payload goes through one encryption pass. Envelope formation MUST follow the code block below verbatim (field names, literal scheme value, literal boolean encrypted, nonce length).

plaintext    = utf8(JSON(payload))                # see §4 for `payload` shape
nonce        = CSPRNG(24)
ct           = XChaCha20-Poly1305(envelope_key, nonce, plaintext)
envelope     = {
  "ciphertext": hex(ct),
  "nonce":      hex(nonce),
  "sender_pub": hex(sender_op_pub),
  "scheme":     "personal:notice",
  "encrypted":  true
}
notice.content = JSON.stringify(envelope)         # placed in the commit's `content` UTF-8 string
notice.tags    = [["enclave_id", "<source_enclave_id_hex>"], ...]  # OPTIONAL plaintext routing tags

3.3 Wire fields on the notice event

{
  // protocol commit envelope (clear-text — see spec.md §Commit Structure):
  "type":    "notice",
  "from":    "<sender_op_pub_hex>",     // identical to envelope.sender_pub
  "content": "<JSON-stringified envelope>",  // the JSON-encoded envelope object below
  "tags":    [ ["<key>", "<value>", ...], ... ],  // protocol-standard, plaintext
  // ... (hash, exp, alg, sig as usual)
}

The content string parses to:

{
  "ciphertext": "<hex>",
  "nonce":      "<hex>",
  "sender_pub": "<sender_op_pub_hex>",   // the ECDH peer pub the receiver consumes
  "scheme":     "personal:notice",       // discriminator so a generic decryptor can dispatch
  "encrypted":  true                     // sentinel for clients (plaintext bypass guard)
}

The receiver MUST process the wire envelope by the code block below; in particular, it MUST derive envelope_key using env.sender_pub from the parsed wire envelope, not commit.from.

env          = JSON.parse(notice.content)
shared       = ECDH(my_op_priv, fromHex(env.sender_pub))
envelope_key = HKDF(shared, "enc:personal:notice", 32)
payload      = JSON.parse(utf8_decode(XChaCha20-Poly1305(envelope_key, fromHex(env.nonce)).decrypt(fromHex(env.ciphertext))))

The OWNER tries each of their own operating keys (parent and sub when distinct) — the sender chose one; the receiver tries each. With a single key, only one trial is needed.

sender_pub is included explicitly because, for sub-key wallets, it MAY differ from the commit's from field (the sender encrypts with whichever op-key it controls; from reflects the signing key). Receivers MUST use env.sender_pub for ECDH, not commit.from.


4. Payload field contract

The plaintext sealed inside the envelope is a single JSON object. The plugin defines the fields it MUST carry; applications MAY add their own fields under an x- prefix.

{
  "kind":          "group_invite" | "dm_invite" | "x-<app-defined>",   // REQUIRED — discriminator
  "enclave_id":    "<source_enclave_id_hex>",   // REQUIRED — hex64; the enclave the notice refers to
  "enclave_kind":  "group" | "dm" | "<x-app-id>", // REQUIRED — manifest hint
  "inviter":       "<inviter_pub_hex>",         // REQUIRED — identity that produced the addressed action
 
  "topic":         "<utf8>",                    // OPTIONAL — group display name (for group_invite)
  "greeting":      "<utf8>",                    // OPTIONAL — human-readable message
  "manifest_hash": "<hex32>",                   // OPTIONAL — sha256 of the source enclave's Manifest event content
  "move_ref":      "<source_enclave_move_event_id_hex>",  // OPTIONAL — for client-side cross-verification
  "handoff":       { ... PathSecretEntry ... }, // OPTIONAL — present for kind == "group_invite"; see §5
  "epoch_n":       <int>                        // REQUIRED iff handoff is present — the source enclave's epoch.n that the handoff secret was wrapped at
}
FieldRequirement
kindREQUIRED — discriminator
enclave_idREQUIRED — hex64; the enclave the notice refers to
enclave_kindREQUIRED — manifest hint
inviterREQUIRED — identity that produced the addressed action
epoch_nREQUIRED iff handoff is present

The payload MUST include kind, enclave_id, enclave_kind, and inviter. When handoff is present, epoch_n MUST also be present.

Unknown kind values MUST be passed through (forward-compat). Required fields MUST be present and validated; missing required fields → notice rejected as malformed.

Applications MAY also surface a subset of these fields via notice.tags for plaintext routing — e.g. ["enclave_id", "<hex>"], ["enclave_kind", "group"]. Plaintext tags are useful when a relay or indexer needs to route notices without decrypting; they are NOT a security boundary and MUST match the sealed payload when both are present.


5. Inner handoff sub-encryption (group-invite path secret)

For kind: "group_invite", the handoff field carries the group's root_secret for the epoch the inviter committed (32 bytes), sealed with a distinct ECDH-derived key. The receiver derives epoch_secret = HKDF(root_secret, "enc:mls:epoch", 32) per mls-lazy.md §4. The domain separator differs from §3 so a leaked outer-envelope key cannot replay against a handoff, and vice versa.

The payload's REQUIRED epoch_n field (§4) records which epoch.n this root_secret belongs to — the receiver MUST use it both to verify the originating Move (§6) and to index the recovered secret in their per-group epoch map.

5.1 Sender side

Handoff formation MUST follow the code block below verbatim (dist_key derivation, nonce length, handoff field set); the sender MUST also set payload.epoch_n to the epoch.n the committer wrote.

root_secret = <the 32-byte root_secret the committer chose for this epoch>
shared      = ECDH(committer_priv, recipient_op_pub)          # 32 bytes
dist_key    = HKDF(shared, "enc:personal:notice:epoch", L=32)
nonce       = CSPRNG(24)
ct          = XChaCha20-Poly1305(dist_key, nonce).encrypt(root_secret)
 
handoff = {
  recipient:  lowercase_hex(recipient_op_pub),
  ecdh_pub:   lowercase_hex(committer_pub),
  ciphertext: hex(ct),
  nonce:      hex(nonce)
}
# also set payload.epoch_n = <the epoch.n the committer wrote>

committer_priv / committer_pub is the inviter's identity — the admin who issued the Move(OUTSIDER→MEMBER) in the source group. Backward-compatibility: when recipient_op_pub == recipient_id_pub (no sub-key), a single handoff suffices. When they differ, the inviter SHOULD address the operating key the recipient is currently signed in with; conservative implementations MAY emit one handoff per published recipient op key, embedded as an epoch_or_wraps-style array. This plugin's reference impl uses a single addressed entry; multi-recipient variants are out of scope here (handle at the application layer if needed).

5.2 Receiver side

After decrypting the outer envelope (§3) the OWNER parses the payload (§4), finds handoff (when present), and:

if handoff.recipient != lowercase_hex(my_op_pub):
    skip  # not addressed to my operating key
shared    = ECDH(my_op_priv, fromHex(handoff.ecdh_pub))
dist_key  = HKDF(shared, "enc:personal:notice:epoch", 32)
secret    = XChaCha20-Poly1305(dist_key, fromHex(handoff.nonce)).decrypt(fromHex(handoff.ciphertext))
# `secret` MUST be 32 bytes; feed into plugins/mls-lazy.md §4 to derive epoch_secret

The receiver MUST skip handoffs whose recipient field does not equal lowercase hex of any of their operating keys. The recovered handoff plaintext MUST be exactly 32 bytes.

The recovered secret is the group's root_secret at the epoch when the envelope was written. The receiver derives epoch_secret = HKDF(root_secret, "enc:mls:epoch", 32) (per mls-lazy.md §4) and stores it in their per-group epoch map. From then forward, normal MLS epoch ratcheting (subsequent Move / rotate events the recipient sees once they are subscribed to the group) supersedes this bootstrap secret.

5.3 Bootstrap-only contract

The handoff is bootstrap-only: it seeds the recipient's first known group epoch. All subsequent group epochs reach them through the group's normal in-CT mechanism (plugins/mls-lazy.md §5 — encrypted_path_secrets and §6 epoch_or_wraps). The handoff is not re-emitted on future rotations.


6. Cross-enclave reconciliation (recipient client behavior)

A notice is a claim — the inviter might not actually have moved the recipient into the source enclave, or the source enclave might not exist. Before surfacing the notice as actionable, the recipient's client MUST:

  1. Parse the wire-level envelope from notice.content (§3.3) and decrypt it under the receiver's operating key(s).
  2. Parse the recovered plaintext as JSON; validate the payload field contract (§4).
  3. Look up the source enclave by payload.enclave_id via reg_enclave.
  4. Subscribe to the source enclave and verify the originating action exists in its CT:
    • For kind: "group_invite": search for Move(OUTSIDER → MEMBER) targeting the recipient by the inviter (event.from == payload.inviter) at or before the bundle whose epoch.n matches payload.epoch_n.
  5. If the originating action is present → surface as actionable.
  6. If absent → treat as pending; retry verification within a tolerance window (default 24 h). On timeout, silently discard via OWNER:D.

This client-side check is the spec's primary defense against forged notices.


7. Error & rejection rules (normative)

An implementation MUST:

  1. Reject the notice (drop) when notice.content is not valid JSON or is missing the envelope shape ({ciphertext, nonce, sender_pub, scheme: "personal:notice", encrypted: true}).
  2. Reject the notice when AEAD decryption fails for every candidate operating key the receiver holds.
  3. Reject when the recovered payload is not valid JSON, or is missing any of kind, enclave_id, enclave_kind, inviter.
  4. Reject when kind == "group_invite" and epoch_n is absent.
  5. Reject handoff (treat as missing) when handoff.recipient does not match any of the owner's operating keys.
  6. Reject handoff decryption when the recovered plaintext is not exactly 32 bytes.
  7. Not treat handoff decryption failure as a notice rejection — surface the notice as actionable text-only and let the cross-enclave verification (§6) catch a forged invite.

8. Security properties

PropertyStatus
Envelope (content) confidentiality against the nodeYES — node sees the opaque envelope object only; notice.tags are NOT plugin-sealed and are intentionally plaintext for routing
Envelope confidentiality against non-owner readersYES — OWNER:R is the only R operator on notice in enclaves/personal.md's manifest
Sender unforgeability of inviter claimNO at the envelope layer — the envelope only proves "someone with sender_op_priv wrote this"; the claimed inviter field is application data. Verification of the originating action (§6) is the spec's defense.
Handoff confidentialityYES — distinct ECDH + distinct domain separator; addressed to recipient op key.
Forward secrecy against identity-key compromiseNO — the CT stores both the outer envelope and the handoff ciphertexts; an identity-key compromise recovers them all. Tradeoff inherited from CT replay.
Post-compromise securityNO — same reason.
Replay protection on notice eventsINHERITED from protocol-level commit dedup (commit.sig is unique per event); the plugin adds none.
Sub-key (multi-wallet) supportYES — sender addresses recipient's published operating key (sub_pub when present); handoff is similarly addressed.

9. Compliance vectors (required in plugin tests)

Implementations MUST publish KAT vectors for:

  1. Outer envelope round-trip: encrypt a fixed payload object → decrypt under the recipient's op-priv yields byte-identical JSON plaintext; the envelope JSON object on the wire matches a fixed reference.
  2. Handoff round-trip: a sender's wrap with recipient = parent_pub AND the same recipient = sub_pub yields two distinct ciphertexts; each is recoverable only by the matching priv.
  3. Wrong sender_pub → outer-envelope AEAD failure → notice rejected per §7.2.
  4. Wrong handoff.recipient → handoff treated as missing per §7.5 (notice still surfaces as text-only).

10. Versioning

This plugin is version 1. Any change to a domain separator, the AEAD primitive, the nonce length, the KDF, or the wire field layout requires a new plugin (ecdh-envelope-v2) and a new kind discriminator namespace — clients negotiate by manifest, never by field-sniffing.