Skip to content
ENC Protocol

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.


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).

PluginCryptographic shapeOne-line use case
ratchet-pairPairwise: 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-lazyShared-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-aeadSingle-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-envelopeOne-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}).

Capabilityratchet-pairmls-lazyidentity-aeadecdh-envelope
Confidentiality vs. node
Per-message key isolationnonce-onlynonce-only
Per-sender / per-counterparty isolationn/a (single)
Forward secrecy across epochsNONENONE
Forward secrecy on removaln/an/an/a
Backward secrecy on addn/an/a
Multi-device via identity_priv
Sub-key wallet support (OR-wrap)n/a✅ (single recipient)
Stateless decryption
Post-compromise security vs. identity_privNONONONO

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 pluginRoleSuite
ratchet-pairpairwiseenc-xchacha-v1
mls-lazysharedSecret (N-party)mls-chacha-v1
identity-aeadsingleOwnerenc-xchacha-v1
ecdh-envelopeoneShotDirectedenc-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 = 3enclaves/dm.mdratchet-pair, enclaves/group.mdmls-lazy, enclaves/personal.mdidentity-aead + ecdh-envelope.

AppPlugin(s)Covers
enclaves/dm.mdratchet-pairinvite, message, sent, rotate, Move(O→F) epoch payload
enclaves/group.mdmls-lazymessage, reaction, notice, rotate, admin membership Move epoch payload
enclaves/personal.mdidentity-aead + ecdh-envelopeprivate (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:

  1. Role & status — what cryptographic shape the plugin provides, its reference consumer(s), and its production-readiness.
  2. Primitives (§1) — fixed concrete choices: AEAD, KDF, ECDH, CSPRNG, encoding (primitiveCategories.length = 5). No negotiation.
  3. Domain separators (§2) — the exhaustive ASCII list. Anything not on this list is forbidden.
  4. Key schedule — the full HKDF/ECDH chain producing each key used, with exact info strings and lengths.
  5. Wire format — JSON shape on the wire for every event content the plugin touches, with byte-level encoding (hex/base64).
  6. State-machine concerns — monotonicity, rotation, recovery, multi-key / OR-wrap fallback where applicable (stateMachineConcerns.length = 4).
  7. Error & rejection rules — what makes a wire value invalid; normative MUST/MAY/SHOULD.
  8. Security properties — MUST include an explicit YES/NO/OPTIONAL table for FS, PCS, multi-device, etc.
  9. Compliance vectors — required KAT (known-answer test) categories that an implementation MUST publish.
  10. Versioning — what triggers a -v2 and what migration looks like.

How an app spec references a plugin

App specs (app/*.md) MUST satisfy three normative requirements (appSpecRequirements.length = 3):

  1. 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.
  2. Declare the app-side payload contract — which event-content / tag fields the plugin's encryption applies to (e.g. "message.content is a ratchet-pair message envelope; Move(O→F).content.epoch is a ratchet-pair epoch wrap").
  3. 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.