Plugin: ratchet-pair
Role: Pairwise confidentiality. Two identities establish a per-pair epoch over secp256k1 ECDH, then each side derives a per-sender symmetric ratchet inside the epoch. Multi-key recipients (sub-key wallets) are handled by an additive OR-wrap on epoch tags.
Reference consumer: enclaves/dm.md — confidentiality for invite, message, sent, rotate, Move(O→F).content.epoch. Any future app that has 1:1 confidential threads between two identities (call setup, file drop, signed receipt, etc.) is a candidate consumer.
Slot type (suggested): PairRatchetCryptoFn (client-side). Implementations MAY alias to their app-specific slot name (the reference impl uses DMCryptoFn for historical reasons).
This document is the complete wire-and-key contract. Two compliant implementations MUST produce byte-identical ciphertexts on the wire given identical inputs.
Table of Contents
- 1. Primitives (fixed; no negotiation)
- 2. Domain separators (exhaustive — case-sensitive ASCII)
- 3. Key schedule
- 4. Wire format (event content)
- 5. Per-contact monotonicity (client-validated)
- 6. Multi-recipient OR-wrap (repeatable
epochtag) - 7. Epoch recovery (multi-device join / fresh login)
- 8. Error & rejection rules (normative)
- 9. Security properties
- 10. Compliance vectors (required in plugin tests)
- 11. Versioning
1. Primitives (fixed; no negotiation)
The primitives below are normative; implementations MUST use the concrete choices specified for every row. The Forbidden list MUST NOT be used.
| Primitive | Concrete choice |
|---|---|
| AEAD | XChaCha20-Poly1305 (libsodium / RFC 8439-derived) — 24-byte nonce, 16-byte Poly1305 tag |
| KDF | HKDF-SHA-256 — salt = b"" (empty) unless stated, IKM = stage-specific, info = ASCII domain separator, L = 32 bytes |
| ECDH | secp256k1. shared = first 32 bytes of compressed point = x-coordinate. x-only inputs (32 bytes) are accepted by prepending 0x02 (BIP-340 even-y convention; see spec.md §Public Key Parity). |
| CSPRNG | globalThis.crypto.getRandomValues (Web Crypto). MUST be cryptographically secure. |
| Pubkey encoding on the wire | lowercase hex; x-only (32 bytes / 64 hex chars) by convention |
| Combined-AEAD encoding on the wire | **`base64( nonce(24) |
Forbidden: Composite key reuse across stages, non-btoa/atob base64 variants (URL-safe / no-pad alphabets), big-endian / signed conversions of HKDF outputs.
2. Domain separators (exhaustive — case-sensitive ASCII)
Each line below is a normative HKDF info literal; implementations MUST emit and accept the case-sensitive ASCII bytes verbatim. The per-counterparty sent-mirror separator MUST be enc:dm:sent: concatenated with the lowercase-hex recipient pubkey.
enc:dm:ratchet:init
enc:dm:ratchet:advance
enc:dm:ratchet:message
enc:dm:epoch_dist
enc:dm:invite
enc:dm:sent:root
enc:dm:sent:<recipient_pub_hex_lowercase>No other separators are used by this plugin. Implementations MUST NOT add or accept aliases.
3. Key schedule
3.1 Per-contact symmetric ratchet (message keys)
For epoch secret E (32 bytes) and a sender's sender_seq = i ≥ 0, the per-contact symmetric ratchet MUST derive every key by the code block below.
ratchet_seed = HKDF(IKM=E, info="enc:dm:ratchet:init", L=32)
chain[0] = ratchet_seed
chain[i+1] = HKDF(IKM=chain[i], info="enc:dm:ratchet:advance", L=32)
message_key(i) = HKDF(IKM=chain[i], info="enc:dm:ratchet:message", L=32)Each sender_seq MUST yield exactly one message_key. Deriving message_key(i) requires i advance steps from ratchet_seed. Senders SHOULD cache the highest chain[i] they've reached and forget earlier links (intra-epoch forward secrecy on a stateful client; stateless rederive is also valid).
3.2 Epoch distribution (one wrap)
For an epoch_secret (32 bytes) wrapped from my_priv to peer_pub, the wrap MUST be computed by the code block below.
shared = ECDH(my_priv, peer_pub) # 32 bytes (x-coordinate)
dist_key = HKDF(IKM=shared, info="enc:dm:epoch_dist", L=32)
nonce = CSPRNG(24)
ct = XChaCha20-Poly1305(key=dist_key, nonce, plaintext=epoch_secret)
encrypted_secret = base64( nonce || ct ) # single string
ecdh_pub = lowercase_hex( x_only( pub(my_priv) ) )ecdh_pub is the wrapper's own pubkey — for self-encrypted wraps (e.g. owner-to-owner in a Move payload, for device sync), peer_pub == my_pub. For participant-encrypted wraps (delivered cross-enclave to a recipient), peer_pub == recipient_op_pub (see §6).
Recovery (any party that holds recipient_priv for this ecdh_pub) MUST proceed by the code block below; the recovered epoch_secret MUST be exactly 32 bytes.
shared = ECDH(recipient_priv, fromHex(ecdh_pub))
dist_key = HKDF(shared, "enc:dm:epoch_dist", 32)
buf = base64-decode(encrypted_secret)
nonce = buf[0:24]
ct = buf[24:]
epoch_secret = XChaCha20-Poly1305.decrypt(dist_key, nonce, ct) # MUST be 32 bytes3.3 Invite envelope
The invite event's content (an encrypted greeting from OUTSIDER:C) is sealed by a single-shot ECDH-derived key (no ratchet); the envelope MUST be produced by the code block below.
shared = ECDH(sender_priv, recipient_op_pub)
invite_key = HKDF(shared, "enc:dm:invite", 32)
nonce = CSPRNG(24)
ct = XChaCha20-Poly1305(invite_key, nonce, utf8(plaintext))
content = base64( nonce || ct )3.4 Sent mirror (per-counterparty, owner's own enclave)
The owner writes a self-encrypted copy of each outgoing DM into their own enclave so cross-device sync sees the conversation. Key derivation is stateless from the identity key + counterparty pub — any device with identity_priv rederives.
Sent-mirror events MUST be produced by the code block below.
self_shared = ECDH(identity_priv, identity_pub) # ECDH with own key
sent_root = HKDF(self_shared, "enc:dm:sent:root", 32)
to_lower = lowercase_hex(recipient_pub)
sent_key = HKDF(sent_root, "enc:dm:sent:" || to_lower, 32)
nonce = CSPRNG(24)
ct = XChaCha20-Poly1305(sent_key, nonce, utf8(plaintext))
content = base64( nonce || ct )
tag = ["to", "<recipient_pub_hex_lowercase>"]The ["to", …] tag is REQUIRED on every sent event (lets readers find their per-counterparty key without scanning every sent event).
3.5 NIP-44 v2 (remote-signer Nostr identities)
A Nostr (NIP-07) identity is a pure remote signer — the private key never leaves the signer (browser extension / hardware), so it CANNOT compute the ECDH x-coord locally and therefore cannot run the §3.1 ratchet or unwrap the §3.2 epoch. Just signing commits is not sufficient for DM/group encryption (that needs ECDH). For such an identity the DM/group cipher is NIP-44 v2 (nostr-protocol/nips §44): the signer runs ECDH + cipher in-place (window.nostr.nip44), and the counterparty — who holds a priv — speaks the SAME cipher over the SAME secp256k1 ECDH x-coord (byte-identical to ECDH(...) everywhere else in this plugin), so both derive the same conversation key.
The construction MUST be the code block below. Primitives: secp256k1 ECDH (x-coord, even-Y lift), HKDF-SHA256 (extract + expand, separate steps), ChaCha20 (raw stream — NOT XChaCha20-Poly1305), HMAC-SHA256.
conversation_key = HKDF-extract(salt = utf8("nip44-v2"), ikm = ECDH_x(my_priv, peer_pub))
# per message:
nonce = CSPRNG(32)
keys = HKDF-expand(conversation_key, info = nonce, length = 76)
chacha_key = keys[0:32]; chacha_nonce = keys[32:44]; hmac_key = keys[44:76]
padded = u16_be(len(plaintext)) || utf8(plaintext) || zero_pad # §3.5.1
ct = ChaCha20(chacha_key, chacha_nonce, padded)
mac = HMAC-SHA256(hmac_key, nonce || ct) # aad = the 32-byte nonce
payload = base64( 0x02 || nonce(32) || ct || mac(32) )Decrypt reverses this and MUST verify the MAC in constant time before unpadding; it MUST reject on version ≠ 0x02, payload byte-length ∉ [99, 65603], or MAC mismatch.
§3.5.1 Padding. For plaintext length L (1 ≤ L ≤ 65535): pad_len(L) = 32 if L ≤ 32; otherwise chunk · (⌊(L−1)/chunk⌋ + 1) where next = 1 << (⌊log2(L−1)⌋ + 1) and chunk = 32 if next ≤ 256 else next/8. The padded buffer is 2 + pad_len(L) bytes (2-byte big-endian length prefix + plaintext + zero pad).
Conformance. Implementations MUST pass every v2.valid vector in Nostr's official nip44.vectors.json (get_conversation_key, calc_padded_len, get_message_keys, encrypt_decrypt). The scheme composes ONLY the verified core primitives (chacha20Stream / hmacSha256 / hkdfExtract / hkdfExpand / base64Encode); it is generated from Enc.DSL.Modules.DmRatchet into plugin-dm-ratchet and pinned by that package's test/nip44.test.mjs.
4. Wire format (event content)
All four event types carry plaintext-as-utf8 inside the AEAD. Public keys on the wire MUST be lowercase hex.
Each subsection below specifies a normative wire shape; the JSON code block is the canonical emitted form (field names, types, ordering of required fields).
4.1 message
{
"epoch": <int>, // epoch.n that produced the message_key
"sender_seq": <int>, // ≥ 0; the sender's per-epoch counter
"ciphertext": "<base64(nonce || ct)>"
}4.2 invite
content: "<base64(nonce || ct)>" # the encrypted greeting
tags:
["enclave_id", "<sender_dm_enclave_id_hex>"] # the sender's DM enclave (for the recipient to reply into)
["epoch", "<n>", "<encrypted_secret_b64>", "<ecdh_pub_hex>"] # 1+ tags — see §64.3 sent (owner's own copy of an outgoing message)
content: "<base64(nonce || ct)>"
tags: [["to", "<recipient_pub_hex_lowercase>"]]4.4 rotate (DM epoch rotation)
{
"target": "<contact_pub_hex>",
"epoch": {
"n": <int>,
"encrypted_secret": "<base64(nonce || ct)>",
"ecdh_pub": "<owner_pub_hex>"
}
}For rotate (owner-only event) the canonical wrap MUST be self-encrypted (ecdh_pub == owner_pub). The peer learns the new epoch lazily via the next outgoing message's repeatable epoch tag (§6). There is no epoch_or_wraps field on ratchet-pair commits — that array belongs to mls-lazy and does NOT apply here; per §6.3 below, OR-wrap delivery for ratchet-pair rides on the repeatable epoch tag instead.
4.5 Move(OUTSIDER→FRIEND) content (carries epoch for device-sync)
{
"target": "<contact_pub_hex>",
"from": "OUTSIDER",
"to": "FRIEND",
"epoch": { "n": 0, "encrypted_secret": "...", "ecdh_pub": "<owner_pub_hex>" }
}The Move(O→F) epoch wrap MUST be self-encrypted (ecdh_pub == owner_pub). The peer learns the epoch via the epoch tag on the corresponding invite (§6), not from this Move.
5. Per-contact monotonicity (client-validated)
For a target identity T, the highest accepted epoch.n MUST grow strictly across same-target wraps in the owner's CT (Move(OUTSIDER→FRIEND).epoch.n, rotate.epoch.n) and across participant epoch tags from the same sender enclave. Wraps that violate this MUST be rejected (replay / reorder defense).
prevHighestN[T] = max(observed n for target T so far)
on new wrap for T: require( wrap.n > prevHighestN[T] ) // strictFirst wrap for T MUST be n == 0.
6. Multi-recipient OR-wrap (repeatable epoch tag)
A recipient can operate from more than one key:
- A parent identity pub (
id_pub, 32-byte x-only) — direct ECDH on the secp256k1 identity key. - A sub pub (
sub_pub, 32-byte x-only) — a deterministic HKDF sub-key published inreg_identity.sub_pub, used by wallets that cannot perform secp256k1 ECDH (notably MetaMask EIP-191/ECDSA). When the wallet is ECDH-capable (ENC, Nostr, NFC),sub_pub == id_puband there is no distinct sub key.
The sub-key is derived once per wallet binding from a static, deterministic HKDF chain — its derivation is out of scope for this plugin (see spec.md §Delegated Sub-keys). This plugin only consumes recipient pubkeys as published in reg_identity.sub_pub.
6.1 Sender side (emit one tag per recipient key)
For each recipient R of an outgoing invite or message, senders MUST emit one tag per operating key by the code block below.
ops = unique([ R.id_pub, R.sub_pub ]) # ≥ 1 entry; collapses to one when sub_pub == id_pub
for op_pub in ops:
w = wrap(my_priv, op_pub, epoch_secret) # §3.2
push tag: ["epoch", str(epoch.n), w.encrypted_secret, w.ecdh_pub]All OR-wrap epoch tags MUST carry the same n and the same ecdh_pub (the sender's own pub). encrypted_secret differs per op_pub because dist_key differs (different shared). Order is not significant — receivers MUST iterate all tags.
6.2 Receiver side (try each tag with the operating key)
Receivers MUST iterate every ["epoch", ...] tag per the code block below; receivers MUST keep secret and stop when len(secret) == 32, and MUST continue iteration on AEAD tag failure.
for tag in event.tags where tag[0] == "epoch":
try:
shared = ECDH(my_op_priv, fromHex(tag[3]))
dist_key = HKDF(shared, "enc:dm:epoch_dist", 32)
secret = XChaCha20-Poly1305.decrypt(dist_key, base64-decode(tag[2]))
if len(secret) == 32: keep secret; break
except AEAD tag failure: continue # this tag was not for my op-keyThe receiver's "operating key" my_op_priv / my_op_pub is whichever of { parent, sub } the current login holds. Backward-compatibility note: when the sender's recipient set collapses to a single key (parent == sub, legacy wallets, or recipient is dev/passkey), the event carries exactly one epoch tag — identical to pre-OR-wrap wire.
6.3 Where OR-wrap is required
inviteevent tags (epochtag list — §4.2).messageevent tags when a new epoch is being delivered to the peer (§4.1 messages MAY also carryepochtags).
The epoch tag MUST be repeatable on invite and message events.
OR-wrap MUST NOT apply to rotate or the Move-carried epoch object — those wrap with peer_pub = owner_pub (self-encrypted device sync), and the owner's operating key is whatever they are signed in with.
7. Epoch recovery (multi-device join / fresh login)
A device with identity_priv (and, when applicable, its derived sub_priv) MUST rebuild the per-contact epoch map by replaying the owner's CT in seq order per the code block below.
state: { contact_pub → { n → epoch_secret } }
for evt in CT (in seq order):
if evt is Move(O→F, target=T) or rotate(target=T):
try unwrap(my_priv, evt.content.epoch.encrypted_secret, evt.content.epoch.ecdh_pub)
→ secret; record(T, evt.content.epoch.n, secret)
if evt is incoming invite or message with one-or-more "epoch" tags:
for each "epoch" tag (n, enc_secret, ecdh_pub):
try unwrap(my_op_priv, enc_secret, ecdh_pub)
→ secret; record(senderEnclave→ourPair, n, secret); breakThe epoch map MUST be per-contact, per-epoch. The "current" epoch for a contact is the entry with the highest n that satisfies the §5 monotonicity rule.
8. Error & rejection rules (normative)
An implementation MUST reject (drop the event / fail decrypt) when:
encrypted_secretdoes not base64-decode to at least24 (nonce) + 16 (Poly1305 tag) = 40bytes. After AEAD decryption, the recoveredepoch_secretplaintext MUST be exactly 32 bytes; reject otherwise.epoch.nis not a non-negative integer; or violates §5 monotonicity for that target; or is the first wrap observed for a target and is not exactly0(§5).sender_seqin amessageis negative or not an integer.- An AEAD tag verification fails on any inner decryption (per stage, per the listed key).
- A required
epochtag is absent on the firstinvite/messageof a new epoch (no key material to derivemessage_keyfrom). ecdh_publength is not 64 hex chars / 32 bytes.
Implementations SHOULD log AEAD failures at debug level only (they are expected during OR-wrap iteration).
9. Security properties
| Property | Status |
|---|---|
| Per-message key isolation | YES — unique message_key per (epoch, sender_seq) via KDF ratchet |
| Per-contact key isolation | YES — epochs are per-contact; one contact's epoch reveals nothing about another's |
| Per-epoch forward secrecy | YES — independent random epoch_secret per rotation |
| Intra-epoch forward secrecy | OPTIONAL — stateful clients can forget chain[i-1] after advancing; stateless decrypt re-derives |
| Backward secrecy on add | YES — new contact sees only their own epoch_secret, not other contacts' |
| Forward secrecy against identity-key compromise | NO — the CT stores epoch_secret wraps recoverable with identity_priv. Tradeoff for stateless multi-device sync. |
| Post-compromise security | NO — same reason; the long-term identity key recovers all past + future epoch material from the CT |
| Multi-device support | YES — every per-contact epoch and every sent mirror is rederivable from identity_priv alone |
| Sub-key multi-wallet support | YES (OR-wrap §6) — MetaMask-style ECDSA-only wallets read via sub_priv |
| Replay / reorder of epoch wraps | YES — strict epoch.n monotonicity (§5) |
Note on CT-storage tradeoff: PCS and FS-against-identity-compromise are out of reach for any CT-replayable confidentiality scheme on this protocol. PCS-grade variants require an off-CT side-channel (e.g., Double-Ratchet).
10. Compliance vectors (required in plugin tests)
Implementations MUST publish KAT vectors covering at least:
- Ratchet message-key derivation for
sender_seq ∈ {0, 1, 7, 100}from a fixedepoch_secret. - Round-trip
wrap → unwrapfor a self-encrypted epoch (owner_priv = owner_pub case). - Round-trip
inviteencrypt/decrypt. - Round-trip
sentencrypt/decrypt with a non-equalid_pub ≠ to_pub. - OR-wrap: two tags emitted with
parent_pub ≠ sub_pub; receiver-as-parent recovers from tag 1, receiver-as-sub recovers from tag 2. - Monotonicity: a wrap with
nequal to or less thanprevHighestNis rejected.
11. 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 (ratchet-pair-v2) and a new canonical event type or manifest field — clients negotiate by manifest, never by tag-sniffing. Clients MUST negotiate by manifest and MUST NOT negotiate by tag-sniffing.