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: 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. 1. Primitives (fixed; no negotiation)
  2. 2. Domain separators (exhaustive — case-sensitive ASCII)
  3. 3. Key schedule
  4. 4. Wire format (event content)
  5. 5. Per-contact monotonicity (client-validated)
  6. 6. Multi-recipient OR-wrap (repeatable epoch tag)
  7. 7. Epoch recovery (multi-device join / fresh login)
  8. 8. Error & rejection rules (normative)
  9. 9. Security properties
  10. 10. Compliance vectors (required in plugin tests)
  11. 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.

PrimitiveConcrete choice
AEADXChaCha20-Poly1305 (libsodium / RFC 8439-derived) — 24-byte nonce, 16-byte Poly1305 tag
KDFHKDF-SHA-256 — salt = b"" (empty) unless stated, IKM = stage-specific, info = ASCII domain separator, L = 32 bytes
ECDHsecp256k1. 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).
CSPRNGglobalThis.crypto.getRandomValues (Web Crypto). MUST be cryptographically secure.
Pubkey encoding on the wirelowercase 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 bytes

3.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 §6

4.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] )  // strict

First 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 in reg_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_pub and 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-key

The 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

  • invite event tags (epoch tag list — §4.2).
  • message event tags when a new epoch is being delivered to the peer (§4.1 messages MAY also carry epoch tags).

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); break

The 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:

  1. encrypted_secret does not base64-decode to at least 24 (nonce) + 16 (Poly1305 tag) = 40 bytes. After AEAD decryption, the recovered epoch_secret plaintext MUST be exactly 32 bytes; reject otherwise.
  2. epoch.n is not a non-negative integer; or violates §5 monotonicity for that target; or is the first wrap observed for a target and is not exactly 0 (§5).
  3. sender_seq in a message is negative or not an integer.
  4. An AEAD tag verification fails on any inner decryption (per stage, per the listed key).
  5. A required epoch tag is absent on the first invite/message of a new epoch (no key material to derive message_key from).
  6. ecdh_pub length 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

PropertyStatus
Per-message key isolationYES — unique message_key per (epoch, sender_seq) via KDF ratchet
Per-contact key isolationYES — epochs are per-contact; one contact's epoch reveals nothing about another's
Per-epoch forward secrecyYES — independent random epoch_secret per rotation
Intra-epoch forward secrecyOPTIONAL — stateful clients can forget chain[i-1] after advancing; stateless decrypt re-derives
Backward secrecy on addYES — new contact sees only their own epoch_secret, not other contacts'
Forward secrecy against identity-key compromiseNO — the CT stores epoch_secret wraps recoverable with identity_priv. Tradeoff for stateless multi-device sync.
Post-compromise securityNO — same reason; the long-term identity key recovers all past + future epoch material from the CT
Multi-device supportYES — every per-contact epoch and every sent mirror is rederivable from identity_priv alone
Sub-key multi-wallet supportYES (OR-wrap §6) — MetaMask-style ECDSA-only wallets read via sub_priv
Replay / reorder of epoch wrapsYES — 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:

  1. Ratchet message-key derivation for sender_seq ∈ {0, 1, 7, 100} from a fixed epoch_secret.
  2. Round-trip wrap → unwrap for a self-encrypted epoch (owner_priv = owner_pub case).
  3. Round-trip invite encrypt/decrypt.
  4. Round-trip sent encrypt/decrypt with a non-equal id_pub ≠ to_pub.
  5. OR-wrap: two tags emitted with parent_pub ≠ sub_pub; receiver-as-parent recovers from tag 1, receiver-as-sub recovers from tag 2.
  6. Monotonicity: a wrap with n equal to or less than prevHighestN is 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.