Encryption Plugins
ENC enclaves carry opaque ciphertext end-to-end — the node never sees plaintext, and the enclave's RBAC schema authorizes against roles, not crypto primitives. Confidentiality is therefore an application-layer concern: an app picks an encryption plugin whose wire format and key schedule it uses to seal event content and tags.
This directory is a catalog of cryptographic shapes, not an app dispatcher. Each plugin is named by what it is (the cryptographic mechanism), not by the first app that adopted it — so any new app spec can pick the right plugin from this index without renaming or forking.
Each plugin spec is the single source of truth for its wire-and-key contract — two compliant implementations MUST produce byte-identical ciphertexts on the wire given identical inputs.
Table of Contents
- Catalog
- Capability matrix
- Reference consumers (current apps)
- What each plugin spec contains
- How an app spec references a plugin
- Versioning across the catalog
- Catalog Implementation Names
Catalog
The catalog lists exactly CATALOG_SIZE = 4 plugins, one per PluginID variant — ratchet-pair, mls-lazy, identity-aead, ecdh-envelope — each named by its cryptographic shape (shapeOf .ratchet_pair = .pairwise, shapeOf .mls_lazy = .sharedSecret, shapeOf .identity_aead = .singleOwner, shapeOf .ecdh_envelope = .oneShotDirected).
| Plugin | Cryptographic shape | One-line use case |
|---|---|---|
ratchet-pair | Pairwise: ECDH-derived per-pair epoch + per-sender HKDF ratchet inside the epoch; additive OR-wrap for sub-key recipients. | Any 1:1 confidential thread between two identities (DMs, call setup, file drop, signed receipt). |
mls-lazy | Shared-secret: binary ratchet tree keyed to sorted member pubs (O(log N) per commit) + per-sender HKDF ratchet + additive OR-wrap fallback. | Any N-party shared confidentiality (group chat, forum, project room, channel). |
identity-aead | Single-owner: deterministic HKDF from identity_priv + enclave id; no ECDH, no rotation. | Any event whose only reader is the owning identity (personal vault, per-app prefs, wallet backup). |
ecdh-envelope | One-shot directed: ECDH(sender, recipient) + AEAD; OPTIONAL inner sub-encryption for handoff. | Any one-shot sealed message from an outside writer to a single recipient (cross-enclave invite, drop-box submission, anonymous tip, addressed receipt). |
Capability matrix
The matrix has capabilityAxes.length = 10 rows ({confidentiality_vs_node, per_message_key_isolation, per_sender_isolation, FS_epochs, FS_removal, backward_secrecy_add, multi_device, sub_key_wallet, stateless_decryption, PCS_vs_identity_priv}).
| Capability | ratchet-pair | mls-lazy | identity-aead | ecdh-envelope |
|---|---|---|---|---|
| Confidentiality vs. node | ✅ | ✅ | ✅ | ✅ |
| Per-message key isolation | ✅ | ✅ | nonce-only | nonce-only |
| Per-sender / per-counterparty isolation | ✅ | ✅ | n/a (single) | ✅ |
| Forward secrecy across epochs | ✅ | ✅ | NONE | NONE |
| Forward secrecy on removal | n/a | ✅ | n/a | n/a |
| Backward secrecy on add | ✅ | ✅ | n/a | n/a |
Multi-device via identity_priv | ✅ | ✅ | ✅ | ✅ |
| Sub-key wallet support (OR-wrap) | ✅ | ✅ | n/a | ✅ (single recipient) |
| Stateless decryption | ✅ | ✅ | ✅ | ✅ |
Post-compromise security vs. identity_priv | NO | NO | NO | NO |
CT-replayability is custody-conditional. Under local custody (the identity holds identity_priv in memory), every plugin in this catalog is CT-replayable from identity_priv — the protocol's accepted trust-root tradeoff, captured by the "NO" column above. Under delegated custody (the identity is gated by an external signer oracle: NIP-07, NIP-46, WebAuthn, KMS, HSM, threshold signer), identity_priv is unreachable to the client, so offline bulk re-derivation is impossible. Recovery in that case is decrypt-on-demand: one signer round-trip per ciphertext, consent-gated by the oracle. The capability matrix above and the multi-device row (Multi-device via identity_priv) restate that local-custody guarantee. No plugin in this catalog MAY assume offline bulk re-derivation; the identity model (spec.md §Identity Custody) carries the custody descriptor.
PCS-grade variants — those that escape the trust-root tradeoff — require an off-CT side-channel under either custody.
Role × Suite
A plugin in this catalog is the pair (role, suite). The role is the trust-shape (pairwise / single-owner / N-party / one-shot-directed); the suite is the concrete ciphersuite (AEAD + nonce length + KDF + wire framing) that lives above the shared secp256k1 ECDH floor. Roles and suites are orthogonal — a role can run over multiple suites, and a suite can host multiple roles. The registry of suite ids lives in suites.md — a cross-cutting companion doc at the spec root (like smt.md / ct.md), not a fifth entry in this role catalog.
Of the four catalog entries above, three use the enc-xchacha-v1 suite (XChaCha20-Poly1305, 24-byte nonce) and one — mls-lazy — uses mls-chacha-v1 (ChaCha20-Poly1305, 12-byte nonce per RFC 9420). A future Nostr-bridged pairwise plugin would be (pairwise, nostr-nip44-v2); the role stays the existing one, the suite carries the externally-versioned wire shape.
| Catalog plugin | Role | Suite |
|---|---|---|
ratchet-pair | pairwise | enc-xchacha-v1 |
mls-lazy | sharedSecret (N-party) | mls-chacha-v1 |
identity-aead | singleOwner | enc-xchacha-v1 |
ecdh-envelope | oneShotDirected | enc-xchacha-v1 |
Suite authentication, no tag-sniffing, sender-side suite selection, and fail-closed behavior are defined in suites.md §Downgrade resistance and suites.md §Peer capability advertisement.
Reference consumers (current apps)
referenceConsumers.length = 3 — enclaves/dm.md → ratchet-pair, enclaves/group.md → mls-lazy, enclaves/personal.md → identity-aead + ecdh-envelope.
| App | Plugin(s) | Covers |
|---|---|---|
enclaves/dm.md | ratchet-pair | invite, message, sent, rotate, Move(O→F) epoch payload |
enclaves/group.md | mls-lazy | message, reaction, notice, rotate, admin membership Move epoch payload |
enclaves/personal.md | identity-aead + ecdh-envelope | private (owner-only) + notice (cross-enclave inbound) |
Reference consumers exercise the wire-and-key contract, but the plugins themselves are app-agnostic — naming them after the first consumer was a historical accident, corrected by this catalog.
What each plugin spec contains
Every plugin spec follows the same template so reviewers and reimplementers know exactly what to expect — requiredSections.length = 10:
- Role & status — what cryptographic shape the plugin provides, its reference consumer(s), and its production-readiness.
- Primitives (§1) — fixed concrete choices: AEAD, KDF, ECDH, CSPRNG, encoding (
primitiveCategories.length = 5). No negotiation. - Domain separators (§2) — the exhaustive ASCII list. Anything not on this list is forbidden.
- Key schedule — the full HKDF/ECDH chain producing each key used, with exact
infostrings and lengths. - Wire format — JSON shape on the wire for every event content the plugin touches, with byte-level encoding (hex/base64).
- State-machine concerns — monotonicity, rotation, recovery, multi-key / OR-wrap fallback where applicable (
stateMachineConcerns.length = 4). - Error & rejection rules — what makes a wire value invalid; normative MUST/MAY/SHOULD.
- Security properties — MUST include an explicit YES/NO/OPTIONAL table for FS, PCS, multi-device, etc.
- Compliance vectors — required KAT (known-answer test) categories that an implementation MUST publish.
- Versioning — what triggers a
-v2and what migration looks like.
How an app spec references a plugin
App specs (app/*.md) MUST satisfy three normative requirements (appSpecRequirements.length = 3):
- Name the plugin(s) they use for each event type that carries encrypted content or tags. The catalog above is the picker — choose by cryptographic shape, not by which app first used the plugin.
- Declare the app-side payload contract — which event-content / tag fields the plugin's encryption applies to (e.g. "
message.contentis aratchet-pairmessage envelope;Move(O→F).content.epochis aratchet-pairepoch wrap"). - State the security guarantees the app relies on — minimum properties (multi-device, FS-on-removal, sub-key support, etc.) that the chosen plugin MUST provide. If the app's threat model needs a property the plugin lists as
NO, the app must say so explicitly.
App specs MUST NOT duplicate the key schedule or wire format — those live here. A drift between an app spec and a plugin spec is a spec bug; the plugin spec wins.
Versioning across the catalog
All plugin specs in this directory are version 1 (CATALOG_VERSION = 1). The set of v2 triggers has v2Triggers.length = 7: anything that changes a domain separator, the AEAD primitive / nonce length, the KDF, member-sort order, tree topology, or a wire field's encoding requires a new plugin file (<name>-v2.md) and a new manifest negotiation hook. Versions are negotiated by manifest, never by tag-sniffing (versionNegotiation = .byManifest).
A v1 plugin's wire-level domain-separator prefixes are fixed (v1DomainPrefixes.length = 5: e.g. enc:dm:*, enc:group:*, enc:mls:*, enc:personal:*, enc-personal-private:). A v2 of any plugin MAY adopt a generic prefix (e.g. enc:ratchet-pair:*, given by v2GenericPrefixFor).
Catalog Implementation Names
referenceImpls.length = 4 — one row per catalog plugin: ratchet-pair, mls-lazy, identity-aead, ecdh-envelope.
The reference impls still use their historical slot / package names (historicalImplNames.length = 5: DMCryptoFn, GroupCryptoFn, plugin-group-mls-lazy, PersonalPrivateCryptoFn, PersonalNoticeCryptoFn). The plugin spec name is canonical going forward; implementations MAY add aliases for backward compatibility but new code SHOULD prefer the catalog name.