# ENC Protocol > A formally verified, append-only protocol for sovereign, independently-verifiable apps. ## ENC — Litepaper ### Abstract The apps you use were built for a world where **humans wrote the code, humans reviewed it, humans ran the servers, and humans clicked the buttons.** That world is ending. Software is increasingly **written by AI** and **operated by autonomous agents** acting at machine speed — and every assumption the old app stack rested on (*trust the operator, someone reviewed the code, your data lives in their platform*) breaks at once. **ENC** — *encode, encrypt, enclave* — is the substrate for what comes next: a **trust-minimized, self-sovereign** protocol where code is **verified, not trusted**, data **carries its own proof**, and apps are **generated** to be owned by their users and usable by agents. The verification doesn't stop at the protocol. The **infrastructure and the apps are formally verified too**, because they're all *generated* from the same proven mathematical core — their guarantees hold by *mathematics*, not by trusting whoever (or *whatever*) wrote the code. So one specification, reviewed once, expands — **instantly, across \~10 platforms** — into a running app whose security is carried by proof, not re-audited by hand. And it's not one app. The same machine that generates a verified messenger generates a verified marketplace, wallet, social feed, or registry — **AppGen redefines existing apps as agent-native, trust-minimized, sovereign ones.** A sovereign messenger is simply the **first instance** — not the product. In one line: **you can forge a screenshot; you can't forge verified data** — and ENC turns every app's money, messages, agreements, and agent actions into verified data. *** ### I. The paradigm is breaking Every app today runs on infrastructure you have to **trust**. The operator can silently rewrite your data, censor you, lock you out, or vanish — and you can't check. Even "audited" or "decentralized" systems still ask you to trust that the **code actually running matches what was reviewed**, and that the operator behaves. That bargain held while four things were true: code was **human-written**, it was **human-reviewed**, apps were **operated by accountable people**, and they were **used by humans** who would notice when something looked wrong. All four are now false: * **Code is AI-written** — "vibe-coded" faster than anyone can read, let alone audit. No reviewer — not the best engineer, not the best mathematician — keeps pace with what machines now emit; human review, the old basis of trust, simply doesn't scale to machine output. You can't validate what no one fully read; "we reviewed it" becomes a statement about a fraction of the code, at a point in time. * **Apps are agent-operated** — autonomous agents act on your behalf and transact with *other* agents, at machine speed, across parties that have never met. There is no human in the loop to apply judgment. * **Trust doesn't compose to machines** — reputation, terms of service, and a careful read are human-scale instruments. They don't survive millions of unattended interactions. And the human method for *building* trustworthy systems — theorize a clean spec, then hand-write the code and *hope* it matches — can't keep pace with machine-scale output either; it's the wrong instrument for the job. * **Claims have to leave the room** — a screenshot proves nothing to anyone who wasn't there. The instant your data must be trusted by *someone else's* system, "trust me" fails. When neither the operator nor the code can be vouched for by a human — because no human can keep up — the only trust that scales is **mathematical proof.** Trustworthy infrastructure *for* agents can therefore only be built *by* agents whose output is checked not by a reviewer but by **math.** That is the forcing function behind everything in this paper. *** ### II. The new paradigm: trust-minimized, self-sovereign, verifiable ENC replaces the old bargain with three properties that stack: * **Trust-minimized** *(the system property)* — you have to trust as little as possible. Not "trustless" (there's always *some* trusted base) — **minimized** to a small, explicit, *measurable* surface, with everything above it backed by proof. * **Self-sovereign** *(the outcome you feel)* — your identity, data, and money are **yours**: encrypted, portable, no phone number, no central account, no operator that can censor or revoke you. * **Verifiable** *(the mechanism)* — every piece of data and every line of running code can be **checked**, not believed: authentic, ordered, unaltered, authorized — by you, a counterparty, another app, or an agent, *without trusting the source.* Three stacked properties: a trust-minimized foundation (a small, explicit, measured trusted surface) supports verifiability (every datum and every running line is checked, not believed), which in turn delivers self-sovereignty (your identity, data, and money are yours, and uncensorable). Each layer rests on the one below. One sentence: **ENC is a *trust-minimized* protocol that delivers *self-sovereignty* through *verifiability and formal proof.*** *Own it — and prove it.* Each primitive is the **forced replacement** for an assumption the agent era broke — skip any one and the stack falls back to the failure it was built to avoid: | The agent era broke… | ENC forces… | Where | | ------------------------------------- | ------------------------------------------------------- | ----- | | reviewed, human-written code | a spec that **generates** its verified implementation | §VI | | accountable human operators | a **self-sovereign mesh** — host your own node | §III | | trust that composes (reputation, ToS) | **verifiable, composable** data — trust travels with it | §VIII | | a human in the loop | **bounded authority** — permissions are theorems | §IX | | apps hand-built one by one | apps **generated** from one verified spec | §VII | *** ### III. The protocol At its core ENC is small and sharp — an **event-sourcing store you own**: signed writes (events), ordered into append-only logs, committed to structures anyone can verify, governed by explicit permissions. A client holding its own key signs commits to a single-sequencer node that only orders writes and never signs for you; the node appends events to an append-only enclave whose state (Sparse Merkle Tree) and history (Transparency log) are bound and signed as a Signed Tree Head, which anyone — or the ZK node — verifies offline. #### Declarative, not computational An ENC node runs *no app code.* State is **non-computing** — key→value leaves in a **Sparse Merkle Tree** — and a single **manifest** *declares* everything an app is: its states, traits, operations, readers, and lifecycle. The node doesn't *execute* an app; it **enforces a declaration** — verify the signature, check the manifest, append, update the tree. That is exactly what keeps the whole system *verifiable* (there's no arbitrary computation to reason about) and *fast* (the work per event is small and bounded). #### Virtual enclaves The unit of an ENC app is an **enclave** — a **self-sovereign realm** for your data. The name is an analogy to a hardware **TEE** (a protected enclave like SGX), but with the trust inverted: an ENC enclave is protected by **cryptography and math, not hardware.** Concretely it's a cryptographically-defined, append-only log that is *yours*: writes are authorized by your keys, and the data is portable. Reference templates express every app shape: **Personal** (profile / identity anchor), **DM** (a private mailbox), **Group** (roles, moderation, group encryption), **Registry** (discovery). An enclave is **platform-blind** (the host only sees ciphertext), **portable** (it *is* its keys plus signed history), **composable** (enclaves reference each other by key and coordinate via signed commitments any third party can verify), and **agent-compatible** (an agent's enclave is indistinguishable from a person's). **Discoverability is opt-in:** list an enclave in a **Registry** to be found, or keep it entirely private — the choice is yours, not the platform's. #### Identity & lifecycle An identity is a secp256k1 key with **Schnorr (BIP-340)** signatures, held by you — passkey, extension, or hardware, **no phone number** — never the server. The node is a **single sequencer** that only orders writes. Sequence diagram: the Client sends a Schnorr-signed Commit to the Node; the Node orders and appends an Event to the Enclave log; the Node binds the state and history roots and signs the Signed Tree Head with a host-held key; the Node returns a co-signed Receipt. The node never holds your key and the kernel never sees the signing key. Because the node only orders writes — it can't author them or sign for you — its power is sharply bounded. #### Authorization (RBAC) Who may do what in an enclave is a small, *decidable* permission model baked into the verified kernel — **three columns and six verbs**, packed into a single **bitmask** so every check is one cheap, *provably-correct* computation, never a tangle of `if` statements. *Three columns — who you are:* * **State** *(where you are)* — UPPER-CASE, mutually exclusive: `PENDING`, `MEMBER`, `BLOCKED`. * **trait** *(what modifies you)* — lower-case, stackable flags, each carrying a **rank**: `owner`, `admin`, `muted`. * **Context** *(who you are to this object)* — `Self`, `Sender`, or `Public`, evaluated at check time. *Six operations — what you may do:* **Create, Read, Update, Delete, Push, Notify** — permitted or refused as a function of the columns, with deny marked by a leading `_`, and **deny overriding allow**. **Authority changes** are themselves signed events — `Move` (state, e.g. `PENDING → MEMBER`), `Grant` / `Revoke` / `Transfer` (traits), `Gate` (open or close a capability), `Shared` / `Own` (key-value state) — each requiring explicit permission, with a **proven rank rule** that stops anyone acting on someone ranked above them (no quietly promoting yourself to `owner`). Concretely: an `admin` can admit a `PENDING` member or mute a `MEMBER`, but **cannot** grant `owner` or act on anyone ranked at or above itself — the kernel simply refuses, because the rank rule is a *theorem*, not a hopeful runtime check. So a moderated group's whole policy — who can post, admit, ban, or mute — is a tiny declared table the compiled code is *structurally incapable* of violating. **Permissions aren't config; they're theorems** — **impeccable math for authorization**, where escalation isn't merely *forbidden*, it's *unrepresentable*. Each enclave becomes a **sovereign jurisdiction**: its own small constitution, governing humans and their agents under one law the code *cannot* break — exactly what makes bounded delegation to an agent (§IX) safe. The live per-enclave permission matrix for a group chat. Columns are grouped by type — State (MEMBER, BLOCKED), ranked traits (owner, admin, muted), and Context (Sender, Self). Rows are events: content events (message, reaction) above the line, authority changes (Move, Grant, Revoke, Transfer) below. Each cell shows the allowed operations — Create, Read, Update, Delete — color-coded, with denied operations struck through in red, since deny overrides allow. Every cell is a theorem; rank_prevents_escalation forbids acting on a higher rank, so escalation is unrepresentable. #### Verifiable state & history Current state commits to a **Sparse Merkle Tree** (one root); full history to an append-only **Certificate-Transparency-style log**; the two are bound and signed as a **Signed Tree Head**. Anyone verifies inclusion, append-only consistency, and any state fact **offline, without trusting the node** — history is **tamper-evident.** Optional **validity proofs (zkEnc)** prove state was reached *only by authorized transitions*, so a node can't write `Mallory = admin` and have a membership proof still pass. And privacy costs nothing here: an enclave's contents can stay **encrypted** while its *state* remains **zk-provable** — you can prove what's true about it without revealing what's in it. A membership/transparency proof proves WHAT the state is but not THAT it followed the rules; a zkEnc validity proof proves the state was reached only by authorized RBAC transitions, and folding turns an enclave's whole history into one O(1)-size proof. #### Event sourcing, and dataviews Underneath, ENC is an **event-sourcing store.** Every change is an immutable, signed **event** appended to the log; current state (the SMT) is just a *fold* over those events. Nothing is overwritten — the history *is* the database. That log is the foundation all app-side logic builds on, and there are two ways to read it. A **private** app — a messenger, a DM, encrypted email — needs nothing else: the client holds the keys and **queries the node directly** for its own enclave's events, no index in between. Only when you need **public, indexable** data — a public timeline, a search index, an aggregate feed — does an **(optional) dataview** earn its place: a service that **projects** events into a queryable read-model and keeps it live as new events arrive. Delivery is part of the permission model: **Push (`P`)** streams full events to a dataview or service endpoint; **Notify (`N`)** sends a lightweight ping to a human client. A **Registry**, for instance, is just an enclave whose dataview indexes listings and serves them as a discovery **API**. So it is command-and-query, signed end to end: write a signed event, then read it back — by **querying the node directly** for private data, or **through a dataview** for public, indexable data. On that one foundation you can build **anything** — a private messenger, a public timeline, productivity apps, an end-to-end-encrypted email system, a headless agent API, or a category no one has named yet. Two read paths. At the bottom, a node hosts your enclave — an event-sourcing store of signed, append-only events (event 1 → 2 → 3 → 4 → append), where current state is a fold over the events (the Sparse Merkle Tree) and full history is the Certificate-Transparency log. On the left, private apps (a messenger, a DM, end-to-end email) query the node directly — you hold the keys, no index needed. On the right, public or indexed apps (a public timeline, search, a feed) go through an optional dataview, which the node pushes events to (Push, P) and which projects them into a live, queryable read-model. #### The mesh ENC is **not a central server and not a blockchain** — a **federated mesh** of nodes. A node's role is deliberately narrow: it **hosts** the enclaves assigned to it, **orders** their writes, and **delivers** them — pushing new events and **notifications** (the `Push` / `Notify` operations) to members and their devices, and holding data for offline sync and multi-device catch-up. It never authors writes or signs for you. Each enclave is ordered by one such node, but that sequencer is **bounded** — it can't forge, reorder, or fake state — so you get edge-latency speed and one ordered source of truth *without* a central operator or a global-consensus tax. Because that power is bounded, the network is **ultra-flexible**: a node can run anywhere — the edge, a server, your laptop, even your **phone** — and you can **be your own node**, so no third party sees even your metadata. No one can lock you in, there's no choke point to censor, and your enclave **survives any node dying** — it just moves. An always-on node still gives reliable offline delivery and multi-device sync, so you keep sovereignty *without* the peer-to-peer reliability tax. And because an enclave *is* a repository — keys plus signed history — the tooling treats it like **git for your data**: you `clone`, `push`, `pull`, `snapshot`, and `migrate` it. Moving your data to another host — or off one entirely — is a single command, and a snapshot round-trips byte-identically on any host. Portability stops being a promise and becomes `git clone`. *** ### IV. Privacy — transparent by default, private and pluggable ENC is **transparent by default** — but transparent about *the rules*, not your *content.* State and every transition are verifiable and the log is tamper-evident, so anyone you allow can confirm that something happened and followed the rules; **confidentiality then layers on top**, so the operator can *store* your data but never *read* it. The primitives are standard and conservative — secp256k1 with **Schnorr (BIP-340)** signatures, **ECDH** key agreement (Curve25519 / secp256k1), **XChaCha20-Poly1305** authenticated encryption, and **HKDF-SHA256** derivation. What's unusual is that *which* scheme protects a given piece of data is a **swappable plugin**, chosen by the shape of the conversation: * **One party** — your own private data — `identity-aead`: authenticated encryption under a key only you hold. * **Two parties** — a direct message — `dm-ratchet`: a **forward-secret double ratchet**, so a compromised key doesn't expose past messages. * **A group** — `group-mls-lazy`: a **lazy** variant of **MLS** (the IETF group-messaging standard) that scales it further — rekeying the whole group in *O(log N)* on a membership change while deferring the work *lazily* onto the enclave's real, verifiable history, so large groups stay cheap. #### Crypto as swappable plugins Each is a real package filling a typed **slot** in the model — `identity-aead`, `dm-ratchet`, `ecdh-envelope`, `group-mls-lazy`, and more — so you can **swap or add** schemes (post-quantum, a different group ratchet, a sealed-sender mode) *without touching the verified core.* And because every plugin carries its **own machine-checked security claim**, swapping never means "trust whoever wrote it": the guarantee travels with the plugin, and the set you've chosen *is* your explicit privacy surface. Encrypted reads require an ECDH session, so confidentiality is built in, not bolted on. *Privacy isn't a feature you're handed — it's a choice you own, and can upgrade.* #### The subkey system — keys for agents and devices You never hand an agent — or a new device — your root key. ENC mints **subkeys**: child keypairs derived from your identity with **HKDF** under a domain separator, so a subkey is provably *yours* yet leaks nothing about the root — and the root private key never leaves your control. Each subkey is issued with a signed **capability grant** that is *scoped, expiring, and revocable* — it names exactly what the holder may do and for how long. A co-signed certificate binds parent → subkey, and a *tweaked* variant (`S = P + t·G`) is even publicly verifiable without one. Revoke a subkey and it's dead — so a compromised agent or a lost device never compromises *you*. That is how a **remote** agent in the cloud or a **local** agent on your device can act *as you* within a strict, revocable scope (§IX) — a key that is **provably yours and provably bounded**, enforced by math, not a dashboard toggle. *** ### V. One instance: a messenger ENC is the substrate; **a messenger is just one app on it** — taken here as an example because messaging is the hardest, most-contested consumer surface. Even there, the new paradigm shows: an ENC messenger owns your identity (no phone), your data (verifiable, portable), and your money — on a mesh no one controls — and every message can be a *typed, verifiable object* that other apps and agents compose. Comparison matrix of an ENC messenger vs Signal, WhatsApp, Telegram, X DM, and Keet. Encryption is a tied row near the top; then a cluster — no phone, own your data, tamper-evident, own your account, censorship-resistant, formally verified — where only the ENC app is green; reliable delivery is green for all but Keet; and the ENC app leads on agent-native and own-your-money. How to read it: the table scores **default, user-visible** guarantees — optional modes, whether a system *retains or discards* its logs, and network reach are separate dimensions, called out where they differ. The honest read: **encryption is table stakes** (ENC, Signal, WhatsApp, Keet all have it), and ENC does **not** claim to beat Signal on metadata — Signal's privacy comes from *storing nothing*, the opposite bet from a *verifiable, retained* log. The rows where **only the ENC app is green** are the point: **self-sovereignty** and **verifiability** (formally verified — alone, even against Keet), plus **reliable delivery** where pure-P2P Keet isn't. The one row reversed against everyone is **network** — so the battle is *distribution, not capability*. And this is only **instance #1**: the same AppGen line produces the next thousand apps. *** ### VI. Formal verification — trust, replaced by proof #### The formalization gap Every other "formally verified" protocol shares one unsolved problem: the proof covers a **model**, but the code that actually ships is **hand-written** — or now **vibe-coded by an AI** — and the compiler and runtime beneath it are trusted, not proven. A *specification* (a document) sits on one side and an *implementation* on the other; they drift the moment coding starts, and that gap is where bugs and backdoors live. It is the signature of the **top-down** method — *theorize a clean spec, then hand-write code and hope it matches* — which neither closes the gap nor keeps pace with the code machines now emit. An audit only ever *samples* it — and no one can audit what no one fully read. **This is the gap ENC closes.** Two columns. A conventional 'verified' protocol: a spec or model is proven on paper, but then humans or an AI hand-write the code that ships and the compiler and runtime are trusted — a broken link — so the deployed bytes are not what was proven. ENC with SpecGen and CodeGen: a Lean spec is proven with zero sorry across 2,851 theorems; CodeGen deterministically emits Lean, JavaScript for Cloudflare, Rust, and WebAssembly; a byte gate requires regenerating to the same sha-256; and witnesses run tests against the exact bytes — an unbroken chain, so what runs is what was proven. #### Closing the gap by construction ENC closes it by construction — and inverts the method. It formalizes the protocol *from the behavior of real systems* and drives that formalization as far up the proof as the math allows — **bottom-up**, not top-down, an asymptote it approaches rather than a perfection it claims. The implementation is *generated from the proven spec* by a verified **CodeGen**: from one Lean 4 artifact — a small, closed, total calculus, **EncDSL** — four generators emit **Lean**, **JavaScript** (the SDKs and the Cloudflare host), **Rust**, and a **Rust→WebAssembly** kernel. No human hand-writes the protocol code and no AI vibe-codes it — *the code is the spec, compiled.* Because the generators are **deterministic**, a **reproducibility gate** regenerates every artifact and requires the bytes to match **hash-for-hash** (sha-256) or the build fails; key **refinement properties** are proven at the host boundary. So the deployed bytes provably *are* the generated ones — there is no second codebase to drift. The same gate lets the protocol *evolve* safely: every new version is re-proven before it ships, never merely re-trusted. #### The spec runs — its own reference Here is the part **no other "verified" project has**: the Lean spec isn't only a model on paper — *it runs.* Everyone else keeps a paper spec **and** a separate reference implementation, two artifacts that drift — the original formalization gap. ENC's spec **compiles to an executable reference** (the Lean `enc-oracle`), which decides every commit with the *exact* state machine the theorems are proved against. So *validating the spec is validating the reference;* there is **no gap between them to close.** Anyone can run that same oracle as an independent **referee** to settle whether an event was valid, or who deviated in a dispute. #### The proofs are the audit About **2,851 machine-checked theorems, zero `sorry`** — a security review performed by *mathematics*, exhaustive over every input, that never goes stale because the code is generated from the very thing it proves. No human vouches for these bytes; the proof does, mechanically and in full. The remaining links are closed concretely. The **reproducibility gate** regenerates every artifact and fails the build unless the bytes match hash-for-hash. **Generated tests (TestGen)** replay the *same* workflow corpus across **every platform** through pluggable adapters, and a *matrix theorem* makes passing the two axes imply the whole grid. And **witnesses** run those tests against the *exact published bytes* and record their hashes. So spec, proof, code, tests, and running bytes become one continuous, checkable chain — not a document and a hope. One Lean specification is mechanically expanded into byte-identical JavaScript SDKs, a Rust-to-WebAssembly kernel, and the Cloudflare host — while the Lean spec itself doubles as the executable reference node, so there is no spec-to-implementation gap; a reproducibility gate fails the build on any drift; and witnesses run the generated tests against the exact published bytes. #### SpecGen — created and audited ENC isn't hand-built and hoped-correct; it's produced and graded by **SpecGen**, a protocol-agnostic framework that drives every claim along one pipeline: human-readable **prose** → a reviewed **formal meaning** → a **Lean theorem** → **generated code** → a **witness** that ran the real bytes → a single **signed report**. ENC is just *one instance* of the **SpecGen / CodeGen / AppGen / TestGen** meta-protocols (which carry no protocol of their own) — generators that turn any domain into a fully-verified system. Aim them widely and you get a **formally-verified, autonomously-evolving universe** of software; ENC is simply its first citizen. So the "audit" isn't a PDF from a firm that sampled the code once — it's a machine-checked report that re-derives the entire chain and grades each claim. And trust-minimization here is **measured**, not asserted: that report places every claim on a public **trust lattice**, from **T0** ("trust the operator — nothing checked") up to **T6** (a ratified, version-pinned release) — with **T5, *proved & conformant***, the tier where the generated code provably matches the proof and the witnesses agree. Almost no system can put a *number* on how much trust it removes; ENC can. A trust lattice grading each claim from T0 (unreviewed — trust the operator, nothing checked) up through T1 a prose claim, T2 a human-reviewed formal meaning, T3 type-checks in Lean, T4 proved with zero sorry, T5 proved and conformant (the generated code matches the proof and witnesses agree — where ENC operates), to T6 a ratified, version-pinned release. Higher means a smaller trusted surface. #### Verification all the way up And none of this is confined to the protocol kernel. Because the **infrastructure and the apps are generated from the same proven core**, the verification flows all the way up: you review a small declarative spec *once*, and the math guarantees the expansion into a running, multi-platform app. That is the elegance — a tiny calculus that does the expanding for you — and it's what makes "formally verified" affordable for *ordinary apps*, not just a protocol. It reaches the **infrastructure and its scaling**, too — which is why ENC apps reach **Telegram scale** on a kernel small enough to *verify.* The proven kernel ships as a compact **WebAssembly** module on **Cloudflare**'s edge: one Durable Object sequences one enclave, globally distributed and auto-scaling, and because the protocol is **declarative and non-computing**, the work per event is tiny and bounded — verify, check the manifest, append, update the tree, nothing arbitrary to run. The node, the policy layer, and the host contract are all specified in Lean — even the server-side **hook system** is proven **orthogonal** (`replay_invariant_under_hook_swap`: swapping policy hooks can't change replayed state). And **how it scales is *proven*, not just benchmarked**: **sharding** provably preserves the full history (the shards' union *is* the log), rebalancing loses nothing, shard assignment is bounded, and a connection-**aggregating hub** collapses socket blow-up from *O(clients × enclaves)* to *O(clients) + O(enclaves)* — relaying frames **verbatim** as a metadata-only router that never sees content or becomes an auth point. A *Telegram-scale* workload is mechanically modelled to pass; raw throughput is *measured*, but the **correctness of scaling** is *proved* — so the system grows without any guarantee quietly breaking. **What you still trust** is small and named: the platforms ENC runs on (the WebAssembly runtime, the browser, the Cloudflare edge), a few standard cryptographic primitives taken as axioms (Schnorr, SHA-256), and the integrity of code distribution. Everything above that line is backed by proofs you can check — §X measures exactly how much. *** ### VII. AppGen — redefining every app for agents A verified core isn't safe on its own. The **app** is where your **identity** lives and the surface your **agent** acts through — so an unverified app is a trusted black box sitting between you and the proof, free to misuse the very keys and permissions the protocol so carefully bounds. Verifying only the node leaves the most dangerous layer unchecked: the app's behavior has to be verified *too*. That's why, on ENC, **apps are generated** — emitted from the same proven core, so the parts that can hurt you (how it wields your identity, keys, and authorization) are verified *by construction*, not hand-written and hoped-safe. #### Apps, generated not hand-built An app is mostly declarative JSON — data types, mapping, actions, a UI tree — compiled by the **Flow** engine. Flow even **resolves the infrastructure for you**: it matches each piece of state to an enclave that can host it by its *access model* (who must read it back, how many parties, public or private), so you never hand-wire where data lives. The **interface isn't exempt from the math** either. The **ui-kit** builds views from a formally-specified **atom algebra** — a closed set of UI atoms with composition laws — and its rendering implementation is proven to **refine** that algebra. From that one definition ENC generates the **typed SDK**, the **tests**, native clients for **\~10 platforms** (built instantly by platform adaptors, not rewritten), and **agent skills** — so an agent drives the app through the same verified surface a human's SDK uses. #### From a sentence of intent **AppGen** goes the last mile: it turns an **intent in natural language** into a validated specification and then a generated, verified app — correctness proved once and reused across many. The generated apps are **formally verified too** — not because each is hand-proved, but because the pipeline is proven and the app is generated from a tiny reviewed spec: you review the *sentence* — a human stays on the intent, which is tiny and human-scale — and the math verifies the *implementation*, which no longer is; cross-platform consistency is a **theorem**, so the same app behaves identically on all \~10 targets. **Instant, ten platforms, verified — because the math is small and elegant enough to do the expanding for you.** One reviewed spec, derived from natural-language intent, is mechanically expanded by the verified pipeline into an instant, formally-verified app across ~10 platforms (web, iOS, Android, desktop, CLI, TUI, and more) plus a typed SDK, tests, and agent skills — with cross-platform consistency as a theorem. #### Redefining the app landscape This is the paradigm's reach: the same machine that produces a verified, sovereign, agent-native **messenger** produces a **marketplace, a wallet, a social feed, a registry, a payments app, an identity app** — each trust-minimized, owned by its users, and usable by agents, *by construction.* The ambition is not "a few apps on a new protocol." It is to **redefine the existing app landscape for the agent era** — every category re-expressed as a verified, composable, sovereign ENC app, generated rather than re-audited one by one. And it's already underway, not hypothetical: **\~1,500 existing apps** — from health systems to social feeds and payments — have already been **redefined as formally-specified ENC apps** — a corpus **empirically built by agents** that AppGen draws on to turn a **sentence of intent** into the next app **instantly, with formally-verified security, across all \~10 platforms**. #### Plugins & custom apps Plugins make this extensible without bloating the proven core: the kernel encodes only what *all* apps share — the state machine and RBAC, generated and type-checked — and **everything outside it is a composable plugin** filling a **typed slot**. Read-side projections (ranker, indexer, aggregator, filter), write-side gates (spam, moderation, fraud, validation), and the crypto suite (sign, verify, key-exchange, ratchet, group) are all slots; today **\~41 plugin packages** fill the kernel-defined ones. Each slot's *type* is its contract and its assumptions join the verification surface — so you build **custom apps** the same generated way — spin up new enclaves and infra, compose existing enclaves, or extend the verified SDKs, plugins, and app UIs — and they *inherit* the proofs rather than re-earning trust. ENC provides the **whole toolchain** for exactly this. *** ### VIII. Verifiable data → composability Verifiability matters far beyond "was this message tampered with." A piece of **non-verifiable** data is *trapped in the conversation*: a message, a screenshot, a P2P chat record only means something to the people in the room, in the moment. Hand it to a third party, another app, or an agent and it's a forgeable claim. **Verifiable data is portable trust** — it can *leave* the room and be checked by anything, *without re-trusting the source.* It stops being a dead end and becomes a **building block.** That is the unlock: **verifiability is the precondition for composability.** An ENC object is a **typed, verifiable thing** — a **payment receipt**, an **API call and result**, a **vote**, a signed **agreement**, an **attestation** — that can be handed to an escrow, court, or tax tool and verified; consumed by another app as a *trusted input*; or **chained**, each step verifying the last *without trusting it.* A non-verifiable screenshot or plain message is forgeable and trapped in the room — a dead end. A verified object (signed, ordered, authorized — and private via zero-knowledge) fans out to an escrow or court, another app, a counterparty's agent, and you after a reinstall, each of which verifies it without trusting the source. A clean test for where verifiability earns its place: **anything the data must be trusted by someone *not in the room*** — money, agreements, cross-app inputs, an agent, or future-you after a reinstall. (Two people just chatting don't need it.) One honest boundary: verifiability kills **forgery, tampering, reordering, and deniability** — it does *not* make content *true.* A verified object proves *"X really committed to this, unaltered, in order, and was allowed to"* — which, for money and agents, is exactly what you needed: *can I hold them to it, and can anyone check?* #### Verifiable, not exposed And verifiable does **not** mean *exposed.* With **zero-knowledge proofs**, data stays **private** while remaining verifiable — you can prove a balance is sufficient, a member is authorized, or a rule was followed *without revealing the content itself.* Privacy and verifiability aren't a trade-off; ZK gives you both. So ENC isn't a verifiable messenger — it's a **composable, verifiable data fabric.** *** ### IX. Built for agents An agent has no human judgment to fall back on — so the trust a human used to supply (reputation, a gut-check, support) must become something the machine can **compute.** The only computable trust is **verification.** Walk one transaction — *your agent buys a service from a stranger's agent and pays* — and every ENC primitive is load-bearing: * **Authenticity** → the quote is signed; your agent can't be fed a forged or injected one. * **Bounded authority** → your agent acts under a **derived sub-key** carrying a *scoped, revocable* grant — "up to $50, this category" — and the kernel structurally prevents more, *even if your agent is buggy or hijacked.* Compromising the agent never exposes your root identity. That's what makes delegating to an AI you don't fully trust survivable — the leash is math, not hope. * **Verifiable receipts** → the payment emits a proof the seller's agent *checks* before releasing. Agent-to-agent commerce is impossible without it. * **Ordered tamper-evidence** → "deposit *before* release" is reliable. * **Mechanical disputes** → a third party verifies the signed, ordered log and sees who deviated. * **Composability** → the receipt feeds an accounting agent; the next agent verifies the last one's output without trusting it. A sequence between Your agent and the Seller's agent: a signed quote (authentic, can't be spoofed); a payment bounded to a proven, scoped capability the kernel enforces; a verifiable receipt the seller checks before releasing; a delivery proof; and a dispute resolved by any third party verifying the signed, ordered log. At every step, **trust travels with the data**, because two agents that share no central authority can't trust each other any other way. That's the gap between agents calling APIs *inside one vendor's walls* (siloed) and an **open agent economy where agents from different parties transact** — and that wave is cresting now (agent-payment rails are hitting exactly this wall and faking it with cards and API keys inside silos). #### Agent apps belong on ENC So the framing flips: it isn't "agent apps *need* verifiability" — it's that **agent apps should be *built on* ENC.** On ENC an agent gets, *for free*, the five things an autonomous actor actually needs: * **Identity** — a sovereign, portable agent identity (a derived sub-key), with no central account to revoke or impersonate. * **Memory** — durable, portable, *verifiable* memory: its enclave is an append-only log it owns and can carry anywhere, not state trapped in one vendor's database. * **Privacy** — pluggable, formally-specified encryption over everything it holds. * **Composability** — typed, verifiable data that other agents and apps consume as trusted input, so work chains across parties. * **Verifiability** — every action is checkable, so trust travels *with the data*, not with a relationship. Add RBAC-**bounded** delegation and a **wallet** on top, and **if an agent can take any responsibility, ENC is the substrate it should stand on.** "Agents are just regular users" is only *safe* because it's verifiable and bounded. *** ### X. What you have to trust — minimized, and measured The aim isn't zero trust — it's a trusted surface that is **small, explicit, and measured**: the soundness of the host platforms ENC runs on (the WebAssembly runtime, the browser, the edge), a few well-studied cryptographic primitives, and the integrity of code distribution. Everything above that line — that the running code matches the proven spec, that history is intact, that state is valid, that your agent stayed in bounds — is backed by **proofs you, or your agent, can check.** The trust lattice puts a number on it. The bet is simple, and it's a paradigm bet: software you depend on shouldn't require you to trust the people who run it — or the AI that wrote it. And it cuts deeper than the operator: the same force that broke human review (§I) forces the real paradox of the agent era — *you can't trust an app an agent hand-built either*, vibe-coded past anything a human could vouch for. ENC resolves it **one level up**. Agents don't hand-build the apps you depend on; agents build the verified **system that *generates* them** — **SpecGen → CodeGen → AppGen**, a toolchain architected on these principles so that every output carries the math stamped into it. Trust never rests on the builder, human or machine; it rests on the proof the builder was forced to satisfy. **Don't trust the builder — trust the proof it had to satisfy.** A four-stage vertical pipeline, built by agents for agents. SpecGen creates and audits the spec — claims become Lean proofs, witnessed, and a signed report graded on the trust lattice. CodeGen generates the system — one Lean spec compiled byte-identically to Lean, JavaScript for Cloudflare, Rust, and WebAssembly, reproducibility-gated. AppGen generates the apps — ~10 platforms plus typed SDK and agent skills, from one reviewed sentence of intent. TestGen verifies everywhere — the same workflow replayed across every platform via adapters, with a matrix theorem and witnesses that sign the bytes. Trust rests on the proof, not the builder. And that proof is **empirical, not theorized** — formalized upward from how real systems actually behave (§VI, §VII), not dreamed top-down and hoped into code. Replacing human audit with machine-checked proof doesn't just make iteration *safe* — it makes it *fast*: a proof checks in an instant where a human review would stall, so each new version is regenerated and **re-proven** *before it ships*, reproducibility-gated, advancing toward that ceiling without anyone re-vouching by hand — safe *because* verified, not because someone signed off. The human still owns the one thing that stays human-scale: the **intent** — you review the *sentence*, and the math owns the implementation, the audit, the code that ships. This is the whole toolchain, not just the protocol: **built by agents, for agents.** Make the spec, the proofs, the code, and the running bytes one continuous, checkable chain; let your data leave the room carrying its own proof; generate every app verified and sovereign; and **prove, not promise**, that the rules were followed. The old paradigm trusted. The new one verifies. **Own it — and prove it.** ## Tutorials Hands-on, build-something walkthroughs. Where the [Developer Guide](/guide) covers each tool and the [SDK Reference](/sdk) documents every function, a tutorial takes you **end to end** — from an empty project to a working ENC app you can run. ### Build a custom Personal app The first tutorial builds a small **custom Personal app** on the [`personal` App SDK](/sdk/apps/personal) (`@enc-protocol/personal-cli` → `PersonalSdk`). You'll: * create an identity and mint your own **Personal enclave** from a manifest; * write **public posts** and **owner-only encrypted private notes** (via the [identity-aead](/sdk/plugins/identity-aead) plugin the SDK applies for you); * read your feed, your private notes, and cross-enclave **profiles**; * subscribe to live updates and wire it into a minimal client. By the end you'll have a personal micro-app — your own public posts and a private vault — backed by a verifiable enclave, with the SDK doing the signing, RBAC, and encryption. **Prerequisites** * Node 18+ and the [SDK installed](/guide/sdk). * A backend to run against: either a local [node](/guide/node), or the zero-setup in-process [`memory`](/sdk/memory) adapter (great for following along without deploying anything). It builds directly on the foundations from the Developer Guide — if you haven't yet, skim [Building with the SDK](/guide/sdk) first for the identity → enclave → submit/query → verify loop this tutorial puts to work. ### The build, step by step 1. [Run a local node](/tutorials/personal/node) 2. [Run a dataview server](/tutorials/personal/dataview) 3. [Deploy the Personal enclave](/tutorials/personal/enclave) 4. [Write the frontend app](/tutorials/personal/frontend) 5. [Write a test](/tutorials/personal/test) 6. [Deploy to production](/tutorials/personal/deploy) [Start → Run a local node](/tutorials/personal/node) ## 2. Run a dataview server The Personal app has a **cross-enclave read** — `profiles` aggregates the latest profile across every user's enclave. That's served by a **dataview**: a small Worker that subscribes to the node, projects matching events into a queryable read-model, and answers `query`s. (Your own `public` / `private` reads come straight from the node and need no dataview.) The Personal app ships its dataview. Run it locally, pointed at the node from step 1: ```bash cd impl-cli/apps/personal/dataview # a throwaway signing key for local dev (file is gitignored) echo "DATAVIEW_PRIVATE_KEY=$(python3 -c "print('11'*32)")" > .dev.vars npx wrangler dev --port 8788 # → http://127.0.0.1:8788 ``` Its `NODE_URL` defaults to `http://localhost:8787`, so it follows your local node. Keep it running alongside the node. > A dataview is **optional** — a purely private app (a notes vault, a DM) skips it and reads the > node directly. The Personal app uses one only for its public `profiles` feed. **Next:** [Deploy the Personal enclave →](/tutorials/personal/enclave) ## 6. Deploy to production Local works — now ship the node and the dataview, and point the app at them. All three are Cloudflare Workers. **Node.** Deploy the production node (see [Run a Node → Deploy](/guide/node#deploy)): ```bash cd impl-node/cf-worker yarn wrangler deploy # → https://.workers.dev ``` **Dataview.** Set its signing secret and the production node URL, then deploy: ```bash cd impl-cli/apps/personal/dataview wrangler secret put DATAVIEW_PRIVATE_KEY # paste a real 32-byte hex key ./deploy.sh --var NODE_URL:https://.workers.dev ``` **App.** Point the helper at the deployed node — the same code you ran locally: ```js import { createPersonalSdk } from './enc-personal.mjs' const sdk = await createPersonalSdk({ nodeUrl: 'https://your-node.example.com' }) ``` Then ship the frontend to any static host. Per-enclave isolation, geo-routing, and scaling are handled by the node's Durable Object model; the dataview keeps the public `profiles` feed live. **Verify** the deploy end-to-end with the node's test harness (see [Run a Node → Verify](/guide/node#verify-a-deploy)). 🎉 You've built and shipped a custom Personal app on ENC — local node, dataview, enclave, frontend, tests, and production, all on one verifiable protocol. ## 3. Deploy the Personal enclave An enclave is minted by submitting a **signed manifest** to the node — its RBAC schema, states, and traits. The Personal app's manifest is bundled in the SDK, so you never write it by hand. In this app you don't mint it as a separate step: the `createPersonalSdk({ mode: 'cf' })` helper you'll write [next](/tutorials/personal/frontend) mints the owner's Personal (and Group) enclave on first run — it calls `NetworkAdapter.createEnclave(...)` with the bundled manifest and gets back the derived **enclave id** (deterministic from the manifest plus your key). Minting is idempotent per owner key, so running the app again reuses the same enclave. :::tip Prefer the terminal? The `enc` CLI can mint one ahead of time against your local node: ```bash npm install -g @enc-protocol/cli --registry https://npm-registry.ocrybit.workers.dev/ enc keygen # identity at ~/.enc/key.json NODE_URL=http://localhost:8787 enc personal create # mint the Personal enclave ``` ::: Either way the result is the same: a Personal enclave on your node, owned by your key, where your posts and private notes live — one Personal enclave per owner. **Next:** [Write the frontend app →](/tutorials/personal/frontend) ## 4. Write the frontend app Now the app, using the [`personal` SDK](/sdk/apps/personal). `PersonalSdk` is platform-agnostic — it takes one **adapter** per enclave it declares (Personal + Group). We use the formalized **`NetworkAdapter`** from `@enc-protocol/client` (codegen'd from the Lean spec): it does plaintext signed writes and the **ECDH session-authenticated reads** the node requires. ```bash npm config set @enc-protocol:registry https://npm-registry.ocrybit.workers.dev/ npm install @enc-protocol/personal-cli @enc-protocol/client @enc-protocol/protocol-runtime ``` ### `enc-personal.mjs` — wire the SDK to a node ```js // enc-personal.mjs import { PersonalSdk } from '@enc-protocol/personal-cli' import { flattenEnclaveManifest } from '@enc-protocol/protocol-runtime' import { createIdentity } from '@enc-protocol/client' import { NetworkAdapter } from '@enc-protocol/client/network-adapter.js' export { createIdentity } /** Build a PersonalSdk wired to a real node (mints the owner's enclaves). */ export async function createPersonalSdk({ nodeUrl, identity = createIdentity() }) { const M = PersonalSdk.MANIFESTS const adapters = {} for (const name of M.app.enclaves) { // ['Personal', 'Group'] // the bundled enclave manifest → the wire RBAC manifest for this owner const wire = flattenEnclaveManifest(M.enclaves[name]).enclaveManifest(identity.publicKeyHex) const adapter = new NetworkAdapter(nodeUrl, '', identity) await adapter.createEnclave(wire) // mint + wire the ECDH reader adapters[name] = adapter } const sdk = new PersonalSdk({ adapters, identity: { pubHex: identity.publicKeyHex } }) await sdk.init() return sdk } ``` ### `app.mjs` — the app ```js // app.mjs — NODE_URL=http://localhost:8787 node app.mjs import { createPersonalSdk } from './enc-personal.mjs' const sdk = await createPersonalSdk({ nodeUrl: process.env.NODE_URL || 'http://localhost:8787' }) await sdk.submitPublic({ draft: 'hello from my Personal app' }) await sdk.submitPrivate({ draft: 'a note only I can read' }) const posts = await sdk.queryPublic() const notes = await sdk.queryPrivate() console.log('public feed:') for (const p of posts) console.log(' •', JSON.parse(p.content).draft) console.log('private notes:') for (const n of notes) console.log(' •', JSON.parse(n.content).draft) ``` Run it (with the [node from step 1](/tutorials/personal/node) running): ```bash $ NODE_URL=http://localhost:8787 node app.mjs public feed: • hello from my Personal app private notes: • a note only I can read ``` The `NetworkAdapter` mints the enclave, signs writes, and runs the **ECDH session-authenticated reads** the node enforces — all from the formalized client SDK. Each query returns event objects whose `content` is the JSON you wrote, so `JSON.parse(p.content).draft` reads the field back. **Next:** [Write a test →](/tutorials/personal/test) ## 1. Run a local node The app talks to an ENC node. For development, run one locally on port `8787`. ```bash git clone https://github.com/enc-protocol/impl-node cd impl-node yarn install yarn wrangler dev --config test/wrangler.toml --local # → http://127.0.0.1:8787 ``` This is the **spec-shape node** the public SDK speaks to — it mints an enclave from a signed manifest commit over `POST /` and serves ECDH-authenticated reads (the same node the [test](/tutorials/personal/test) boots). Leave it running in its own terminal. Quick check it's up: ```bash curl -s http://localhost:8787/ | head -c 60 # the protocol banner ``` :::tip The WASM `cf-worker` node bootstraps differently (`init-with-priv`), so for this SDK-driven tutorial use the spec-shape Worker above. ::: **Next:** [Run a dataview server →](/tutorials/personal/dataview) ## 5. Write a test Test the app the way it runs — against a **real node**, with [vitest](https://vitest.dev). A global setup boots a node for the run; the tests mint an enclave, write, and read back over HTTP. ```bash npm install -D vitest ``` `vitest.config.mjs`: ```js import { defineConfig } from 'vitest/config' export default defineConfig({ test: { globalSetup: './test/global-node.mjs', hookTimeout: 120_000, testTimeout: 60_000 }, }) ``` `test/global-node.mjs` — boots the node (and reuses one if already running): ```js import { spawn } from 'node:child_process' import { fileURLToPath } from 'node:url' import { dirname, resolve } from 'node:path' const __dirname = dirname(fileURLToPath(import.meta.url)) const NODE_DIR = process.env.NODE_DIR || resolve(__dirname, '../../impl-node') const PORT = Number(process.env.NODE_PORT || 8787) const BASE = `http://localhost:${PORT}/` export default async function () { try { if ((await fetch(BASE)).ok) return () => {} } catch {} // reuse a running node const node = spawn( 'npx', ['wrangler', 'dev', '--config', 'test/wrangler.toml', '--local', '--port', String(PORT)], { cwd: NODE_DIR, stdio: 'ignore', detached: true }, ) for (let i = 0; i < 90; i++) { await new Promise((r) => setTimeout(r, 1000)) try { if ((await fetch(BASE)).ok) return async () => { try { process.kill(-node.pid) } catch {} } } catch {} } throw new Error(`ENC node did not start at ${BASE}`) } ``` `personal.test.mjs`: ```js import { test, expect } from 'vitest' import { createPersonalSdk } from './enc-personal.mjs' const nodeUrl = 'http://localhost:8787' test('public post round-trips on a real node', async () => { const sdk = await createPersonalSdk({ nodeUrl }) await sdk.submitPublic({ draft: 'gm everyone' }) const posts = await sdk.queryPublic() expect(posts.map((p) => JSON.parse(p.content).draft)).toContain('gm everyone') }) test('private notes round-trip for the owner', async () => { const sdk = await createPersonalSdk({ nodeUrl }) await sdk.submitPrivate({ draft: 'a private note' }) const notes = await sdk.queryPrivate() expect(notes.map((n) => JSON.parse(n.content).draft)).toContain('a private note') }) ``` Run them: ```bash $ npx vitest run Test Files 1 passed (1) Tests 2 passed (2) ``` The tests sign real commits and read them back through the node's ECDH-authenticated query path — the same code your app runs, exercised against the real protocol, not a mock. **Next:** [Deploy to production →](/tutorials/personal/deploy) ## ENC Protocol — Spec The normative protocol spec, divided by dependency layer. The **kernel** holds the protocol math and canonical semantics; the **node** realizes it at runtime; and the **app** layer — enclave profiles and confidentiality plugins — builds on top. ### Kernel Protocol math and canonical semantics. * [Protocol summary](/spec/kernel/spec) — trust model, core abstractions, cryptography, wire protocol, event semantics, lifecycle * [RBAC](/spec/kernel/rbac) — RBAC v2, contexts, manifests, authorization, per-event processing * [Sparse Merkle Tree](/spec/kernel/smt) — state and proof semantics * [Certificate Transparency](/spec/kernel/ct) — log and proof bundles ### Node Node, server, and runtime behavior that realizes the kernel. * [Node API](/spec/node/node-api) — REST / WS / session / errors, hooks, DataView surfaces * [Migration](/spec/node/migration) — migration modes, checkpoints, backup / restore * [ZK validity proofs](/spec/node/zk) — zero-knowledge folded validity proofs ### Enclaves The enclave profile catalog and per-profile semantics. * [Catalog](/spec/app/enclaves) * [Personal](/spec/app/enclaves/personal) * [DM](/spec/app/enclaves/dm) * [Group](/spec/app/enclaves/group) * [Registry](/spec/app/enclaves/registry) ### Plugins Confidentiality plugins and the cryptographic suite registry. * [Catalog](/spec/app/plugins) * [Cryptographic suites](/spec/app/suites) * [ratchet-pair](/spec/app/plugins/ratchet-pair) * [mls-lazy](/spec/app/plugins/mls-lazy) * [ecdh-envelope](/spec/app/plugins/ecdh-envelope) * [identity-aead](/spec/app/plugins/identity-aead) ## Enclave Migration This document specifies enclave **migration** — the controlled transfer of an enclave from one node to another while preserving its full event history and identity. The `Migrate` event, the three migration modes (eager / lazy / fork), checkpoint verification, split-brain prevention, and the backup pattern that re-binds the old enclave's SMT root are all defined here. *** ### Table of Contents * [Migrate Event](#migrate-event) * [Migration Modes](#migration-modes) * [Checkpoint Verification](#checkpoint-verification) * [Split-Brain Prevention](#split-brain-prevention) * [Backup Pattern](#backup-pattern) * [Enclave Snapshot Format](#enclave-snapshot-format) * [Snapshot Endpoints](#snapshot-endpoints) *** ### Migrate Event **Type:** `Migrate` **Content Structure:** ```json { "new_sequencer": "", "prev_seq": 1234, "ct_root": "" } ``` **Fields:** | Field | Required | Description | | -------------- | -------- | -------------------------------------------------------------------------------------------------- | | new\_sequencer | Yes | Public key of the new sequencer node | | prev\_seq | Yes | Sequence number of the last event BEFORE the Migrate event (Migrate will have seq = prev\_seq + 1) | | ct\_root | Yes | CT root at prev\_seq (proves log and state before Migrate) | **Authorization:** * Only Owner can issue Migrate. * The commit MUST be signed by Owner. **Migration Barrier:** Once a node accepts a Migrate commit: * The node MUST reject all other pending commits. * The Migrate event MUST be the final event from this sequencer. * No concurrent commits are allowed during migration. * The node MUST immediately close the current bundle after finalizing Migrate. **Bundle Handling:** The Migrate event does NOT need to be alone in its bundle. Events accepted before the Migrate commit MAY be in the same bundle. The bundle closes immediately after Migrate is finalized, regardless of `bundle.size` or `bundle.timeout` configuration. Example with `bundle.size = 10`: * Events seq=100-105 are in an open bundle * Migrate commit arrives, finalized as seq=106 * Bundle closes immediately with events seq=100-106 * No events seq=107+ are possible from this sequencer This ensures a clean handoff with no ambiguity about which events belong to which sequencer. *** ### Migration Modes **Peaceful Handoff (old node online):** 1. Owner submits `Migrate` commit to old node 2. Old node finalizes Migrate as the last event 3. Old node transfers full event log to new node 4. New node verifies log matches `ct_root` 5. New node continues sequencing; next event will be seq = `prev_seq + 2` (Migrate event was `prev_seq + 1`) 6. Owner updates Registry (`reg_enclave`) **Forced Takeover (old node offline):** 1. Owner or Backup has a copy of the event log 2. Owner signs `Migrate` commit (unfinalized) 3. Owner submits log + unfinalized commit to new node 4. New node verifies log integrity (see verification below) 5. **New node finalizes the Migrate event** (special case) 6. New node becomes the sequencer 7. Owner updates any discovery mechanism in use (e.g., the [Registry enclave](/spec/app/enclaves/registry) via `reg_enclave`) — outside the core migration protocol. In forced mode, the `sequencer` field of the Migrate event will be the NEW node, not the old one. This is the only event type where sequencer discontinuity is allowed. **Forced Takeover Verification:** Before finalizing Migrate, the new node MUST rebuild state from scratch: 1. Replay all events from the log, computing SMT state after each state-changing event 2. Verify computed SMT root matches `state_hash` in final bundle 3. Verify the `from` field of the Migrate commit has owner trait set in the computed SMT (RBAC namespace) 4. Recompute CT root — MUST match `ct_root` in Migrate commit 5. Verify commit signature (`sig`) is valid for the computed commit hash 6. Verify `prev_seq` equals the last event's sequence number in the log If any check fails, the new node MUST reject the migration. This full replay ensures the new node has a correct, verified copy of enclave state. Alternatively, a folded validity proof over `(r_0 … state_hash)` attests that every transition was valid without replaying the log — see [zk.md](/spec/node/zk) → Folding. (Replay remains the baseline; the validity proof is an O(1) optimization, not a replacement for data availability.) *** ### Checkpoint Verification The `ct_root` field serves as a checkpoint: * CT root commits to both the event log AND state (via `state_hash` in leaves) * New node recomputes CT root from received log * If mismatch, migration is rejected *** ### Split-Brain Prevention After migration: * Old node's sequencer key is no longer valid. * Any events finalized by old node after Migrate are invalid. * Sequencer discovery is out of scope for core; clients periodically re-check whatever discovery mechanism they use (e.g., the [Registry enclave](/spec/app/enclaves/registry)). **Client Recovery after Migrate:** If a client queries the old node after Migrate: 1. Old node MAY still serve read requests (CT proofs, state proofs) — data is valid. 2. Old node MUST reject new commits (new sequencer handles writes). 3. Client discovers migration via its discovery mechanism (e.g., a Registry lookup) or via `ENCLAVE_NOT_FOUND` on commit. 4. Client migrates to new node for subsequent operations. No explicit "migrated" error is required on reads; normal operation continues until the client syncs with its discovery mechanism. *** ### Backup Pattern To enable forced takeover, ensure someone has the full event log: **Option 1: Owner maintains backup** * Owner's client stores all events locally **Option 2: Dedicated Backup role** * Define a custom role with P (Push) permission for all event types * Assign to a backup service Schema example — define a `backup` trait with P ops on all events: ```json { "event": "*", "operator": "backup", "ops": ["P"] } ``` The wildcard `*` means the backup trait receives push for ALL event types. This enables disaster recovery if the node goes offline. > **Important:** Forced takeover requires Owner signature on the Migrate commit. If the Owner is offline and cannot sign: > > * Forced takeover is blocked (intentional security — only Owner can authorize sequencer changes) > * Mitigation: ensure Owner's client/key is highly available, or use Transfer(owner) before sequencer goes offline *** ### Enclave Snapshot Format When the old node transfers full state to the new node (Peaceful Handoff step 3) or a Backup supplies the log to a new sequencer (Forced Takeover step 3), the bytes on the wire need a stable interchange format. The protocol defines `.enc` as the canonical format. A `.enc` file is the **complete enclave state**: all finalized events, the SMT, the CT log, and the per-event metadata needed to rebuild the enclave on any compliant node. The format is hash-pinned (sha256 footer) and version-tagged so the receiving node can refuse incompatible kernels rather than silently corrupt state. #### File Layout ``` ┌──────────────────────────────────────────────────────────┐ │ Header (32 bytes, fixed) │ │ │ │ offset size field │ │ ───── ──── ────────────────────────────────────── │ │ 0 4 magic b"ENC\x01" │ │ 4 4 layout_ver u32 LE │ │ 8 4 kernel_ver u32 LE (semver-packed) │ │ 12 4 flags u32 LE (see Flags) │ │ 16 8 payload_size u64 LE (bytes that follow)│ │ 24 8 reserved 0u64 │ ├──────────────────────────────────────────────────────────┤ │ Payload (variable, payload_size bytes) │ │ │ │ The complete enclave state in the producing kernel's │ │ natural layout (see Payload Encoding below). │ ├──────────────────────────────────────────────────────────┤ │ Footer (32 bytes, fixed) │ │ │ │ sha256(header || payload) │ └──────────────────────────────────────────────────────────┘ Total: 32 + payload_size + 32 bytes ``` #### Magic `b"ENC\x01"` — four bytes, fixed. Lets restorers reject non-snapshot inputs immediately. #### layout\_ver (u32 LE) Version of THIS file format. v1 = the format above. A future v2 MAY move fields or change framing; restorers MUST refuse on unknown `layout_ver`. #### kernel\_ver (u32 LE) Semver of the kernel that produced the snapshot, packed: * bits 0-15: patch * bits 16-23: minor * bits 24-31: major **Compatibility rules:** | Producer / restorer relationship | Restorer action | | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | Same `kernel_ver` byte-for-byte | Accept. | | Differs in patch only, ≥ 1.0 | Accept; MAY warn. | | Differs in minor only, ≥ 1.0 | Accept; SHOULD warn. | | Differs in major | REFUSE. Requires explicit out-of-band migration. | | Any difference at all, pre-1.0 kernels (0.x.y) | REFUSE. Pre-1.0 layouts can shift between any release; defer loosening until a kernel reaches 1.0 with a documented backwards-compat contract. | On refusal, the restorer MUST surface `KERNEL_VERSION_MISMATCH` (the named error code in [`node-api.md` §Snapshot Endpoints](/spec/node/node-api#snapshot-endpoints)) along with both versions. #### Flags (u32 LE) ``` bit 0: COMPRESSED — payload is brotli-compressed bit 1: SELF_CONTAINED — payload prepends a copy of the kernel binary for offline verification bit 2: ENCRYPTED — payload encrypted; key derivation in implementation-defined metadata bit 3-31: reserved — MUST be 0 ``` v1 producers MAY set all flags to 0 (no compression, no embedded kernel, no encryption). Restorers MUST refuse on unknown flag bits. #### Footer `sha256(header || payload)`, 32 bytes. Restorers MUST recompute and refuse on mismatch — this is what makes the snapshot integrity-checked even when transferred over an untrusted channel (e.g., R2 / S3 / IPFS / hand-off via USB drive). #### Payload Encoding Two payload encodings are defined; restorers detect by sniffing the first four bytes of the payload: * **Compact (`payload[0..4] == "ENC\x01"`)** — just the populated storage buffer. Typical 200 KB – few MB for a single-bundle enclave; 10-30× smaller than the raw form. RECOMMENDED for migration over the network. * **Raw memory dump (anything else, typically zero bytes from a WASM data segment)** — the entire kernel address space as a single byte blob. Typical 65 MB even for small enclaves (the kernel reserves a full bump-allocator arena). Useful for forensic snapshots that capture allocator state exactly. Both encodings carry the same logical enclave state — every event, every SMT entry, every CT leaf. The choice is bytes-on-the-wire vs. memory-layout fidelity. A future `layout_ver = 2` can assign a flag bit to make the distinction explicit instead of sniffed. #### Snapshot Procedure ``` 1. Kernel writes its full state to a contiguous byte buffer `payload`. 2. Compute header bytes: magic || layout_ver || kernel_ver || flags || payload_size || reserved 3. Compute footer = sha256(header || payload) 4. Emit: header || payload || footer ``` #### Restore Procedure ``` 1. Read first 32 bytes → header. Verify magic == "ENC\x01". Reject otherwise. Verify layout_ver is known. Reject otherwise. 2. Read next payload_size bytes → payload. 3. Read next 32 bytes → expected_footer. 4. Compute actual_footer = sha256(header || payload). Reject on mismatch. 5. Apply kernel_ver compatibility rules. Reject on major bump. 6. Sniff payload[0..4] to pick the encoding; install into a fresh kernel instance. 7. Run the kernel's self-test entry point. Reject on non-zero result. 8. The enclave is live; sequencing can resume. ``` Steps 1-5 are MUST. Step 6 is implementation-dependent on the encoding. Step 7 is OPTIONAL but RECOMMENDED — a self-test catches structural corruption the sha256 footer alone cannot (e.g., bit-flips in the page table that happen to balance). *** ### Snapshot Endpoints The `.enc` byte format is wire-format only; the protocol surface that moves snapshots between nodes is two HTTP endpoints. They are specified in [`node-api.md` §Snapshot Endpoints](/spec/node/node-api#snapshot-endpoints): | Method | Path | Body | Purpose | | ------ | ------------------------ | --------------------------------- | ------------------------------------------------------------------------ | | `GET` | `/enclaves/:id/snapshot` | — | Download the complete enclave state as `application/octet-stream` `.enc` | | `POST` | `/enclaves/:id/restore` | `application/octet-stream` `.enc` | Bootstrap a fresh enclave on the receiving node from a `.enc` payload | \::: extension-point id=migration-snapshot-restore-authz class=local\_policy reason: authorization is operator-deployment policy; the wire format and verification rules are the same regardless of who's authorized to call Authorization for the `/enclaves/:id/snapshot` and `/enclaves/:id/restore` endpoints is set by the operator (typically Owner only for `restore`, public-with-RBAC-projection for `snapshot`). The wire format and verification rules are the same regardless of who's authorized to call them. \::: *** ## Node API This document specifies the **ENC Node API** — the HTTP, WebSocket, and webhook surface every conforming node exposes. It covers the high-level surface and Session tokens, the REST endpoints and Filter query DSL, the WebSocket streaming subscription protocol, the proof retrieval endpoints, the push / notify and webhook delivery semantics, the wire-level handling of encrypted payloads, and the full error catalog. *** ### Table of Contents * [Overview](#overview) * [Session](#session) * [Enclave API](#enclave-api) * [Filter](#filter) * [WebSocket API](#websocket-api) * [Proof Retrieval API](#proof-retrieval-api) * [Webhook Delivery](#webhook-delivery) * [Registry DataView API](#registry-dataview-api) * [Push/Notify](#push-notify) * [Encryption](#encryption) * [Error Codes](#error-codes) * [Error Response Format](#error-response-format) *** ### Overview #### Base URL ``` https:/// ``` #### Content Type All requests and responses use `application/json`. #### Authentication | Operation | Method | | ---------------- | --------------------------------------------------------------------------------------------------- | | Commit | Schnorr or ECDSA signature over commit hash (per `alg`; see [Signature Schemes](/spec/kernel/spec)) | | Query | Session token (see [Session](#session)) | | Pull | Session token | | WebSocket Query | Session token | | WebSocket Commit | Schnorr or ECDSA signature (per `alg`) | #### Endpoints Summary **Enclave API (all enclaves):** | Method | Path | Description | | ------ | ------------------------ | ---------------------------------------------------------------------------------------- | | POST | `/` | Submit commit, query, or pull request | | WS | `/` | Real-time subscriptions | | GET | `/enclaves/:id/snapshot` | Download complete enclave state (`.enc`) — see [Snapshot Endpoints](#snapshot-endpoints) | | POST | `/enclaves/:id/restore` | Bootstrap enclave from a `.enc` payload — see [Snapshot Endpoints](#snapshot-endpoints) | **Node Bootstrap (legacy):** | Method | Path | Description | | ------ | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | POST | `/create-enclave` | Legacy self-serve enclave bootstrap endpoint; see [Legacy `/create-enclave` reference](#legacy-create-enclave-reference-kept-for-backward-compat). | Deployments can omit `/create-enclave` when bootstrap is gated externally (e.g., a hosted node service like `impl-cloud` that pre-provisions enclaves before the node accepts traffic). The detailed legacy section specifies the absence response. **Request types:** Commit, Query, Pull **Proof Retrieval API:** | Method | Path | Access | Description | | ------ | ----------------------- | ------ | ------------------------ | | GET | `/:enclave/sth` | Public | Current signed tree head | | GET | `/:enclave/consistency` | Public | CT consistency proof | | POST | `/inclusion` | R | CT inclusion proof | | POST | `/bundle` | R | Bundle membership proof | | POST | `/state` | R | SMT state proof | | POST | `/state-batch` | R | Batched SMT state proofs | **Registry DataView API (Registry enclave only):** | Method | Path | Description | | ------ | ----------------------- | ----------------------------------------------- | | GET | `/nodes/:seq_pub` | Resolve node by public key | | GET | `/enclaves/:enclave_id` | Resolve enclave → enclave record + hosting node | | GET | `/identity/:id_pub` | Resolve identity by public key | *** *** ### Session Session tokens provide stateless authentication for queries. An identity authorizes a session key in one of two ways: the **Schnorr-algebraic** token below (default), or a **delegated** token authorized by a recoverable ECDSA signature (for identities that sign with ECDSA; see [Delegated Session](#delegated-session-ecdsa-authorized)). #### Token Format 136 hex characters = 68 bytes ``` Bytes 0-31: r (Schnorr signature R value) Bytes 32-63: session_pub (x-only public key) Bytes 64-67: expires (big-endian uint32, Unix seconds) ``` #### Client Derivation ``` 1. expires = now + duration (max 7200 seconds) 2. message = "enc:session:" || be32(expires) 3. sig = schnorr_sign(sha256(message), id_priv) 4. r = sig[0:32] 5. s = sig[32:64] 6. session_priv = s 7. session_pub = point(s) 8. session = hex(r || session_pub || be32(expires)) ``` #### Node Verification O(1) EC math — no signature verification. ``` 1. Parse: r, session_pub, expires from token 2. Check: expires > now - 60 (allow 60s clock skew) 3. Check: expires ≤ now + 7200 + 60 (allow 60s clock skew) 4. message = "enc:session:" || be32(expires) 5. expected = r + sha256(r || from || message) * from 6. Verify: session_pub == expected ``` Clock skew tolerance (±60 seconds) allows clients with slightly-off clocks to connect. **Curve Parameters:** All EC arithmetic uses secp256k1. The curve order is: ``` n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 ``` All scalar operations (addition, multiplication) are performed modulo `n`. #### Delegated Session (ECDSA-authorized) Identities that sign with ECDSA rather than Schnorr (see [Signature Schemes](/spec/kernel/spec)) cannot produce the Schnorr-algebraic token above, because `session_pub = r + e·from` requires a Schnorr signature by the identity key. Such an identity MAY instead authorize a fresh **ephemeral session key** with a recoverable ECDSA signature. **Client Derivation:** ``` 1. expires = now + duration (max 7200 seconds) 2. (session_priv, session_pub) = fresh ephemeral secp256k1 keypair 3. auth_msg = sha256("enc:session:delegate:" || session_pub || be32(expires)) 4. session_auth = ecdsa_sign_recoverable(auth_msg, id_priv) # { sig: r||s compact, recovery } 5. session = hex(0x00*32 || session_pub || be32(expires)) # the r slot is unused (32 zero bytes) ``` `session_auth` is carried **alongside** the token in the request payload (it does not fit the 68-byte token): the Query/Pull encrypted content gains an optional `session_auth` field. **Node Verification:** ``` 1. Parse: session_pub, expires from token; read session_auth from the request payload 2. Check expiry (same ±60s skew bounds as the Schnorr session) 3. auth_msg = sha256("enc:session:delegate:" || session_pub || be32(expires)) 4. recovered = ecdsa_recover(auth_msg, session_auth.sig, session_auth.recovery) 5. Verify: x_only(recovered) == from ``` The ephemeral `session_pub` feeds Signer Derivation and ECDH exactly as a Schnorr session does — only the authorization differs. The identity key signs once to authorize the session key; the long-term key is not used again for that connection. | | Schnorr session | Delegated session | | ----------------------------------- | ---------------------------------------------------- | -------------------------------------------- | | Identity binding | intrinsic (token *is* a Schnorr signature by `from`) | explicit (`ecrecover(session_auth) == from`) | | Recoverable from token alone | no | yes (via `ecrecover`) | | Self-contained in the 68-byte token | yes | no (auth travels with the request) | | Requires Schnorr signing | yes | no (ECDSA only) | #### Signer Derivation Per-node signer for ECDH. ``` t = sha256(session_pub || seq_pub || enclave) signer_priv = session_priv + t (mod n) signer_pub = session_pub + t * G ``` **Design Rationale:** The `enclave` ID is included in the signer derivation so that the same `session_pub` produces different signer keys for different enclaves. This provides per-enclave key isolation: * Session token is reusable across enclaves (same `session_pub`) * Derived encryption key (via `signer_pub`) is enclave-specific * A compromised signer key in Enclave A cannot decrypt messages for Enclave B The isolation provides defense-in-depth. **Security Properties:** | Property | Protected? | Reason | | ---------------------- | ----------- | ---------------------------------------------------------- | | Cross-session reuse | ✓ | Different `session_pub` → different `t` → different signer | | Cross-enclave reuse | ✓ | Different `enclave` → different `t` → different signer | | Cross-node reuse | ✓ | Different `seq_pub` → different `t` → different signer | | Same session + enclave | Same signer | Intentional — enables session continuity | No additional replay protection is needed; the derivation inputs guarantee uniqueness. #### Session Properties | Property | Value | | --------------- | ----------------------------------------------------------------- | | Max expiry | 7200 seconds (2 hours) | | Timestamp unit | Seconds (for uint32 compactness; API timestamps use milliseconds) | | Reusable | Yes, until expiry | | Per-node signer | Yes (different ECDH per node) | | Multi-key | One connection, multiple sessions | *** *** ### Hook System (server-side policy layer) Nodes admit commits to the protocol layer via a configurable **hook chain**. A hook is a policy gate that examines an incoming commit and either lets it through (`pass`) or refuses admission (`reject`). Hooks **MUST NOT** transform commits — they only side-effect (audit, telemetry, rate-counter increment) or reject. The protocol layer's state machine sees only the admitted commits. ``` Policy layer (hooks) ─── side effects + reject decisions │ ▼ (admitted commits, unchanged) Protocol layer (kernel) ── deterministic state machine, CT log, SMT roots ``` The **hook orthogonality theorem** (`Enc.Core.Hooks.Determinism.replay_invariant_under_hook_swap`) establishes the release rule: two deployments with different hook configurations but identical admitted-commit sets MUST produce IDENTICAL protocol state. The kernel's correctness theorems (sharding, CT log, composition, rebalance, jump-hash bound, Telegram-scale runnability) all apply for ANY hook configuration. Hooks are deployment policy; the protocol layer is policy-agnostic. #### Hook chain semantics * Hooks compose in deployer-specified order. * Any `reject` short-circuits the chain. The commit is refused. * All `pass` → commit is admitted to the protocol layer. * Empty chain → admit everything (no policy; not safe for production). #### Standard starter hooks | Hook | Trigger | Rejects when | | ----------------- | ------------------------------------------------ | ------------------------------------------------- | | Bearer | `Authorization: Bearer ` header | header missing or token != configured secret | | ProvisionerSig | `X-Provisioner-Sig` + `X-Provisioner-Ts` headers | sig invalid, ts skewed, or signer not allowlisted | | ParseCommitSigner | (no headers — reads `commit.from_pub`) | from\_pub not in allowlist | | RateLimitByPub | per-pubkey counter | rate exceeded | Implementations can ship additional hooks (CF Access JWT, API key allowlist, IP allowlist, etc.) as opt-in plugins. The intended promotion path for each hook plugin is a formal Lean specification in `Enc.Core.Hooks.` and a claim sidecar in `claims/prose/node/node-api.claims.yaml` (or per-plugin file). #### Verify-once invariant For Commit-shaped requests, the hook layer avoids verifying the commit's Schnorr signature; the kernel verifies it as part of normal commit processing. A `ParseCommitSigner` hook that does an allowlist check on `commit.from_pub` (without re-verifying the sig) keeps the verification cost at ONE signature operation per request, not two. For non-commit requests (admin endpoints with no downstream kernel verification), full cryptographic auth via Bearer / ProvisionerSig / JWT is appropriate. #### Deprecated: `/create-enclave` The `/create-enclave` endpoint is **DEPRECATED** as of this revision. Hosted node services and self-serve deploys use either of these bootstrap paths: 1. **Hosted gating** — the hosting plane (e.g., `impl-cloud`) accepts user bootstrap requests, signs the manifest commit itself, and POSTs the resulting commit to `POST /` (the standard multiplexed endpoint). The node treats this as any other commit; a Bearer hook authorizes the hosting plane's API access. 2. **Self-signed bootstrap** — the operator signs a manifest commit offline and POSTs it to `POST /` directly. Backward-compatible implementations can retain a `/create-enclave` endpoint, but new deployments use `POST /` plus a hook chain for bootstrap admission. The hook chain at `POST /` covers all admission policies that `/create-enclave` previously gated. #### Legacy `/create-enclave` reference (kept for backward compat) Some deployments expose a self-serve endpoint that creates a fresh enclave by signing a manifest commit. The endpoint is **OPTIONAL**: implementations choose whether to include it based on their deployment model. **When to include `/create-enclave`:** * Self-serve nodes where any caller may instantiate a new enclave. * Reference implementations used for protocol conformance testing. * Local-dev / open-test infrastructure. **When to OMIT `/create-enclave`:** * Hosted node services (e.g., `impl-cloud`) that gate enclave creation externally — bootstrap is performed via the hosting plane's admin API, not the per-node HTTP surface. * Single-tenant deployments where the enclave set is fixed at deploy time. * Compliance regimes that require pre-provisioning + per-enclave KYC. Deployments that omit the endpoint MUST return `404 Not Found` so callers can detect the absence with a single round-trip. #### POST `/create-enclave` (when supported) Create a fresh enclave by submitting a manifest commit. The node generates the enclave\_id deterministically from the manifest content and signs the bootstrap event with its node key (`NODE_PRIVATE_KEY`). **Request:** ```json { "manifest": { "RBAC": { "use_temp": "none", "schema": [ ... ] }, "": ... } } ``` **Response (success):** ```json { "ok": true, "enclave_id": "<64 hex chars>", "head": { "": ... } } ``` **Response (omitted endpoint):** `404 Not Found` **Response (errors):** Standard error envelope (see [Error Codes](#error-codes)). **Semantics:** * The node MUST verify the manifest is well-formed before signing. * The node MUST sign the bootstrap commit with the configured `NODE_PRIVATE_KEY`. * The resulting `enclave_id` MUST be deterministic from the manifest content (so the same manifest from two callers produces the same enclave\_id and idempotent collisions are detectable). * Idempotent reposts of the same manifest MUST return the existing enclave's `head`, not create a duplicate. ### Enclave API #### POST / (Commit) Submit a commit to the enclave. **Detection:** Request contains `exp` field. **Request:** ```json { "hash": "", "enclave": "", "from": "", "type": "", "content": "", "content_hash": "", "exp": 1706000000000, "tags": [["key", "value"]], "sig": "" } ``` | Field | Type | Required | Description | | ------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | hash | hex64 | Yes | CBOR hash of commit (see spec.md) | | enclave | hex64 | Yes | Target enclave ID | | from | hex64 | Yes | Sender's identity public key | | type | string | Yes | Event type | | content | string | Yes | Event content as a UTF-8 string. Binary MUST be base64-encoded (see [`spec.md` §Content](/spec/kernel/spec)). The node MUST verify `sha256(utf8_bytes(content)) == content_hash` (next field) and reject with `CONTENT_HASH_MISMATCH` if not. Empty payloads use `""`. | | content\_hash | hex64 | Yes | `sha256(utf8_bytes(content))`. Bound in the commit signature via the CBOR pre-image; the node enforces the match against `content`. | | exp | uint | Yes | Expiration timestamp (Unix milliseconds) | | tags | array | No | Array of \[key, value] pairs | | alg | string | No | Signature scheme: `"schnorr"` (default if absent) or `"ecdsa"` (see [Signature Schemes](/spec/kernel/spec)) | | sig | hex128 | Yes | Signature over hash, per `alg` | **`content_hash`:** The client MUST send `content_hash` alongside `content` (it's a commit field — see [`spec.md` §Commit Structure](/spec/kernel/spec)).The node MUST recompute `expected = sha256(utf8_bytes(content))` and reject the commit with `CONTENT_HASH_MISMATCH` if `expected ≠ content_hash`.See [`spec.md` §Commit Hash Construction](/spec/kernel/spec) for the rationale and implementation status table. **Response (200 OK):** Receipt ```json { "type": "Receipt", "id": "", "hash": "", "timestamp": 1706000000000, "sequencer": "", "seq": 42, "sig": "", "seq_sig": "" } ``` | Field | Type | Description | | --------- | ------ | ----------------------------------------------------------------------------------------------------------------- | | type | string | Always `"Receipt"` | | id | hex64 | Event ID | | hash | hex64 | Original commit hash | | timestamp | uint | Sequencer timestamp (Unix milliseconds) — recorded when sequencer finalizes the event, not client submission time | | sequencer | hex64 | Sequencer public key | | seq | uint | Sequence number | | sig | hex128 | Client's signature (from commit) | | seq\_sig | hex128 | Sequencer's signature over event | **Note:** Receipt omits `enclave` for privacy — client already knows which enclave it submitted to. **Errors:** | Code | HTTP | Description | | -------------------- | ---- | -------------------------------- | | `INVALID_COMMIT` | 400 | Malformed commit structure | | `INVALID_HASH` | 400 | Hash doesn't match CBOR encoding | | `INVALID_SIGNATURE` | 400 | Signature verification failed | | `EXPIRED` | 400 | `exp` \< current time | | `DUPLICATE` | 409 | Commit hash already processed | | `UNAUTHORIZED` | 403 | Insufficient RBAC permissions | | `ENCLAVE_NOT_FOUND` | 404 | Enclave doesn't exist | | `ENCLAVE_PAUSED` | 403 | Enclave is paused | | `ENCLAVE_TERMINATED` | 410 | Enclave is terminated | | `RATE_LIMITED` | 429 | Too many requests | #### POST / (Query) Query events from the enclave. **Detection:** Request contains `type: "Query"` field. **Request:** ```json { "type": "Query", "enclave": "", "from": "", "content": "" } ``` | Field | Type | Required | Description | | ------- | ------ | -------- | ------------------------------------------------- | | type | string | Yes | Must be `"Query"` | | enclave | hex64 | Yes | Target enclave ID (plaintext for routing) | | from | hex64 | Yes | Requester's identity public key | | content | string | Yes | Encrypted payload (see [Encryption](#encryption)) | **Content (plaintext):** ```json { "session": "", "filter": { ... } } ``` | Field | Type | Required | Description | | ------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------ | | session | hex136 | Yes | Session token (see [Session](#session)) | | session\_auth | object | No | ECDSA authorization `{ sig, recovery }` for a delegated session (see [Delegated Session](#delegated-session-ecdsa-authorized)) | | filter | object | Yes | Query filter (see [Filter](#filter)) | **Note:** `enclave` is plaintext for routing — node needs it before decryption. **Response (200 OK):** ```json { "type": "Response", "content": "" } ``` **Response Content (plaintext):** ```json { "events": [ { "event": Event, "status": "active" }, { "event": Event, "status": "updated", "updated_by": "" }, ... ] } ``` | Field | Description | | ----------- | --------------------------------------------------------------------------- | | event | The event object | | status | `"active"` — event is current; `"updated"` — superseded by Update event | | updated\_by | (Present when `status: "updated"`) Event ID of the superseding Update event | **Note:** Deleted events are NOT returned. To query deleted event IDs, use the SMT Event Status proof. **Errors:** | Code | HTTP | Description | | ------------------- | ---- | --------------------------------- | | `INVALID_QUERY` | 400 | Malformed query structure | | `INVALID_SESSION` | 400 | Session token verification failed | | `SESSION_EXPIRED` | 401 | Session token expired | | `DECRYPT_FAILED` | 400 | Cannot decrypt content | | `INVALID_FILTER` | 400 | Malformed filter | | `UNAUTHORIZED` | 403 | No read permission | | `ENCLAVE_NOT_FOUND` | 404 | Enclave doesn't exist | | `RATE_LIMITED` | 429 | Too many requests | *** *** ### Filter Query filter for event retrieval. #### Structure ```json { "id": " | [, ...]", "seq": " | [, ...] | Range", "type": " | [, ...]", "from": " | [, ...]", "tags": { "": " | [, ...] | true" }, "timestamp": "Range (Unix ms)", "limit": 100, "reverse": false } ``` All fields are optional. Omitted field = no filter (match all). **Tags Filter:** The `tags` field filters events by tag presence or value: * `{ "r": "abc123." }` — events with `r` tag matching value * `{ "r": ["abc123.", "def456."] }` — events with `r` tag matching any value * `{ "auto-delete": true }` — events with `auto-delete` tag (any value) **Default Sort Order:** Events are sorted by sequence number ascending unless `reverse: true`. This is the canonical enclave order. #### Range ```jsonc { "start_at": 100, // >= 100 "start_after": 100, // > 100 "end_at": 200, // <= 200 "end_before": 200 // < 200 } ``` #### Semantics | Pattern | Meaning | | ---------------- | --------- | | Top-level fields | AND | | Array values | OR | | Omitted field | Match all | #### Limits | Field | Max | | --------------------- | ---- | | `id[]` | 100 | | `seq[]` | 100 | | `type[]` | 20 | | `from[]` | 100 | | `tags` keys | 10 | | `tags` values per key | 20 | | `limit` | 1000 | #### Examples **By type:** ```json { "type": "message" } ``` **By authors:** ```json { "type": "message", "from": ["abc...", "def..."] } ``` **Time range:** ```json { "timestamp": { "start_at": 1704067200000, "end_before": 1704153600000 } } ``` **Resume from seq:** ```json { "seq": { "start_after": 150 }, "limit": 100 } ``` **Newest first:** ```json { "type": "message", "reverse": true, "limit": 20 } ``` *** *** ### WebSocket API Real-time pub/sub for event subscriptions. **Endpoint:** `wss:///` **Subscription is automatic:** First valid Query on a connection creates a subscription. The node assigns a `sub_id` — or, if the Query carries one, adopts the caller-supplied value (any UTF-8 string) — and begins streaming events. #### Connection Model | Direction | Type | Description | | --------- | --------- | ----------------------------------------------------------------------------------- | | C → N | `Query` | First valid Query subscribes; node adopts a caller-supplied `sub_id` or assigns one | | C ← N | `Event` | Stored events (encrypted) | | C ← N | `EOSE` | End of stored events | | C ← N | `Event` | Live updates (encrypted) | | C → N | `Commit` | Write event | | C ← N | `Receipt` | Write success | | C ← N | `Error` | Write error | | C → N | `Close` | Unsubscribe from subscription | | C ← N | `Closed` | Subscription terminated | | C ← N | `Notice` | Informational message | #### Client → Node | Message | Format | Description | | ------- | ------------------------------------------- | ----------------------------------------------------------------------------------- | | Query | Same as POST / (Query), optional `sub_id` | First valid Query subscribes; node adopts a caller-supplied `sub_id` or assigns one | | Commit | Same as POST / (Commit) | Write event, returns Receipt | | Close | `{ "type": "Close", "sub_id": "" }` | Unsubscribe from subscription | **Note:** `Close` unsubscribes from a single subscription. To close the entire WebSocket connection, close the WebSocket transport directly. Closing the transport terminates all active subscriptions. #### Subscription Identifiers (`sub_id`) Every subscription on a connection is keyed by a `sub_id`, which the node echoes on every frame it sends for that subscription (`Event`, `EOSE`, `Closed`). A `sub_id` is set one of two ways: * **Node-assigned (default):** if a `Query` carries no `sub_id`, the node generates a unique one and tags the subscription's frames with it. * **Caller-supplied:** if a `Query` includes a `sub_id` (a non-empty string), the node adopts it verbatim as the subscription key. The caller is responsible for keeping its supplied ids unique within the connection. Caller-supplied ids let a client carry an arbitrary number of independent subscriptions over a single connection and demultiplex incoming frames by `sub_id` without tracking a node-assigned mapping. They are also the wire-level foundation of the **aggregator pattern** described below. #### Subscribe Replay Semantics (no silent truncation) `Query`-as-subscribe replays stored events to the client before transitioning to live. The replay window is fully determined by the caller's filter cursor, NOT by a node-side default page size: * **With `seq.start_after = c`**: the node MUST replay every stored event with `seq > c` contiguously, then transition to live. The transition is signalled by `EOSE` and the live stream resumes from the first event committed after the last replayed `seq`. There MUST NOT be a gap. * **Without a `seq` cursor**: the subscription is **live-only**. The node MUST NOT replay an oldest-N window of stored events. Catch-up history is obtained via a separate `POST / (Query)` request with explicit pagination, then the client opens a subscribe with `seq.start_after = ` to attach the live stream contiguously. Equivalently: `limit` is a **Pull/Query pagination control**, not a subscribe-replay cap. A node MUST NOT apply a default `limit` to subscribe-replay that would produce a non-contiguous "oldest-N then live" stream. The rule exists because silent truncation is the worst failure mode: a `{}` subscribe that returns the oldest 100 stored events and then transitions to live drops the entire middle range and is impossible for the client to detect from frame contents alone. A node that needs to bound subscribe cost for DoS reasons MUST do so by: 1. Rejecting the subscribe with an explicit `Closed` reason (e.g., `subscribe_replay_too_large` with a hint to Pull history in chunks); OR 2. Streaming events contiguously and asynchronously without buffering them all in memory; OR 3. Treating the absent-cursor case as live-only per the rule above. A node MUST NOT silently keep the oldest-N events and discard everything since. **Client cache reconciliation.** A client that caches events locally MUST NOT assume subscribe-replay re-delivers state-mutating events such as `Delete` or `Update`. On reconnect / rejoin, the client MUST reconcile its cached events against the authoritative `event_status` SMT entries (the `0x01` namespace, per smt.md §Event Status), and treat any cached event whose authoritative status is `deleted` or `updated` as no longer active — regardless of whether the corresponding `Delete` / `Update` event was observed in the subscribe stream. Caching clients on busy enclaves WILL, by the no-truncation rule above, sometimes miss the explicit `Delete` event on the live stream; reconciliation against `event_status` is what closes the resurrection hole. #### Aggregator Pattern (Connection Collapse) **The problem.** A client watching its DM enclave plus N group enclaves needs `N+1` WebSocket connections to the node — one per enclave (because each enclave is its own subscription target, and platforms like Cloudflare bind an accepted WS to one Durable Object instance per enclave). A 100-member group with every member online means every other member opens a socket into that enclave's DO; connection count scales as `O(clients × enclaves_per_client)`. **The aggregator.** A WebSocket aggregator (a "hub") sits between clients and the node and collapses that fan-out: ``` Without aggregator With aggregator ────────────────── ─────────────── client A ─┬─► node:enclave_1 client A ─┐ ├─► node:enclave_2 │ └─► node:enclave_3 ├─► hub ─┬─► node:enclave_1 │ ├─► node:enclave_2 client B ─┬─► node:enclave_1 client B ─┤ ├─► node:enclave_3 ├─► node:enclave_2 │ └─► node:enclave_4 └─► node:enclave_4 │ client C ─┘ (6 client→node sockets) (3 client→hub sockets + 4 hub→node upstream sockets) ``` * **Each client opens ONE WebSocket** to the hub, regardless of how many enclaves it watches. All of that client's subscriptions are multiplexed on it via caller-supplied `sub_id`s. * **The hub opens ONE upstream WebSocket per enclave** (refcounted across all its clients of that enclave). When the last client of an enclave unsubscribes, the upstream is closed; when the next client subscribes, it reopens. * Total connections drop from `O(clients × enclaves_per_client)` (without) to `O(clients) + O(distinct live enclaves)` (with). **Why this is wire-compatible.** The hub is a pure `sub_id` router: * It forwards `Query` frames **verbatim** — `{session, filter}` stays opaque, the node verifies each client's session and enforces per-client RBAC. The hub is **never** an authorization point; it cannot read encrypted event payloads and does not need to. * It rewrites the `sub_id` on the way in (to a hub-global id unique on the upstream socket) and back to the client's id on the way out — so two clients can both use `sub_id = "s1"` without colliding. * It honors `Close { sub_id }` per subscription, decrementing the upstream refcount and closing the upstream when refcount = 0. The node sees only a normal multiplexed-`sub_id` client and applies its normal authorization rules per `Query`. No new opcodes; no auth model changes. **Wire-level requirements the aggregator pattern imposes on the node.** These are normative — an aggregator built against a node missing any of them silently misroutes subscriptions: 1. **`Query` accepts a caller-supplied `sub_id`** (used verbatim) and echoes it on `Event` / `EOSE` / `Closed` / `Error` frames. (Defined above under *Caller-supplied*.) 2. **`Close { sub_id }`** removes only the named subscription, leaving other subscriptions on the same connection alive. 3. **Hibernation preserves ALL subscriptions per connection.** When a node hibernates an idle WebSocket (e.g., Cloudflare Durable Object `acceptWebSocket()`) and restores it on the next inbound message, ALL of the connection's active subscriptions MUST be restored — not just the last-touched one. Restoring only the last drops every other client routed through the aggregator; this is a wire-incompatible regression. Implementations using attachment-style persistence (e.g., `ws.serializeAttachment()`) MUST write the FULL per-connection subscription set, not per-subscription. 4. **`tags` round-trip losslessly through storage.** Plugins (`dm:sent` with `["to", ...]`, cross-enclave `notice` with `["enclave_id", ...]`) depend on tags surviving backfill from the event store, not just live broadcast. (Normative requirement is in [`spec.md` §Tags](/spec/kernel/spec#tags).) Items 1, 2, and 4 are also true for direct client→node use; item 3 only matters under aggregation (a single-subscription connection trivially preserves its only sub). All four are checked by `tools/conformance/run-wire.mjs` against the Lean reference; sibling impls get equivalent CI coverage. #### WebSocket Heartbeats The wire protocol defines a minimal ping / pong heartbeat to keep connections alive across proxy / edge idle timeouts (notably Cloudflare's \~100s outbound WS idle close, which does not always surface as a clean close event): | Direction | Frame | Body | | --------- | ---------- | ------ | | C → N | text frame | `ping` | | N → C | text frame | `pong` | | N → C | text frame | `ping` | | C → N | text frame | `pong` | **Wire format:** plain-text `ping` / `pong`. **Not** JSON. **Not** a typed message envelope. The 4-byte (`ping`) / 4-byte (`pong`) payloads are kept short so they remain distinguishable from any JSON message (every JSON message begins with `{`). **Cadence:** SHOULD send a `ping` every 25 seconds of idle, with a 10-second `pong` deadline. On missed pong, the sender SHOULD close the connection and reconnect. **Symmetry:** both directions independently initiate pings. A client pings the node to detect silent edge timeouts; the node (or an intermediary aggregator forwarding to an upstream node) pings its peer for the same reason. The response handler is the same on both sides: reply `pong` to any inbound `ping`. **Aggregator forwarding:** an intermediary multiplexer forwards a client's `ping` to its upstream and the upstream's `pong` back, so a single missed pong on EITHER side surfaces to the originator and triggers reconnect. #### Node → Client **Event (stored or live):** ```json { "type": "Event", "sub_id": "", "event": "" } ``` **End of Stored Events:** ```json { "type": "EOSE", "sub_id": "" } ``` **Write Success:** Receipt (same as HTTP) ```json { "type": "Receipt", "id": "", "hash": "", "timestamp": 1706000000000, "sequencer": "", "seq": 42, "sig": "", "seq_sig": "" } ``` **Write Error:** ```json { "type": "Error", "code": "", "message": "" } ``` **Subscription Closed:** ```json { "type": "Closed", "sub_id": "", "reason": "" } ``` **Closed Reasons:** | Reason | Description | Client Action | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | `access_revoked` | Live tail rejected at session open: requester has neither a `retention: "current"` column in their current bitmask nor any open-ended `retention: "snapshot"` interval. | Permanent; re-subscribe on rejoin or permission restoration. | | `no_access` | Query accepted, but its seq / filter range does not overlap any reader interval the requester is allowed to read in this enclave. | Permanent for this query; a different seq range may succeed. | | `live_access_ended` | Subscription served historical events successfully and was streaming live, but an RBAC change closed the requester's open-ended interval mid-stream (Move(MEMBER → OUTSIDER), Revoke(admin), …). | Permanent; re-subscribe on rejoin to resume live tail. | | `session_expired` | Session token expired | Generate new session, re-subscribe | | `enclave_terminated` | Enclave was terminated | Permanent; no recovery | | `enclave_paused` | Enclave was paused | Wait for Resume event, then re-subscribe | | `enclave_migrated` | Enclave migrated to new node | Query Registry for new node, re-subscribe there | | `upstream_closed` | Emitted by a WebSocket aggregator (see [Aggregator Pattern](#aggregator-pattern-connection-collapse)) when its shared upstream socket to the enclave node died. Per-subscription; other subs on the same client connection remain alive. | Re-subscribe (the aggregator opens a fresh upstream + backfill catches up). | `no_access` vs `live_access_ended` vs `access_revoked` are normatively distinct outcomes — see [Read Authorization](#read-authorization) for when each fires. Implementations targeting the snapshot read-authorization path SHOULD emit the most specific reason that applies. **Notice:** ```json { "type": "Notice", "message": "" } ``` #### Node Processing **On Query:** 1. Verify session (same as HTTP) 2. Authorize the query per [Read Authorization](#read-authorization) — compute the requester's served seq set; reject with `access_revoked` (no readable intervals at all) or `no_access` (intervals exist but don't overlap the query) as defined there 3. Adopt the Query's `sub_id` if present (non-empty string), else generate one; store subscription 4. Send matching events as `Event` messages (encrypted), restricted to the served seq set 5. Send `EOSE` message 6. On new events matching filter AND inside the requester's open-ended interval → push `Event` to client; on RBAC change that closes the open-ended interval, emit `Closed { reason: "live_access_ended" }` **On Commit:** Same as HTTP, returns Receipt. **On Close:** Remove subscription. Terminate connection if none remain. #### Read Authorization The node's authorization decision per `Query` (HTTP or WebSocket) is normative; deviation produces wire-incompatible behavior — a client sees inconsistent close reasons or, worse, served events the requester was not entitled to. The authorization rule has three concerns: 1. Compute the requester's **per-reader-column access intervals** in the target enclave (a list of disjoint seq ranges). 2. Intersect with the query's `seq` / filter range to obtain the **served seq set** and reject if empty. 3. For subscriptions, split into a historical phase and a live tail; the live tail terminates on RBAC events that close the requester's open-ended interval. ##### Access Intervals For a `(requester, enclave, reader)` triple, where `reader` is a `readers` entry from the enclave's manifest (rbac/manifest.md §Section Details / Reader Retention), the **access interval list** `I(requester, reader)` is a sorted list of disjoint half-open seq intervals `[[s0, e0), [s1, e1), …]`: | `reader.retention` | `reader.type` | `I(requester, reader)` | | --------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `"current"` (default) | State / trait | `[[0, ∞)]` if requester's CURRENT bitmask contains `reader.type`; else `[]`. | | `"snapshot"` | State / trait | Replayed over CT as defined in **Computing Snapshot Intervals** below. | | n/a | `Self` / `Sender` / `Public` | `[[0, ∞)]` — intervals span all seqs; the per-event Context predicate is checked at serve time, not in the interval set. | The **served seq set** for the requester is then: ``` served_seq_set(requester) = ⋃ I(requester, r) for each r ∈ manifest.readers ``` with the per-event constraint that an event `e` is served only if `e.type ∈ r.reads` for at least one reader `r` whose interval contains `e.seq` AND, for Context readers, whose per-event predicate (`Sender`: `e.from == requester`; `Self`: `e.from == requester` for the event-as-actor case; `Public`: always true) holds. The query's served events are then `{ e ∈ enclave.events : e.seq ∈ served_seq_set(requester) AND e matches query.filter AND the per-event Context constraint holds }`. ##### Computing Snapshot Intervals For `retention: "snapshot"` with column `c` (a State or trait), the node replays the requester's RBAC history in the enclave's CT: ``` 1. Initialize bitmask = init_bitmask(requester) // per manifest.init, or 0 if absent open = (c ∈ bitmask) if open: emit interval-start at seq = 0 2. For each event e in CT, in seq order, where e affects requester: (i.e. Move with target == requester, Grant / Revoke with target == requester, Transfer with operator == requester OR target == requester) apply e per rbac/events.md → new_bitmask new_open = (c ∈ new_bitmask) if new_open AND NOT open: emit interval-start at seq = (e.seq + 1) if open AND NOT new_open: emit interval-end at seq = (e.seq + 1) bitmask, open = new_bitmask, new_open 3. If open at end-of-CT: the last interval is open-ended ([…, ∞)). ``` Resulting intervals are half-open `[start, end)`, sorted, disjoint, with the last entry's end either a concrete seq (column was lost) or `∞` (column is currently held). For Sub-keyed Transfer (rbac-v2 §8.4) where the same event mutates both operator and target, the same seq advance applies to both the operator's and target's interval lists. The State enum value vs trait flag distinction does NOT matter for interval computation: both are bits in the same 256-bit RBAC bitmask (rbac-v2 §1.1); the interval ends the moment the bit is no longer set. **Caching.** Implementations SHOULD cache `I(requester, reader)` per `(enclave_id, requester_id_pub, reader_index)`.The cache MUST be invalidated when any of the following events finalize: Move with `target == requester`, Grant / Revoke with `target == requester`, Transfer with `operator == requester` OR `target == requester`.The SMT update path already detects these (the bitmask actually changed); the cache invalidation hook can ride on the same trigger. ##### Query Phases A query has two distinct phases, evaluated separately: | Phase | Seq range | Authorized iff | | -------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | **Historical** | `[query_start, current_seq)` at query open | `query_seq_range ∩ served_seq_set(requester) ≠ ∅` | | **Live tail** | `[current_seq, ∞)` (only if the query has no `seq.end_before` cap or it exceeds `current_seq`) | At least one reader `r` exists where `I(requester, r)` contains an open-ended `[…, ∞)` entry | The two phases produce distinct close-reason outcomes: * Historical phase empty → `Closed { reason: "no_access" }`. Permanent for this query. * Historical phase non-empty but live phase rejected → serve the historical events, then emit `Closed { reason: "live_access_ended" }` immediately after the `EOSE`. * Both phases empty (rare: every reader is `current` and requester has nothing) → `Closed { reason: "access_revoked" }` at query open, before any `EOSE`. * Live phase was streaming and an RBAC change closes the requester's last open-ended interval → emit `Closed { reason: "live_access_ended" }` (the same code, but mid-stream rather than at open). `access_revoked` is the "the requester has no read access in this enclave at all" case; `no_access` is "the requester has some access but not in the slice asked for"; `live_access_ended` is "the requester had access and lost it (at open or mid-stream)." Implementations MUST emit the most specific reason. ##### Worked Examples Assume the group manifest: ```json "readers": [ { "type": "MEMBER", "reads": "*", "retention": "snapshot" } ] ``` and requester Alice's RBAC history in the enclave: * seq=10: `Move(OUTSIDER → MEMBER, target=Alice)` by admin * seq=400: `Move(MEMBER → OUTSIDER, target=Alice)` by admin (kick) * seq=520: `Move(OUTSIDER → MEMBER, target=Alice)` by admin (re-invite) Then `I(Alice, MEMBER) = [[11, 401), [521, ∞)]`. Cases: * Alice opens a fresh subscription with `seq.start_after: 0`: historical `[1, current_seq)` intersects both intervals; serve all events in `[11, 401) ∪ [521, current_seq)`. Live tail authorized (open-ended interval exists). Subscription stays open. * Alice (currently MEMBER) is kicked at seq=900 while subscribed: the second interval closes at `[521, 901)`. The node emits `Closed { reason: "live_access_ended" }`. She keeps the historical events delivered before. * Alice (kicked, currently OUTSIDER) re-subscribes asking for `seq.start_after: 500, seq.end_before: 600`: range is `[501, 600)`. Intersected with `I = [[11, 401), [521, …)]` gives `[521, 600)`. Historical serves; the seq cap means there is no live phase, no `live_access_ended` ever fires. * Alice (kicked) asks for `seq.start_after: 600, seq.end_before: 800`: range is `[601, 800)`. Empty intersection with `I` (her second interval starts at 521 and ended at 401 — wait no, scratch — assume here she's mid-gap with `I = [[11, 401)]` because she has not yet been re-invited). Empty intersection → `Closed { reason: "no_access" }`. * Alice (was never in this group, current bitmask 0) opens any subscription: `I(Alice, MEMBER) = []` under snapshot, and no other reader applies. Both phases empty → `Closed { reason: "access_revoked" }`. For `retention: "current"`, the same Alice mid-kick gets `I = []` immediately (current bitmask lacks MEMBER) — `access_revoked` on open, even for queries against seqs she could read under snapshot semantics. This is the design point of the field. ##### Performance Bound (Non-Normative) Per-query cost is `O(R + I + Q)`: * `R` = manifest readers (typically \< 10) * `I` = events targeting `requester` in the CT — Move / Grant / Revoke / Transfer (typically \< 10 per identity per enclave) * `Q` = events served (bounded by `query.limit`) The cached interval list per `(requester, enclave, reader)` is on the order of tens of bytes; cache hits are O(R + Q). The "snapshot" path is strictly no more expensive than a per-event SMT lookup in the legacy `current`-only path — both already require checking authorization against state, and snapshot collapses that to one interval test per query instead of per event. ##### Self-Authored Content (Sender Context) A `readers` entry with `type: "Sender"` is satisfied per-event by `event.from == requester`. The node serves these regardless of the requester's State / trait history. A kicked former MEMBER who authored messages during their membership window MUST be able to query and recover those messages via the `Sender` reader, even when the corresponding MEMBER reader is `retention: "current"` and currently denies.This preserves the requester's ability to export their own content irrespective of RBAC outcome. A manifest that wants to disable this property MUST omit the `Sender` reader entry — there is no separate switch. #### Connection Lifecycle **Termination conditions:** * All subscriptions closed by client * All sessions expired * All access revoked * Client disconnects **Multi-key support:** * One connection, multiple identities (`from`) * Each identity has own session * Session expiry only affects that identity's subscriptions #### HTTP vs WebSocket | Aspect | HTTP | WebSocket | | ------- | ----------------- | --------------------------------- | | Query | One-time response | Subscribe + live updates | | sub\_id | N/A | Node-assigned, or caller-supplied | | Commit | Receipt | Receipt | | Session | Per-request | Cached per connection | | State | Stateless | Subscriptions | *** *** ### Proof Retrieval API Endpoints for retrieving cryptographic proofs. Used by clients to verify events and state. **Access Control:** | Endpoint | Access | | ----------- | --------------------- | | STH | Public | | Consistency | Public | | Inclusion | Requires R permission | | State | Requires R permission | #### STH (Signed Tree Head) **GET** `/:enclave/sth` Returns the current signed tree head. Public endpoint for auditing. **Response (200 OK):** ```json { "t": 1706000000000, "ts": 1000, "r": "", "sig": "" } ``` See [proof.md](/spec/kernel/ct) for STH structure and verification. #### Consistency Proof **GET** `/:enclave/consistency?from=&to=` Returns consistency proof between two tree sizes. Public endpoint for auditing. | Parameter | Type | Description | | --------- | ---- | ---------------------------------- | | from | uint | Earlier tree size | | to | uint | Later tree size (omit for current) | **Response (200 OK):** ```json { "ts1": 500, "ts2": 1000, "p": ["", ...] } ``` See [proof.md](/spec/kernel/ct) for verification algorithm. **Errors:** | Code | HTTP | Description | | ------------------- | ---- | --------------------------- | | `INVALID_RANGE` | 400 | from > to or invalid values | | `ENCLAVE_NOT_FOUND` | 404 | Enclave doesn't exist | #### Inclusion Proof **POST** `/inclusion` Returns inclusion proof for a bundle. Requires R permission. **Request:** ```json { "type": "Inclusion_Proof", "enclave": "", "from": "", "content": "" } ``` **Content (plaintext):** ```json { "session": "", "leaf_index": 42 } ``` **Response (200 OK):** ```json { "type": "Response", "content": "" } ``` **Response Content (plaintext):** ```json { "ts": 1000, "li": 42, "p": ["", ...], "events_root": "", "state_hash": "" } ``` | Field | Description | | ------------ | ---------------------------------- | | ts | Tree size when proof was generated | | li | Leaf index | | p | Inclusion proof path | | events\_root | Merkle root of event IDs in bundle | | state\_hash | SMT root after bundle | See [proof.md](/spec/kernel/ct) for verification algorithm. **Errors:** | Code | HTTP | Description | | ------------------- | ---- | --------------------------------- | | `INVALID_SESSION` | 400 | Session token verification failed | | `SESSION_EXPIRED` | 401 | Session token expired | | `UNAUTHORIZED` | 403 | No read permission | | `LEAF_NOT_FOUND` | 404 | Leaf index out of range | | `ENCLAVE_NOT_FOUND` | 404 | Enclave doesn't exist | #### Bundle Membership Proof **POST** `/bundle` Returns bundle membership proof for an event. Requires R permission. **Request:** ```json { "type": "Bundle_Proof", "enclave": "", "from": "", "content": "" } ``` **Content (plaintext):** ```json { "session": "", "event_id": "" } ``` **Response Content (plaintext):** ```json { "leaf_index": 42, "ei": 2, "s": ["", ...], "events_root": "" } ``` | Field | Description | | ------------ | ------------------------------------ | | leaf\_index | Bundle's position in CT tree | | ei | Event index within bundle | | s | Siblings for bundle membership proof | | events\_root | Merkle root of event IDs in bundle | See [proof.md](/spec/kernel/ct) for verification algorithm. **Errors:** | Code | HTTP | Description | | ------------------- | ---- | --------------------------------- | | `INVALID_SESSION` | 400 | Session token verification failed | | `SESSION_EXPIRED` | 401 | Session token expired | | `UNAUTHORIZED` | 403 | No read permission | | `EVENT_NOT_FOUND` | 404 | Event doesn't exist | | `ENCLAVE_NOT_FOUND` | 404 | Enclave doesn't exist | #### State Proof **POST** `/state` Returns SMT proof for a key. Requires R permission. **Request:** ```json { "type": "State_Proof", "enclave": "", "from": "", "content": "" } ``` **Content (plaintext):** ```json { "session": "", "namespace": "rbac" | "event_status", "key": "", "tree_size": 1000 } ``` | Field | Required | Description | | ---------- | -------- | ------------------------------------------------------ | | session | Yes | Session token | | namespace | Yes | `"rbac"` or `"event_status"` | | key | Yes | Identity public key (rbac) or event ID (event\_status) | | tree\_size | No | Bundle index for historical state (omit for current) | **Response Content (plaintext):** ```json { "k": "", "v": "", "b": "", "s": ["", ...], "state_hash": "", "leaf_index": 999 } ``` | Field | Description | | ----------- | -------------------------------------------- | | k, v, b, s | SMT proof fields (see proof.md) | | state\_hash | SMT root hash for verification | | leaf\_index | Bundle index (0-based) containing this state | **Verification Flow:** To fully verify a state proof is authentic and from the requested tree position: 1. **Verify SMT proof** against `state_hash` (see proof.md) 2. **Request CT inclusion proof** for `leaf_index` via `POST /inclusion` 3. **Verify CT inclusion:** Recompute leaf as `H(0x00, events_root, state_hash)` and verify against signed CT root 4. **Verify STH signature** to authenticate the CT root This binds the SMT state to a specific, signed tree checkpoint. See [proof.md](/spec/kernel/ct) for detailed algorithms. **Errors:** | Code | HTTP | Description | | --------------------- | ---- | --------------------------------- | | `INVALID_SESSION` | 400 | Session token verification failed | | `SESSION_EXPIRED` | 401 | Session token expired | | `INVALID_NAMESPACE` | 400 | Unknown namespace | | `UNAUTHORIZED` | 403 | No read permission | | `TREE_SIZE_NOT_FOUND` | 404 | Historical state not available | | `ENCLAVE_NOT_FOUND` | 404 | Enclave doesn't exist | #### Batched State Proof **POST** `/state-batch` Returns SMT proofs for many keys against a single `state_hash`, in one round trip. Required by the cache-reconciliation flow (§Subscribe Replay Semantics): on rehydrate-from-archive a client checks `eventstatus:` for every cached message, which is O(N) leaves; the batch endpoint collapses those N lookups into one request bound to one signed root. Requires R permission. Cache-reconciliation is the load-bearing caller; `rbac` keys can be batched too (e.g. computing a per-identity capability matrix on first connect). **Request:** ```json { "type": "State_Proof_Batch", "enclave": "", "from": "", "content": "" } ``` **Content (plaintext):** ```json { "session": "", "namespace": "rbac" | "event_status", "keys": ["", "", ...], "tree_size": 1000 } ``` | Field | Required | Description | | ---------- | -------- | ---------------------------------------------------------------------------------------------------- | | session | Yes | Session token | | namespace | Yes | All keys MUST share one namespace; cross-namespace batches MUST be rejected with `INVALID_NAMESPACE` | | keys | Yes | Array of `` SMT keys to look up; max 1000 per request | | tree\_size | No | Bundle index for historical state (omit for current) | **Limits:** | Field | Max | | ------ | ---- | | `keys` | 1000 | A node MUST reject a batch larger than the limit with `BATCH_TOO_LARGE`. The 1000-key cap matches the `limit` cap on Query / Pull pagination (§Filter Limits) so a single batched-proof round trip covers a single page of cached events without further chunking. **Response Content (plaintext):** ```json { "state_hash": "", "leaf_index": 999, "proofs": [ { "k": "", "v": "", "b": "", "s": ["", ...] }, { "k": "", "v": "", "b": "", "s": ["", ...] } ] } ``` | Field | Description | | ----------- | --------------------------------------------------------------------------------------------------------------------------------- | | state\_hash | SMT root hash; ONE root for the whole batch, MUST be the same root every per-key proof verifies against | | leaf\_index | Bundle index (0-based) containing this state | | proofs | Array, same length and order as request `keys`; each entry has the same `k`/`v`/`b`/`s` shape as the single-key `/state` response | The batch response MUST satisfy two invariants: 1. **Same root for every proof.** Every entry in `proofs` is an SMT proof against the SAME `state_hash`. The client verifies all entries against the single returned root. 2. **Order preservation.** The response's `proofs` array MUST be the same length as the request's `keys` array, and `proofs[i]` MUST be the proof for `keys[i]`. Clients MAY rely on positional alignment. **Verification Flow:** Same as `/state`, applied once to the shared `state_hash`: 1. For each `proofs[i]`, verify SMT proof against `state_hash` (see proof.md). 2. Request CT inclusion proof for `leaf_index` via `POST /inclusion` (ONCE; the same `leaf_index` covers every per-key proof in the batch). 3. Verify CT inclusion as in the single-key flow. 4. Verify STH signature. The savings: one CT-inclusion + one STH-signature verification per batch instead of per key. **Cache-reconciliation usage:** ```json { "type": "State_Proof_Batch", "content": { "session": "...", "namespace": "event_status", "keys": ["", "", ""] } } ``` For each `proofs[i]`, the client checks the returned `v`: `null` (absent) or `0x00…` (active) means the event is live; `0x01` (deleted) means the event is dropped from the cache. The membership proof is verified against `state_hash` once. **Errors:** | Code | HTTP | Description | | --------------------- | ---- | ------------------------------------------------------- | | `INVALID_SESSION` | 400 | Session token verification failed | | `SESSION_EXPIRED` | 401 | Session token expired | | `INVALID_NAMESPACE` | 400 | Unknown namespace, or batch carried multiple namespaces | | `BATCH_TOO_LARGE` | 400 | `keys.length` exceeds the 1000 limit | | `UNAUTHORIZED` | 403 | No read permission | | `TREE_SIZE_NOT_FOUND` | 404 | Historical state not available | | `ENCLAVE_NOT_FOUND` | 404 | Enclave doesn't exist | *** *** ### Webhook Delivery Node delivers Push messages via HTTPS POST to registered endpoints. See [Appendix: Push/Notify](#push-notify) for design rationale. #### Grant with Push Endpoint A Grant event with P (push) ops and an `endpoint` field registers a webhook endpoint. See spec.md for event structure and content fields. Node maintains queue per `(identity, url)`: * Aggregates events from all enclaves **on this node** where identity has P/N permission * Tracks single `push_seq` per queue * All enclaves in a Push delivery share the node's `seq_priv` for encryption **Multiple Endpoints:** An identity MAY register multiple webhook endpoints via separate Grant events with different `endpoint` values.Each `(identity, url)` pair has its own `push_seq` and event queue. Events are delivered independently to each endpoint. To replace an endpoint, submit a new Grant with the new URL; both endpoints remain active until explicitly revoked. **Ordering Guarantee:** Events within each enclave (`push.enclaves[N].events`) are ordered by sequence number ascending. Events from different enclaves have no guaranteed relative ordering. If global ordering is required, use per-enclave `seq` to reconstruct the timeline. **Endpoint Transition Atomicity:** When a Grant changes the webhook URL (new Grant with the same `trait`, different URL): 1. Old and new endpoints operate as separate queues (no events lost) 2. Old endpoint receives events finalized before the Grant 3. New endpoint receives events finalized after the Grant 4. To stop delivery to old endpoint, explicitly Revoke the trait #### Delivery Flow ``` 1. Enclave submits Grant event with P ops + endpoint (grants trait + registers url) 2. Node adds enclave to (identity, url) queue if not exists 3. On new event, node checks the trait's P/N permissions 4. Node aggregates events into (identity, url) queue 5. Node periodically POSTs Push to url 6. Node increments push_seq ``` #### Push Webhook delivery containing full events (P permission) and/or event IDs (N permission). **HTTP Request:** ``` POST Content-Type: application/json ``` **Body:** ```json { "type": "Push", "from": "", "to": "", "url": "", "content": "" } ``` | Field | Type | Description | | ------- | ------ | -------------------- | | type | string | Always `"Push"` | | from | hex64 | Sequencer public key | | to | hex64 | Recipient identity | | url | string | Webhook URL | | content | string | Encrypted payload | **Content (plaintext):** ```json { "push_seq": 130, "push": { "enclaves": [ { "enclave": "", "events": [Event, ...] } ] }, "notify": { "enclaves": [ { "enclave": "", "seq": 150 } ] } } ``` | Field | Type | Description | | -------------------------- | ------ | ------------------------------------------ | | push\_seq | uint | Sequence number per (identity, url) | | push | object | Full events for enclaves with P permission | | push.enclaves\[].enclave | hex64 | Enclave ID | | push.enclaves\[].events | array | Array of Event objects | | notify | object | Latest seq for enclaves with N permission | | notify.enclaves\[].enclave | hex64 | Enclave ID | | notify.enclaves\[].seq | uint | Latest sequence number in this enclave | Either `push` or `notify` can be omitted if empty. **Encryption:** See [Encryption](#encryption). **Delivery semantics:** * At-least-once delivery * Exponential backoff on failure * Recipient MUST dedupe by `event.id` (for push) or track last synced seq (for notify) **Expected response:** `200 OK` #### Pull Fallback If webhook delivery fails, recipient can pull missed batches. **Request:** ```json { "type": "Pull", "enclave": "", "from": "", "content": "" } ``` **Note:** `enclave` is required in the outer request for signer key derivation during decryption. Any enclave on the node where the identity has a registered webhook can be used. **Content (plaintext):** ```json { "session": "", "url": "", "push_seq": { "start_after": 5, "end_at": 7 }, "enclave": "" } ``` | Field | Type | Required | Description | | --------- | ------------------------- | -------- | -------------------------------- | | session | hex136 | Yes | Session token | | url | string | Yes | Registered webhook endpoint | | push\_seq | uint, \[uint,.], or Range | Yes | Batch sequence(s) to retrieve | | enclave | hex64 | No | Filter results to single enclave | **push\_seq formats:** * Single: `6` — returns batch 6 * Array: `[6, 7, 8]` — returns batches 6, 7, 8 * Range: `{ "start_after": 5, "end_at": 7 }` — returns batches 6, 7 **Response:** ```json { "type": "Response", "content": "" } ``` **Content (plaintext):** ```json [ { "push_seq": 6, "push": { "enclaves": [...] }, "notify": { "enclaves": [...] } }, { "push_seq": 7, "push": { "enclaves": [...] }, "notify": { "enclaves": [...] } } ] ``` Array of batches. Each batch has same structure as Push delivery content. **Range Handling:** When `push_seq` is a Range: * `start_after` / `end_at` are inclusive/exclusive as documented * Results are ordered by `push_seq` ascending * If `enclave` filter is provided, only events from that enclave are included in each batch * If `enclave` is omitted, all enclaves in the original batch are included **Retry Policy:** Node retries webhook delivery with exponential backoff per spec.md. After max retries exhausted, batch is moved to dead-letter queue. Recipient can recover via Pull fallback. **Encryption:** Same as Query. See [Encryption](#encryption). *** *** ### Registry DataView API Registry-specific endpoints (`/nodes/:seq_pub`, `/enclaves/:enclave_id`, `/identity/:id_pub`) are specified in [`enclaves/registry.md` §DataView API](/spec/app/enclaves/registry#dataview-api). They are NOT part of the generic node API; only nodes hosting the Registry enclave expose them. *** ### Snapshot Endpoints Two endpoints move complete enclave state between nodes. They are the wire protocol for the migration flow specified in [`migration.md`](/spec/node/migration), and they're equally useful for backup, archival, and audit handoff. The byte format (`.enc`) is normative; see [`migration.md` §Enclave Snapshot Format](/spec/node/migration#enclave-snapshot-format) for the layout, kernel-version compatibility rules, and verification procedure. This section specifies the HTTP surface. #### GET /enclaves/:id/snapshot Download the complete enclave state. **Path parameters:** | Param | Type | Description | | ----- | ----- | ----------- | | `id` | hex64 | Enclave id | **Request headers:** | Header | Required | Description | | --------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------- | | `Authorization` | Implementation-defined | Most production deployments require an auth token; a public snapshot endpoint is the exception, not the default. | **Response (200 OK):** | Header | Value | | --------------------- | -------------------------------------------------- | | `Content-Type` | `application/octet-stream` | | `Content-Length` | `32 + payload_size + 32` | | `Content-Disposition` | OPTIONAL `attachment; filename=".enc"` | Body: the raw `.enc` bytes (header || payload || footer; see migration spec). **Errors:** | Code | HTTP | Description | | ---------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `ENCLAVE_NOT_FOUND` | 404 | This node does not host the requested enclave. | | `ENCLAVE_PAUSED` | 409 | Enclave is paused; some implementations refuse snapshot of a paused enclave to avoid capturing transient state. Caller MAY retry after Resume. | | `SNAPSHOT_UNSUPPORTED` | 501 | This node does not support snapshot export (e.g., proxy-only nodes). | #### POST /enclaves/:id/restore Bootstrap a fresh enclave on this node from a `.enc` payload. The receiving node MUST be able to host the enclave id and MUST NOT already have state for it. \::: extension-point id=enclave-restore-overwrite-mechanism class=local\_policy reason: an overwrite path for non-fresh restore is out of scope for the core protocol but operators can add one (with their own consent and authorization rules) An explicit overwrite mechanism for restoring on top of existing enclave state is out of scope for the core protocol. Operators can provide one with deployment-specific authorization; the wire shape of the restore call is unchanged. \::: **Path parameters:** | Param | Type | Description | | ----- | ----- | ------------------------------------------- | | `id` | hex64 | Enclave id the snapshot will be restored as | **Request headers:** | Header | Required | Description | | ---------------- | ---------------------- | ---------------------------------------------------------------------------------------------- | | `Content-Type` | Yes | `application/octet-stream` | | `Content-Length` | Yes | Total bytes; MUST equal `32 + payload_size + 32` | | `Authorization` | Implementation-defined | Restore is a privileged operation; production deployments typically require Owner credentials. | Body: the raw `.enc` bytes. **Response (200 OK):** the enclave is live. ```json { "type": "Restored", "id": "", "kernel_ver": "", "events": 1234, "last_seq": 1233, "ct_root": "" } ``` **Errors:** | Code | HTTP | Description | | -------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `BAD_SNAPSHOT_MAGIC` | 400 | Header magic ≠ `b"ENC\x01"`. | | `UNKNOWN_LAYOUT_VERSION` | 400 | `layout_ver` is not 1. | | `SNAPSHOT_FOOTER_MISMATCH` | 400 | Computed sha256 footer does not match stored. | | `KERNEL_VERSION_MISMATCH` | 400 | `kernel_ver` falls outside the compatibility window (always: differing major; pre-1.0: any difference). The error body MUST include both producer and restorer versions. | | `SNAPSHOT_TOO_LARGE` | 413 | `payload_size` exceeds the node's configured limit. | | `SELF_TEST_FAILED` | 422 | Post-restore self-test rejected. | | `ENCLAVE_ALREADY_EXISTS` | 409 | This node already hosts an enclave with this id; restoration would overwrite. | | `RESTORE_UNSUPPORTED` | 501 | This node does not support restore. | **`KERNEL_VERSION_MISMATCH` body shape:** ```json { "type": "Error", "code": "KERNEL_VERSION_MISMATCH", "producer": "0.12.3", "restorer": "0.13.0", "message": "snapshot was written by kernel 0.12.3; this node runs 0.13.0; pre-1.0 kernels do not accept any version drift" } ``` #### Migration Flow Using These Endpoints For Peaceful Handoff: ``` 1. Owner submits Migrate commit to old node. 2. Old node finalizes Migrate; closes current bundle. 3. Old node responds to GET /enclaves//snapshot with .enc bytes. 4. New node receives bytes; calls POST /enclaves//restore on itself (or the orchestrator POSTs the bytes to the new node directly). 5. New node verifies header + footer + kernel_ver + self-test, then verifies that the Migrate event in the restored log matches the commit it has (sequencer transition is real). 6. New node becomes the sequencer; sequencing resumes at prev_seq + 2. ``` For Forced Takeover, the Backup holds the `.enc` bytes (produced by an earlier `GET /snapshot` while the old node was alive); the new node verifies + restores + finalizes Migrate as the last event. *** ### Push/Notify #### Problem A DataView server can have P/N permissions across hundreds of enclaves on the same node. Naive approach — one HTTP request per event per enclave — creates massive overhead. #### Solution Node aggregates events into a single queue per `(identity, url)` pair. ``` Enclave A ──┐ Enclave B ──┼──► Queue (identity, url) ──► Single POST to url Enclave C ──┘ ``` #### Why This Is Efficient | Aspect | Benefit | | -------------------- | ----------------------------------------------------- | | Batching | One POST delivers events from many enclaves | | Single sequence | One `push_seq` for gap detection across all enclaves | | Periodic aggregation | Node batches events instead of instant push per event | | Unified message | Push and Notify combined in single delivery | #### Pull Fallback Efficiency When webhook delivery fails, recipient uses Pull to recover. The single sequence number makes this extremely efficient: **Without unified sequence (naive):** ``` Recipient must track: - Enclave A: last_seq = 42 - Enclave B: last_seq = 17 - Enclave C: last_seq = 103 ...hundreds of enclaves... Recovery requires: - One Query per enclave - Complex state management - N round trips for N enclaves ``` **With unified push\_seq:** ``` Recipient tracks: - push_seq = 130 Recovery requires: - One Pull request with Range - Returns all missed batches - Single round trip ``` **Gap detection is trivial:** * Received push\_seq 5, then 8 → the gap implies missed 6, 7 * Pull with `push_seq: { "start_after": 5, "end_at": 7 }` returns both batches in one request This is why the queue is per `(identity, url)` not per enclave — it enables O(1) state tracking regardless of subscription count. #### Push vs Notify (within same message) A single Push delivery contains both: * `push.enclaves[]` — full events for enclaves with P permission * `notify.enclaves[]` — latest seq for enclaves with N permission | | push | notify | | -------- | -------------- | ------------------ | | Content | Full events | Latest seq only | | Use case | Real-time sync | Lightweight alerts | #### Permissions | Permission | What it grants | | ---------- | ----------------------------------------------- | | P (Push) | Receive full event content in `push.enclaves[]` | | N (Notify) | Receive latest seq in `notify.enclaves[]` | | R (Read) | Query full event content from enclave | **Important:** P delivers full content directly. N only delivers the latest seq; fetching full events requires R permission on that enclave. **Example:** ``` Identity has: - Enclave A: P permission → full events in push.enclaves[] - Enclave B: N permission → seq in notify.enclaves[] - Enclave C: N + R permissions → seq in notify.enclaves[], can Query full events ``` If the requester only has N (no R), new events are observable but their content is not readable. This is useful for: * Mobile push notifications (just alert user) * Protocol-level sync signals (trigger sync via other means) * Audit logging (record that activity occurred) #### Typical Pattern 1. Receive Push with notify.enclaves\[].seq 2. Query events with `{ "seq": { "start_after": last_synced_seq } }` (requires R permission) 3. Full events arrive directly in push.enclaves\[] if the requester has P permission #### Node Internal Table ``` Key: (identity, url) Value: { push_seq, enclaves[] } ``` For each enclave, node tracks which events to include based on the identity's State + trait bitmask (and its P / N permissions on each event type). *** *** ### Encryption Client-node communication is encrypted using ECDH + XChaCha20-Poly1305. #### Overview | Context | Client Key | Node Key | HKDF Label | | --------------- | -------------- | --------- | ---------------- | | Query request | signer\_priv | seq\_pub | `"enc:query"` | | Query response | signer\_priv | seq\_pub | `"enc:response"` | | Pull request | signer\_priv | seq\_pub | `"enc:query"` | | Pull response | signer\_priv | seq\_pub | `"enc:response"` | | WebSocket event | signer\_priv | seq\_pub | `"enc:response"` | | Push delivery | to (recipient) | seq\_priv | `"enc:push"` | #### Query Encryption (Client → Node) Client encrypts query content: ``` 1. Derive signer from session: t = sha256(session_pub || seq_pub || enclave) signer_priv = session_priv + t (mod n) 2. Compute shared secret: shared = ECDH(signer_priv, seq_pub) 3. Derive key and encrypt: key = HKDF(shared, "enc:query") ciphertext = XChaCha20Poly1305_Encrypt(key, plaintext) ``` Node decrypts: ``` 1. Derive signer_pub from session_pub: t = sha256(session_pub || seq_pub || enclave) signer_pub = session_pub + t * G 2. Compute shared secret: shared = ECDH(seq_priv, signer_pub) 3. Decrypt: key = HKDF(shared, "enc:query") plaintext = XChaCha20Poly1305_Decrypt(key, ciphertext) ``` #### Response Encryption (Node → Client) Node encrypts response (HTTP and WebSocket): ``` shared = ECDH(seq_priv, signer_pub) key = HKDF(shared, "enc:response") ciphertext = XChaCha20Poly1305_Encrypt(key, plaintext) ``` Client decrypts: ``` shared = ECDH(signer_priv, seq_pub) key = HKDF(shared, "enc:response") plaintext = XChaCha20Poly1305_Decrypt(key, ciphertext) ``` #### Push Encryption (Node → Webhook) Node encrypts webhook payload: ``` shared = ECDH(seq_priv, to) key = HKDF(shared, "enc:push") ciphertext = XChaCha20Poly1305_Encrypt(key, plaintext) ``` Recipient decrypts: ``` shared = ECDH(recipient_priv, seq_pub) key = HKDF(shared, "enc:push") plaintext = XChaCha20Poly1305_Decrypt(key, ciphertext) ``` #### Primitives | Primitive | Specification | | --------- | ------------------ | | ECDH | secp256k1 | | HKDF | HKDF-SHA-256 | | AEAD | XChaCha20-Poly1305 | **HKDF Parameters:** ``` IKM = ECDH shared secret (32 bytes) salt = empty (no salt) info = UTF-8 encoded label string, NO null terminator e.g., "enc:query" = 9 bytes: 0x65 0x6E 0x63 0x3A 0x71 0x75 0x65 0x72 0x79 L = 32 bytes (256-bit key) ``` **XChaCha20-Poly1305 Nonce:** ``` nonce = random 24 bytes, prepended to ciphertext ciphertext_wire = nonce || ciphertext || tag ``` Recipient extracts first 24 bytes as nonce before decryption. **Minimum Length:** `ciphertext_wire` MUST be at least 40 bytes (24-byte nonce + 16-byte Poly1305 tag).Shorter values indicate malformed or truncated ciphertext — implementations MUST reject with `DECRYPT_FAILED`. *** *** ### Error Codes #### HTTP Status Mapping | HTTP | Category | | ---- | -------------------------------- | | 400 | Client error (malformed request) | | 401 | Authentication error | | 403 | Authorization error | | 404 | Not found | | 409 | Conflict | | 410 | Gone | | 429 | Rate limited | | 500 | Internal server error | | 502 | Upstream unreachable | | 503 | Service temporarily unavailable | #### Error Response Format ```json { "type": "Error", "code": "", "message": "" } ``` #### Error Codes | Code | HTTP | Description | | -------------------- | ---- | ------------------------------------ | | `INVALID_COMMIT` | 400 | Malformed commit structure | | `INVALID_HASH` | 400 | Hash doesn't match CBOR encoding | | `INVALID_SIGNATURE` | 400 | Signature verification failed | | `INVALID_QUERY` | 400 | Malformed query structure | | `INVALID_SESSION` | 400 | Session token verification failed | | `INVALID_FILTER` | 400 | Malformed filter | | `DECRYPT_FAILED` | 400 | Cannot decrypt content | | `SESSION_EXPIRED` | 401 | Session token expired | | `EXPIRED` | 400 | Commit `exp` \< current time | | `UNAUTHORIZED` | 403 | Insufficient RBAC permissions | | `ENCLAVE_PAUSED` | 403 | Enclave is paused | | `DUPLICATE` | 409 | Commit hash already processed | | `NODE_NOT_FOUND` | 404 | Node not registered | | `ENCLAVE_NOT_FOUND` | 404 | Enclave not registered | | `IDENTITY_NOT_FOUND` | 404 | Identity not registered | | `ENCLAVE_TERMINATED` | 410 | Enclave is terminated | | `ENCLAVE_MIGRATED` | 410 | Enclave has migrated to another node | | `RATE_LIMITED` | 429 | Too many requests | | `INTERNAL_ERROR` | 500 | Internal server error | **WebSocket Errors:** WebSocket errors use the same JSON format as HTTP errors. HTTP status codes do not apply to WebSocket; use the application-level `code` field instead. *** *** ### Rejection Error Envelope When a node rejects a commit or request, it MUST return an error response: ```json { "type": "Error", "code": "", "message": "", ...additional fields specific to the error... } ``` | Field | Required | Description | | ------------ | -------- | ------------------------------------------------ | | type | Yes | Always `"Error"` | | code | Yes | Machine-readable error code (`UPPER_SNAKE_CASE`) | | message | Yes | Human-readable description | | *additional* | No | Error-specific context (see below) | The canonical code strings and HTTP statuses are defined in §Error Codes. The table below lists additional RBAC and lifecycle rejection codes whose context fields are not already described by the canonical registry. **Additional Rejection Codes:** | Code | Context Fields | Description | | ---------------------------- | ------------------------ | --------------------------------------------------------- | | `UNAUTHORIZED` | — | Sender lacks required permission | | `STATE_MISMATCH` | `expected`, `actual` | Move target's current State does not match `from` | | `RANK_INSUFFICIENT` | — | Operator's rank is not strictly less than target's rank | | `INVALID_STATE_FOR_GRANT` | — | Target's State is not in Grant/Revoke scope | | `INVALID_STATE_FOR_TRANSFER` | — | Target's State is not in Transfer scope | | `INVALID_TRANSFER_TARGET` | — | Transfer target is the operator (self-transfer) | | `TRAIT_ALREADY_HELD` | — | Transfer target already holds the trait | | `INVALID_LIFECYCLE_STATE` | — | Lifecycle transition not valid from current state | | `AC_BUNDLE_FAILED` | `failed_index`, `reason` | AC\_Bundle operation failed at index | | `EVENT_DELETED` | — | Target event has been deleted (Self:U/D on deleted event) | | `ENCLAVE_PAUSED` | — | Enclave is paused, only Resume/Terminate/Migrate accepted | | `ENCLAVE_TERMINATED` | — | Enclave is terminated, no events accepted | | `ENCLAVE_MIGRATED` | — | Enclave has migrated to another node | | `ENCLAVE_NOT_FOUND` | — | Enclave ID not found on this node | *** ## Zero-Knowledge Validity Proofs This document specifies the ZK validity-proof layer for the ENC protocol: a succinct proof that an SMT state transition produced by the node is **valid** under the RBAC rules, verifiable by a client in O(1) without replaying the event log or trusting the node. It complements the Merkle proofs in [proof.md](/spec/kernel/ct) (which prove *inclusion* against a root the client already trusts) by proving the *root transition itself* is well-formed. *** ### Table of Contents 1. [Motivation](#motivation) 2. [Proving System](#proving-system) 3. [SMT Hash: Poseidon2 over BN254](#smt-hash-poseidon2-over-bn254) 4. [The Transition Circuit](#the-transition-circuit) 5. [Multi-Leaf Transitions](#multi-leaf-transitions) 6. [Folding (Incremental Verifiable Computation)](#folding-incremental-verifiable-computation) 7. [Out of Circuit](#out-of-circuit) 8. [Verification Flow](#verification-flow) 9. [Status](#status) *** ### Motivation In the base protocol the node is an **untrusted sequencer**: it orders events, applies them to the Enclave State SMT (see [smt.md](/spec/kernel/smt)), and publishes roots committed via Certificate Transparency. A client that wants to trust a new `state_hash` otherwise has to either re-execute every event or trust the node. A ZK validity proof removes that choice. For each state-changing AC event the node (acting as **prover**) emits a proof that ``` old_root --[valid RBAC transition for this event]--> new_root ``` The client (acting as **verifier**) checks the proof against the public roots and is convinced the transition obeys every authorization rule — semiring evaluation, rank, state preconditions, leaf deletion — without seeing the witness or recomputing the tree. This makes ENC a **validity rollup** for RBAC state. The same transition relation is proven in two complementary forms: a **bounded** Groth16 proof of one transition (or a fixed-size batch), and a single **folded proof over the entire transition history** (Nova IVC — see [Folding](#folding-incremental-verifiable-computation)). The folded form gives O(1) verification of an *unbounded* chain, which the bounded form cannot. | Role | Who | Sees | | --------- | ---------------------------- | ------------------------------------------------ | | Sequencer | Node | Orders + applies events (untrusted) | | Prover | Node (or a delegated prover) | Full witness: pre-image leaves, siblings, schema | | Verifier | Client | Public inputs + proof only; O(1) | *** ### Proving System The validity relation `old_root → new_root` MUST be proven in two complementary modes, both over BN254; implementations MUST use the choices in the following table: | | Bounded | Unbounded (folding) | | ------------ | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | Proof system | **Groth16** over R1CS | **Nova IVC** (folding) + **Spartan** compression | | Scope | one transition, or a fixed batch chained through intermediate roots | an **incremental, unbounded** chain — the enclave's whole transition history | | Prover | one circuit, size O(N) in batch length | **O(1) work per step**, constant memory | | Verifier | O(1) (≈3 pairings) | succinct: O(log n)-size compressed proof on the transparent (IPA) path, O(1) with the optional KZG decider | | Curve(s) | BN254 (alt-bn128) | BN254 + **Grumpkin** (the second curve of the cycle) | Both modes use the **BN254 scalar field** `Fr` (`p = 0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000001`) as the field over which the SMT's Poseidon2 hash is defined, so SMT membership is proven natively in-circuit. The bounded Groth16 verifier is pairing-based and on-chain-friendly (EIP-196/197); the unbounded path folds each transition with Nova and compresses the final instance to one succinct proof. Trusted setup: Groth16 (and the optional KZG-based folding decider) require a per-circuit / universal setup; the **transparent** folding path (IPA/Pedersen commitments) requires none. Verifying keys are published and pinned by clients; a proof is valid only against the pinned key. *** ### SMT Hash: Poseidon2 over BN254 The Enclave State SMT leaf and node hashing use **Poseidon2 over BN254** (a SNARK-native hash), so the prover can prove SMT membership inside the circuit cheaply. The canonical instance is normative: | Parameter | Value | | | | ----------------------- | --------------------------------------------------------------------------------- | - | ----------- | | State width | `t = 3` (rate 2, capacity 1) — clean 2-to-1 compression | | | | S-box | `x^5` (`d = 5`) | | | | Full rounds | `R_F = 8` (4 + 4 external) | | | | Partial rounds | `R_P = 56` (internal) | | | | External matrix `M_ext` | MDS circulant `[[2,1,1],[1,2,1],[1,1,2]]` | | | | Internal matrix `M_int` | `[[2,1,1],[1,2,1],[1,1,3]]` (`1 + diag[1,1,2]` ones-form) | | | | Round constants | NUMS: \`RC\_g = Fr::from\_be\_bytes\_mod\_order(sha256("ENCv1-Poseidon2-BN254-t3" | | LE64(g)))\` | The round structure is the Poseidon2 form (distinct from Poseidon v1): an initial external linear layer, `R_F/2` external rounds, `R_P` internal rounds, `R_F/2` external rounds. `R_F`, `R_P`, `d`, and `M_int` match the official Poseidon2 BN256 reference (HorizenLabs/poseidon2). The round constants are an **intentional NUMS deviation** from the reference Grain-LFSR — a nothing-up-my-sleeve choice for trivial cross-implementation reproducibility; security rests on the (matching) round structure and matrices, not on the specific constants. The full instance, including the materialized constants, is the cross-language contract and MUST be embedded, never re-derived per implementation. #### Hash modes Implementations MUST compute each hash mode per the following equations: ``` compress(cap, l, r) = Poseidon2_permute([cap, l, r])[0] leaf_hash(key, value) = compress(DOMAIN_LEAF, F(key), F(value)) node_hash(left, right) = compress(DOMAIN_NODE, F(left), F(right)) ``` Where `F(b)` interprets a 32-byte big-endian string as an `Fr` (reduced mod `p`), and the capacity element carries the domain constant. This replaces the SHA-256 prefix-byte domain separation of [smt.md](/spec/kernel/smt): | Constant | Value | Replaces | | ------------- | ---------- | ---------------------- | | `DOMAIN_LEAF` | `Fr(0x20)` | SMT-leaf prefix `0x20` | | `DOMAIN_NODE` | `Fr(0x21)` | SMT-node prefix `0x21` | #### Unchanged from [smt.md](/spec/kernel/smt) * **Tree depth** stays 168 bits, MSB-first traversal, namespaced fixed-depth sparse tree. * **Key derivation** stays `namespace || sha256(raw)[0:160 bits]` — SHA-256, *not* Poseidon2. The in-circuit identity→key derivation (`derive_key(ns, id) = poseidon2(...)`) is deliberately **not** adopted: the circuit takes the SMT key as a public input and the binding to identity is enforced out-of-circuit (see [Identity Binding](#identity-binding)), which is sound and avoids a second SMT migration plus \~30k constraints of in-circuit SHA-256. * **Empty sentinel** stays `sha256("")` (reduced into `Fr` for hashing). Empty subtree hashes are precomputed per level. * **Leaf value** encoding: a 32-byte SMT leaf value is interpreted as a single `Fr` via `F(b) = Fr::from_be_bytes_mod_order(b)` (32-byte big-endian → Fr, reduced modulo `p`). RBAC bitmasks (State bits 0-7, trait bits 8+) fit directly in `Fr` (`< 2^253`) and the reduction is a no-op for them. Arbitrary 32-byte values (event-status ids, KV content hashes, SMT leaf hash outputs from a SHA-256 path) MAY exceed `p` by a small margin (`2^254 ≤ x < 2^256`); the modular reduction is sound by reduction to discrete-log hardness of the underlying 32-byte source — the collision probability between two SHA-256 outputs `a, b` with `a ≡ b (mod p)` is `~2^-254`, indistinguishable from collision in SHA-256 itself. *** ### The Transition Circuit The circuit proves a single state-changing AC event drives `old_root → new_root` validly. There is one circuit family parameterized by event shape; the single-leaf circuit is the core, and multi-leaf events chain it (see [Multi-Leaf Transitions](#multi-leaf-transitions)). #### Public Inputs A single-leaf transition exposes exactly these field elements as public inputs: | Input | Meaning | | ------------ | ------------------------------------------------------- | | `old_root` | SMT root before the event | | `new_root` | SMT root after the event | | `author_key` | 21-byte SMT key of the event author (operator), as `Fr` | | `target_key` | 21-byte SMT key of the target identity, as `Fr` | Everything else — the author's and target's leaf values (bitmasks), the Merkle siblings and presence bitmaps for both leaves, the action schema, the operator rank, the Move `from`-state, the Grant scope — is a **private witness**. The verifier learns only that *some* valid witness exists. #### Enforced Constraints The circuit enforces the complete node authorization pipeline (see [`rbac/events.md`](/spec/kernel/rbac)): 1. **Membership binding (old).** `fold_membership(target_key, old_target_leaf, siblings, bitmap)` recomputes to `old_root`. Same for the author leaf. This binds the witnessed pre-image leaves to the public `old_root`. 2. **Authorization (semiring).** The RBAC semiring verdict (deny-override via `_op` rows, Public/Any/ wildcard/Self columns) over the operator's traits MUST be `allow` for this event type and operation. 3. **Rank.** The operator's best rank MUST be strictly less than the target's (skipped for the no-traits and self cases per the spec rule). 4. **Event-specific preconditions.** * **Move:** `current_state == from` (else the transition is invalid), set the State enum and clear trait flags unless `preserve`. * **Grant:** the trait being granted MUST be in-scope for the current State. * **Revoke / Transfer:** clear/move the trait bit. 5. **Mutation + leaf deletion.** The new target leaf is the mutated bitmask, *except* a bitmask of `0` removes the leaf (substitutes the empty sentinel) — an identity with no roles is not in the tree. 6. **Membership binding (new).** Re-folding the mutated target leaf through the *same* siblings MUST recompute to `new_root`. (Single-leaf updates share siblings, so this is sound without a divergence-detection multiproof.) A proof exists iff all of the above hold. A forged `new_root` (or any rule violation) is unsatisfiable. > **Emptiness handling (bounded vs folding).** This bounded circuit takes the per-level > sibling-presence bitmap as a private witness (pinned by the old-root membership binding) and covers > **present-leaf update and deletion** (bitmask→0). **Insert** from a non-membership old state, and the > empty-subtree short-circuit generally, are provided by the **folding step** > (see [Folding](#folding-incremental-verifiable-computation)), which *derives* emptiness in-circuit > from the sibling values rather than from a witnessed flag. #### Identity Binding `author_key` and `target_key` are public 21-byte SMT keys, not raw identities. The verifier closes the identity binding **out-of-circuit** by checking ``` key == namespace || sha256(id_pub)[0:160 bits] ``` against the `id_pub` it already authenticated (e.g. from the Schnorr-signed commit). This is sound — the key→identity map is a single trivial SHA-256 the verifier already computes — and keeps the expensive SHA-256 out of R1CS. `is_self` is derived in-circuit from `author_key == target_key`, so the Self authorization path cannot be forged. *** ### Multi-Leaf Transitions Events that touch more than one leaf MUST be proven as **chains of single-leaf transitions through intermediate roots**, rather than with a single multi-leaf multiproof, with each multi-leaf event constructed per the following table: | Event | Construction | | -------------- | ----------------------------------------------------------------------------------------------------------------- | | **Transfer** | Two chained single-leaf updates (clear operator trait, set target trait) through one intermediate root `mid_root` | | **AC\_Bundle** | N chained single-leaf updates through N−1 intermediate roots | | **Manifest** | Genesis: chained inserts from `init` entries (insert support via the folding step) | For a bundle, `old_root` and every intermediate root are public inputs; the final root is the published `new_root`. Each link is the single-leaf circuit above, so atomicity is proven as "the whole chain verifies or none does." This avoids an in-circuit divergence-detection gadget: because each link is an independent single-leaf update with its own membership binding against the previous root, the chain is sound. *** ### Folding (Incremental Verifiable Computation) The bounded forms above prove a *fixed-size* batch: the circuit grows O(N) in the number of transitions, so there is a ceiling. But the Enclave State SMT also holds arbitrary application state (KV namespace `0x02`, see [smt.md](/spec/kernel/smt)), so the number of state transitions scales with app activity and is **unbounded** over an enclave's lifetime — no fixed batch can cover the whole history. **Folding** (Incrementally Verifiable Computation, via the Nova folding scheme) closes this: each step folds one transition into a running instance, so the prover does **O(1) work per step** with constant memory, and a final compression SNARK yields **one O(1)-verifiable proof of the entire chain**. **Step relation.** The IVC state is the two field elements `z = [smt_root, schema_commitment]`. The first moves each step (the SMT root); the second is **constant** across the chain — a commitment to the enclave's manifest schema, carried unchanged. Each fold step proves the full per-transition authorization relation — membership of the old leaf in `z[0]`, the RBAC semiring + rank rule on the author (with `is_self` derived in-circuit), the Move from-state precondition and Grant state-scope (Enforced Constraints item 4), the `apply_op` mutation, and the new root (including **insert** of a leaf into an empty slot and **delete** when a bitmask reaches 0) — and outputs the next root. **Universal step + witnessed op/schema.** One circuit proves **any** single-leaf transition (RBAC, KV, or event-status), with `is_ac` (whether RBAC authorization applies) derived in-circuit from the target key's namespace byte — so one IVC chain covers an enclave's whole state across all namespaces. To make the constraint *matrices* identical for every transition (a precondition for a single shared `PublicParams`), both the **event op** (Grant/Revoke/Move of any trait) and the **action schema** (allow/deny flags + ranks, with trait positions fixed at bits `8+k`) are **witnessed circuit inputs** rather than baked constants. So one `PublicParams` folds arbitrary ops under **any** enclave schema. **Schema binding.** Because the schema is witnessed, the step also binds it: it hashes the witnessed schema in-circuit to `schema_commitment = Poseidon2(0x53, flags_packed, ranks_packed)` and enforces it equals `z[1]`. The verifier sets `z[1]` from the published manifest (the same hash), so a prover **cannot fold under a forged, more-permissive schema** — the proof is valid only for the manifest's schema. (This is stronger than the bounded circuit, where the schema is a trusted private witness.) Folding `N` steps attests every transition in the chain from a trusted `r_0` to a final `r_n`, with no replay; binding `r_n` to the node's published `state_hash` is the verifier's job (see Verification below). **Construction.** The folding construction MUST follow the choices in the table below: | Aspect | Choice | | -------------- | ----------------------------------------------------------------------------------------------------- | | Folding scheme | Nova (the canonical microsoft/Nova `nova-snark`), over a BN254/Grumpkin 2-curve cycle | | IVC state | `z = [smt_root, schema_commitment]` — the root moves; the commitment is constant (carried) | | Step hash | the **same** Poseidon2-over-BN254 instance as the node and the bounded circuit (byte-identical) | | Op + schema | **witnessed** circuit inputs (not baked) → one `PublicParams` folds arbitrary ops under any schema | | Schema binding | witnessed schema hashed in-circuit to `Poseidon2(0x53, flags, ranks)` and enforced `== z[1]` | | Commitments | **IPA/Pedersen** — transparent, **no trusted setup** (default); KZG optional for constant-size proofs | | Final proof | a **Spartan** SNARK compressing the folded instance to one succinct proof | The empty-subtree short-circuit in the folding step is **derived in-circuit** from the sibling values (a parent of two empty children stays the sentinel) rather than taking a witnessed "sibling present" flag, so the emptiness check is bound to the actual sibling value (no free witness) — the basis for non-membership, insert, and delete, pending the external review noted under Status. Because the step hash is the identical Poseidon2 instance, a folded root commits to state exactly as the node's root does, and the folding step enforces the same authorization relation as the bounded circuit. The one remaining difference is **identity binding**: the bounded circuit exposes the author/target keys as public inputs (so the verifier can bind them to signed events out-of-circuit), whereas the folding step witnesses the keys and derives `is_self` — the IVC state is `[smt_root, schema_commitment]`, no per-step keys. So a folded proof attests the same per-transition validity, without exposing the per-step identities; the anchoring binds **roots** to the signed log (which is exactly what the folding path certifies: every transition between two log-anchored roots was valid). **Verification.** Recompute `schema_commitment` from the published manifest. Obtain `r_0` from a trusted genesis/checkpoint (or anchor it via CT + STH as in the bounded flow), then verify the compressed folding proof against `z = [r_0, schema_commitment]`, checking the attested final state is `[r_n, schema_commitment]`. Success means every one of the (unboundedly many) intervening transitions was valid **and** folded under the manifest's schema — in O(1), with no replay. A reference implementation of this anchored verification (STH + CT inclusion + schema recompute + proof) lives in the impl-zk `verifier/` crate. *** ### Out of Circuit The following checks MUST be performed out-of-circuit by the client with ordinary cryptography, and linked to the proof only by equality of the public field elements (the roots and keys). Putting SHA-256/CBOR/secp256k1 in-circuit would cost millions of constraints per event for no soundness gain. | Check | Where | How it links | | ------------------------------------------------ | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Commit signature (Schnorr / ECDSA / cosign cert) | Out-of-circuit | Authenticates `commit.from` (the parent `id_pub`) → `author_key`. Sub-key-authored commits validate the cosign cert per [`spec.md` §Delegated Sub-keys](/spec/kernel/spec); the SMT key still derives from the parent `id_pub`, never the sub-key. | | CBOR event/commit hash recompute | Out-of-circuit | Standard event verification | | CT inclusion / consistency | Out-of-circuit ([proof.md](/spec/kernel/ct)) | Binds `state_hash` to the log | | STH signature | Out-of-circuit ([proof.md](/spec/kernel/ct)) | Anchors the published root | | Identity → SMT key | Out-of-circuit (verifier SHA-256) | Binds public `*_key` to identity | *** ### Verification Flow To accept a node-claimed state transition for an AC event: 1. Authenticate the event out-of-circuit: verify the commit signature per `commit.alg` ([`spec.md` §Signature Schemes](/spec/kernel/spec) — Schnorr for the default path; ECDSA for the `alg: "ecdsa"` path; cosign cert validation for sub-key-authored commits per [`spec.md` §Delegated Sub-keys](/spec/kernel/spec)). Recompute the CBOR event hash. In all cases, `id_pub_author = commit.from` and `id_pub_target = content.target` (when present); RBAC and the SMT key derivation are keyed to the **parent identity**, not the sub-key, so a cert-authorized commit produces the same `author_key` as a direct-signed commit by the same parent. 2. Derive the expected SMT keys `namespace || sha256(id_pub)[0:160 bits]` and check they equal the proof's public `author_key` / `target_key`. The hash input is the 32-byte x-only `id_pub` (the field-name used in [`spec.md` §reg\_identity](/spec/app/enclaves/registry#reg_identity) — not `id_pub_full` or `sub_pub`). Sub-keys are not in the SMT and are never used as SMT-key material. 3. Establish that `old_root` is trusted — typically via a prior accepted transition, or via a CT inclusion proof + STH anchoring `old_root` to the signed log ([proof.md](/spec/kernel/ct)). 4. Verify the proof against the pinned verifying key. **Bounded mode:** a Groth16 proof over public inputs `(old_root, new_root, author_key, target_key)` (plus any intermediate roots for multi-leaf events). **Unbounded mode:** the compressed folding (Spartan) proof over `z = [r_0, schema_commitment]` → `[r_n, schema_commitment]`, where `schema_commitment` is recomputed from the published manifest — see [Folding](#folding-incremental-verifiable-computation). 5. On success, `new_root` (bounded) / `r_n` (folded) is accepted as a valid successor reached by valid transition(s) — without replaying the SMT. The proof certifies *validity of the transition*; the CT/STH layer certifies *which transition the log commits to*. Together a client trusts a fresh `state_hash` end-to-end with O(1) work. *** ### Production Readiness This layer is **implemented and reference-tested** but not yet production-locked: * **Bounded (Groth16).** The validity proof verifies over a live node's real Poseidon2 SMT roots (real signed RBAC transition → Groth16 proof → client verify; a forged root is rejected). The single-leaf circuit is \~127k constraints; Transfer/AC\_Bundle chain it; the Move precondition, Grant state-scope, rank rule, and leaf deletion are enforced. * **Unbounded (folding).** The Nova IVC layer is implemented on the canonical Nova implementation: single-leaf membership-and-update, the **full in-circuit RBAC authorization relation** (semiring + rank + `apply_op` + Move from-state precondition + Grant state-scope), and structure-changing insert/delete are each folded and recursively verified. The **universal step** proves any single-leaf transition (RBAC/KV/event-status), and the **op and schema are witnessed inputs** — so one shared `PublicParams` folds arbitrary ops under any enclave schema, with the witnessed schema **bound** to a manifest commitment in `z[1]` (a prover cannot fold under a forged schema). A **Spartan** compression to one succinct proof is demonstrated end-to-end on the Poseidon2 fold chain. The folding step's Poseidon2 is gated **byte-identical** to the node (shared golden vectors). The full **out-of-circuit verifier** (impl-zk `verifier/` crate) is implemented and run **end-to-end against a live `wrangler dev` node**: a real signed Grant is folded through the prover-service, and the client verifies the node's real **STH (Schnorr)** + **CT inclusion** proofs anchoring both `r_0` and `r_n` to the signed log, then the compressed folding proof against `[r_0, schema_commitment]` — accepting `r_n`, and rejecting any tampering. It runs **ceremony-free** (transparent IPA commitments). The bounded circuit additionally exposes the keys as public inputs for identity binding; the folding step witnesses them (a by-design IVC difference). Each fold step is sizable (\~1.5·10⁵ constraints); "O(1) per step" is asymptotic in chain length, not small. * **Poseidon2** is byte-identical across the JS node, the Rust node logic, the bounded circuit, and the folding step; the instance is **validated** against the official BN256 reference for `R_F`/`R_P`/`d`/ `M_int` (round constants an intentional NUMS deviation, above). * **Trusted setup.** Groth16 (and the optional KZG folding decider) need a real setup ceremony before production; the transparent IPA folding path does not. Verifying keys MUST be published and pinned. The reference implementation lives in the impl-zk circuit crate (bounded) and its `folding/` crate (IVC). ## Certificate Transparency Proofs This document specifies the **Certificate Transparency** proof formats clients use to verify the ENC enclave event log without trusting the node: CT inclusion and consistency proofs for the log itself, bundle membership proofs that tie individual events to a CT leaf, and the Signed Tree Head (STH) the sequencer publishes as the trust root. SMT membership / non-membership proofs (for current enclave state) are specified separately in [`smt.md`](/spec/kernel/smt). *** ### Table of Contents 1. [Overview](#overview) 2. [CT Proofs](#ct-proofs) 3. [Wire Format (JSON)](#wire-format-json) 4. [Bundle Construction (RFC 9162)](#bundle-construction-rfc-9162) *** ### Overview The ENC protocol uses two types of Merkle proofs: | Proof Type | Purpose | Tree | | ------------------ | ------------------------------------------ | ------------------------ | | **CT Inclusion** | Prove event exists at position N in log | Certificate Transparency | | **CT Consistency** | Prove earlier log is prefix of current log | Certificate Transparency | These Merkle proofs prove inclusion against a root the client *already trusts*. To instead prove that a root *transition* `old_root → new_root` is itself **valid** under the RBAC rules — letting a client trust a fresh `state_hash` in O(1) without replaying the log — see the zero-knowledge validity-proof layer in [`zk.md`](/spec/node/zk). For proofs of current enclave state against a known `state_hash`, see [`smt.md`](/spec/kernel/smt). *** ### CT Proofs #### Bundle Membership Proof Proves that an event is part of a specific bundle. **Structure:** ``` { event_id: <32 bytes>, bundle_index: , bundle_size: , // total event count in the bundle; needed for odd-size promotion siblings: [, ...] } ``` Where: * **event\_id** — the event being proven * **bundle\_index** — position of event within the bundle (0-indexed) * **bundle\_size** — total number of events in the bundle (`>= 1`); used by the verifier to detect odd-leaf promotion at each layer * **siblings** — Merkle proof siblings from event\_id to events\_root, in **leaf-to-root** order (`siblings[0]` is the deepest sibling). Carried (odd-promoted) leaves contribute NO sibling at the layer where they were carried up. **Verification:** ``` hash = event_id index = bundle_index n = bundle_size sib_i = 0 while n > 1: if index == n - 1 and (n % 2 == 1): # Odd promotion — last leaf at an odd-sized layer is carried up # unchanged. No sibling consumed; no hash step at this layer. pass else: s = siblings[sib_i]; sib_i += 1 if index % 2 == 0: hash = H(0x01, hash, s) # current is left child else: hash = H(0x01, s, hash) # current is right child index = index >> 1 n = (n + 1) >> 1 # ceil(n / 2) — size of next layer require sib_i == siblings.length # no excess siblings require hash == events_root ``` **Algorithm Notes:** * `bundle_index` is the event's 0-indexed position within the bundle. * The LSB of `index` determines left (0) or right (1) at each level. * After each layer, shift `index` right and recompute `n = ceil(n/2)` for the next layer's size. * The odd-promotion check (`index == n - 1 and n % 2 == 1`) reflects the RFC 9162-style root construction: when a layer has an odd number of nodes, the rightmost node is carried up to the next layer **unchanged** (no `node_hash` applied).Reference: `computeEventsRoot` and the parity-matching Rust at. * Siblings are ordered leaf-to-root; the verifier consumes `siblings[0]` at the deepest non-promoted layer. **Example (even bundle):** Bundle with 4 events, verifying event at `bundle_index = 2`, `bundle_size = 4`: ``` events_root / \ h01 h23 / \ / \ e0 e1 e2 e3 ← bundle_index: 0, 1, 2, 3 ``` * Start: `hash = e2`, `index = 2`, `n = 4`, `sib_i = 0` * Layer 0 (`n=4`): `index=2, n-1=3`, not last. LSB(2)=0 → `hash = H(0x01, hash, siblings[0])` where `siblings[0] = e3`. `sib_i=1, index=1, n=2`. * Layer 1 (`n=2`): `index=1, n-1=1`, IS last but `n=2` is even — not odd promotion. LSB(1)=1 → `hash = H(0x01, siblings[1], hash)` where `siblings[1] = h01`. `sib_i=2, index=0, n=1`. * Loop ends. `hash == events_root`. `siblings = [e3, h01]` (length 2). **Example (odd bundle, carried leaf):** Bundle with 3 events, verifying event at `bundle_index = 2`, `bundle_size = 3`: ``` events_root = H(0x01, h01, e2) / \ h01 e2 ← layer 1: e2 carried up / \ / e0 e1 e2 ← bundle_index: 0, 1, 2 ``` * Start: `hash = e2`, `index = 2`, `n = 3`, `sib_i = 0` * Layer 0 (`n=3`): `index=2 == n-1=2` AND `n` is odd → ODD PROMOTION, no sibling consumed. `index=1, n=2`. * Layer 1 (`n=2`): `index=1, n-1=1`, last but `n=2` even — normal. LSB(1)=1 → `hash = H(0x01, siblings[0], hash)` where `siblings[0] = h01`. `sib_i=1, index=0, n=1`. * Loop ends. `hash == events_root`. `siblings = [h01]` (length 1). With `bundle_size = 1`, the bundle contains one event, the loop body never runs, `siblings == []`, and `events_root == event_id`. #### CT Inclusion Proof Proves that a bundle exists at a specific position in the log. **Structure:** ``` { tree_size: , leaf_index: , path: [, ...] } ``` Where: * **tree\_size** — number of bundles in the tree when proof was generated * **leaf\_index** — 0-based position of the bundle in the log * **path** — sibling hashes from leaf to root **Leaf Hash:** ``` leaf_hash = H(0x00, events_root, state_hash) ``` Where `events_root` is the Merkle root of event IDs in the bundle, and `state_hash` is the SMT root after the bundle. **Verification (RFC 9162 Section 2.1.3.2):** 1. Set `fn = leaf_index`, `sn = tree_size - 1`, `r = leaf_hash` 2. For each `p` in path: * a. If `sn == 0`: FAIL (proof too long) * b. If `LSB(fn) == 1` or `fn == sn`: * `r = H(0x01, p, r)` * While `LSB(fn) == 0` and `fn != 0`: `fn >>= 1; sn >>= 1` * c. Else: * `r = H(0x01, r, p)` * d. `fn >>= 1; sn >>= 1` 3. Verify `sn == 0` and `r == expected_root` **Test Vector:** ``` Tree size: 7, Leaf index: 5 Initial: fn=5, sn=6 Step 1 (p[0]): LSB(5)=1 → r=H(0x01,p[0],r), shift → fn=2, sn=3 Step 2 (p[1]): LSB(2)=0, fn≠sn → r=H(0x01,r,p[1]), shift → fn=1, sn=1 Step 3 (p[2]): fn==sn → r=H(0x01,p[2],r), shift → fn=0, sn=0 Final: sn=0 ✓, compare r to expected_root ``` **Edge Cases:** 1. **Single-element tree (tree\_size = 1, leaf\_index = 0):** * Initial: `fn = 0`, `sn = 0` * `path` is empty (no siblings) * Skip the loop and verify `r == expected_root` directly 2. **Leaf at last position (leaf\_index = tree\_size - 1):** * Valid case; algorithm handles via `fn == sn` condition #### CT Consistency Proof Proves that an earlier log state is a prefix of the current state. **Structure:** ``` { tree_size_1: , tree_size_2: , path: [, ...] } ``` Where: * **tree\_size\_1** — size of the older (smaller) tree * **tree\_size\_2** — size of the newer (larger) tree * **path** — sibling hashes proving consistency **Precondition:** `tree_size_1 <= tree_size_2`. If `tree_size_1 > tree_size_2`, reject with `INVALID_RANGE` error immediately. **Verification:** 1. If `tree_size_1 == tree_size_2`: verify path has 1 element equal to both roots 2. If `tree_size_1` is a power of 2: prepend `first_hash` to path 3. Set `fn = tree_size_1 - 1`, `sn = tree_size_2 - 1` 4. While `LSB(fn) == 0`: shift both `fn` and `sn` right by 1 5. Set `fr = path[0]`, `sr = path[0]` 6. For each `c` in path\[1:]: * If `sn == 0`: FAIL * If `LSB(fn) == 1` or `fn == sn`: set `fr = H(0x01, c, fr)`, `sr = H(0x01, c, sr)`, then while `LSB(fn) == 0` and `fn != 0`: shift both right by 1 * Else: set `sr = H(0x01, sr, c)` * Shift both `fn` and `sn` right by 1 7. Verify `fr == first_hash`, `sr == second_hash`, and `sn == 0` Based on RFC 9162 Section 2.1.4. #### Signed Tree Head (STH) The sequencer signs the CT root periodically to create a checkpoint. **Structure:** ``` { t: , ts: , r: , sig: } ``` Where: * **t** — Unix milliseconds when STH was generated * **ts** — number of bundles in the tree * **r** — CT root hash (32 bytes) * **sig** — Schnorr signature over the STH **Signature:** ``` message = "enc:sth:" || be64(t) || be64(ts) || r sig = schnorr_sign(sha256(message), seq_priv) ``` Where `r` is the raw 32-byte root hash (NOT hex-encoded). The message is binary concatenation: * `"enc:sth:"` = 8 bytes UTF-8 * `be64(t)` = 8 bytes big-endian * `be64(ts)` = 8 bytes big-endian * `r` = 32 bytes raw Total: 56 bytes before SHA-256. **Wire Format (JSON):** ```json { "t": 1706000000000, "ts": 1000, "r": "", "sig": "" } ``` **Verification:** 1. Reconstruct message: `"enc:sth:" || be64(t) || be64(ts) || hex_decode(r)` 2. Verify: `schnorr_verify(sha256(message), sig, seq_pub)` #### Full Event Proof To fully prove an event exists and verify its state: 1. **Bundle membership proof** — proves event\_id is in bundle's events\_root 2. **CT inclusion proof** — proves bundle is in CT tree 3. **SMT proof** — proves state claim against bundle's state\_hash This two-level structure allows efficient bundling while maintaining per-event verifiability. *** ### Wire Format (JSON) The normative wire format is JSON. #### CT Inclusion Proof ```json { "ts": 1000, "li": 42, "p": ["", ...] } ``` | Field | Encoding | | ----- | ---------------------------------------------- | | ts | Integer (tree\_size) | | li | Integer (leaf\_index) | | p | Array of hex strings, 64 chars each (32 bytes) | #### CT Consistency Proof ```json { "ts1": 500, "ts2": 1000, "p": ["", ...] } ``` | Field | Encoding | | ----- | ---------------------------------------------- | | ts1 | Integer (tree\_size\_1) | | ts2 | Integer (tree\_size\_2) | | p | Array of hex strings, 64 chars each (32 bytes) | #### Bundle Membership Proof ```json { "ei": 2, "s": ["", ...] } ``` | Field | Encoding | | ----- | ---------------------------------------------- | | ei | Integer (event index within bundle, 0-indexed) | | s | Array of hex strings, 64 chars each (32 bytes) | **Note:** With `bundle.size = 1`, the bundle contains one event, so `s` is empty and `ei` is 0. **Note:** Wire format omits `event_id` as the verifier already knows the event being proven from request context. For self-contained proofs (e.g., archival), include `event_id` separately. *** ### Bundle Construction (RFC 9162) How the sequencer assembles events into CT leaves and grows the append-only Merkle tree. Follows [RFC 9162](https://www.rfc-editor.org/rfc/rfc9162.html). **Properties:** * **CT root:** Single hash representing the entire event history AND state * **Inclusion proof:** Proves a specific event exists at a given position in the log * **Consistency proof:** Proves an earlier log state is a prefix of the current state **Bundle Structure:** Events are grouped into bundles (see [`spec.md` §Bundle Configuration](/spec/kernel/spec#bundle-configuration)). Each bundle produces one CT leaf. ``` bundle = { events: [event_id_0, event_id_1, ..., event_id_N], state_hash: } ``` **Initial State:** Before any events (including Manifest), the SMT is empty: ``` empty_state_hash = sha256("") = 0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ``` The Manifest event is always in bundle 0. The bundle's `state_hash` is the SMT root AFTER Manifest's `init` entries have been applied. **Leaf hash:** ``` events_root = merkle_root(events) // binary Merkle tree of event IDs leaf_hash = H(0x00, events_root, state_hash) ``` Where: * `events_root` — Merkle root of event IDs in this bundle * `state_hash` — SMT root AFTER the last event in this bundle is applied **events\_root Construction:** For N events in a bundle: 1. If N = 1: `events_root = event_ids[0]` (no tree needed) 2. If N > 1: Build a binary Merkle tree over event IDs: * Leaf: `event_id` (raw 32-byte hash, no prefix) * Internal node: `H(0x01, left, right)` * Built bottom-up by pairing adjacent nodes; if a level has an odd number of nodes, the final unpaired node is **carried up unchanged** to the next level (it is NOT duplicated or padded). * Example: 3 events → `H(0x01, H(0x01, e0, e1), e2)` (e2 carried up, never duplicated) **Bundle Event Ordering:** Events within a bundle are ordered by their sequence number (`seq`). The first event has the lowest seq, the last has the highest. This ordering is deterministic and verifiable. **Bundle Index Assignment:** Bundles are numbered sequentially starting from 0. An event's bundle membership is determined by bundle boundaries: * `bundle_0` contains events from seq=0 until size or timeout reached * `bundle_N` contains events from `boundary[N-1] + 1` until size or timeout reached * `boundary[N]` = seq of last event in bundle N **Determining bundle from seq:** ``` event.seq belongs to bundle_N where: boundary[N-1] < event.seq <= boundary[N] (boundary[-1] = -1 for the first bundle) ``` **Deterministic reconstruction:** During log replay or migration, bundle boundaries are reconstructed by applying the same closing rules (size/timeout). If CT root matches after reconstruction, bundle assignment is correct. **Example:** ``` Config: bundle.size = 3, timeout = 5000ms seq=0,1,2 (ts: 1000ms) → bundle_0, boundary[0]=2 seq=3,4,5 (ts: 3000ms) → bundle_1, boundary[1]=5 seq=6 (ts: 9000ms) → bundle_2, boundary[2]=6 (timeout hit) Query: which bundle is seq=4? Answer: bundle_1 (because 2 < 4 <= 5) ``` **Internal node:** ``` node_hash = H(0x01, left_child, right_child) ``` The `0x00` and `0x01` prefixes prevent second-preimage attacks by distinguishing leaf nodes from internal nodes. **CT Tree Construction:** The CT tree follows RFC 9162 Section 2.1 (Merkle Tree algorithm): * Bundles are CT leaves, numbered sequentially (`bundle_0`, `bundle_1`, ...). * Tree grows as bundles are appended * The Merkle Tree Hash (MTH) is computed recursively (RFC 9162 §2.1), with NO padding or leaf duplication. For `n` leaves `D`: * `MTH([]) = 0x00…00` (32 zero bytes) — the sentinel root for an empty CT. **NOTE:** This is `[0u8; 32]`, NOT `sha256("")` — the empty-tree sentinel deliberately differs from the SMT's empty-subtree hash `sha256("")` (see [smt.md](/spec/kernel/smt)). * `MTH([d0]) = leaf_hash(d0)` — a single leaf is its own hash (it is NOT re-hashed). * `MTH(D[0:n]) = H(0x01 || MTH(D[0:k]) || MTH(D[k:n]))` for `n > 1`, where `k` is the **largest power of two strictly less than `n`**. * For non-power-of-two `n` this yields an **unbalanced** tree; the trailing leaves are promoted up the right spine, never padded/duplicated. * Example: 5 bundles split at `k = 4` → `H(0x01 || MTH([b0..b3]) || MTH([b4]))` ``` CT root (5 bundles, RFC 9162 §2.1 — split at largest power of two < n): root / \ h0123 b4 ← b4 is a lone leaf promoted up; no padding / \ h01 h23 / \ / \ b0 b1 b2 b3 ``` This matches RFC 9162 exactly and is required for working inclusion AND consistency proofs. Pad-with-last (duplicating the final leaf to the next power of two) MUST NOT be used — it cannot support RFC 9162 consistency proofs. **Proof Structure:** To prove an event exists and verify state: 1. **Bundle membership proof** — proves event\_id is in the bundle's events\_root 2. **CT inclusion proof** — proves bundle is in the CT tree With `bundle.size = 1`, the events\_root equals the single event\_id, and bundle membership proof is trivial. **State Binding:** The CT leaf binds each **bundle** to the enclave state after that bundle: ``` state_hash[bundle_0] = SMT root after all events in bundle 0 state_hash[bundle_N] = SMT root after all events in bundle N ``` Within a bundle, state changes are applied sequentially: ``` For events [e_0, e_1, ..., e_k] in bundle: state = apply(state, e_0) state = apply(state, e_1) ... state = apply(state, e_k) bundle.state_hash = state ``` State-changing events (modify SMT): Manifest, Move, Grant, Revoke, Transfer, Gate, AC\_Bundle, Shared, Own, Update, Delete, Pause, Resume, Terminate, Migrate. Non-state-changing events: Content Events (app-defined customs). Since `state_hash` is deterministic from the log, anyone can recompute and verify it. If a node provides incorrect `state_hash` values, the CT root will not match. > **Note:** State changes take effect immediately for authorization (in-memory). The `state_hash` in CT reflects the state at bundle boundaries for proof purposes. **State Query Semantics:** When clients query enclave state (RBAC or event status) mid-bundle, two modes are available: | Mode | Returns | Verifiable | Fresh | | ---------- | --------------------------------------- | ---------------- | ------------ | | `verified` | `state_hash` from last finalized bundle | ✅ Yes (CT proof) | May be stale | | `current` | SMT root including pending events | ❌ No proof | ✅ Fresh | * **Verified queries**: Use for audits, disputes, high-stakes operations * **Current queries**: Use for real-time apps (chat, collaboration) Nodes SHOULD support both modes. \::: extension-point id=ct-default-query-mode class=impl\_defined\_default reason: choice between `verified` (paid SMT-proof round-trip) and `current` (no proof) is a per-deployment tradeoff between latency and verification strength A node's default query mode (when a caller omits the `mode` parameter) is chosen by the implementation. Nodes SHOULD document their choice. \::: **Staleness Guidance:** With default `bundle.timeout = 5000` ms, `verified` queries MAY be up to 5 seconds stale. Nodes SHOULD document their bundle configuration and expected staleness. **Use cases:** * Client verifies an event is part of the canonical log * Client verifies the enclave state at any point in history * Client verifies the log they cached is still valid (consistency with current log) * Detect if a node is presenting different log histories to different clients * Checkpoint for migration (CT root proves both log and state) *Proof serialization formats are specified in [§Wire Format (JSON)](#wire-format-json) above.* ## RBAC v2 This document specifies **RBAC v2**, the role-based access control system used by every ENC enclave. It covers the bitmask encoding of State and trait, the runtime Context abstractions (Self / Sender / Public), the JSON manifest schema, the authorization algorithm, and the per-event processing rules (Move / Grant / Revoke / Transfer / Gate / AC\_Bundle / Lifecycle / KV). *** ### Table of Contents * [Overview](#overview) * [Bitmask Encoding](#bitmask-encoding) * [Contexts](#contexts) * [SMT Namespace Table](#smt-namespace-table) * [Appendix: Removed Events](#appendix-removed-events) * [Manifest Format](#manifest-format) * [Validation Rules](#validation-rules) * [Example Manifest](#example-manifest) * [Authorization Algorithm](#authorization-algorithm) * [Processing Pipeline](#processing-pipeline) * [Event Processing](#event-processing) *** ### Overview The RBAC system controls authorization for all events in an enclave. Every event — content events (message), AC events (Move, Grant, Revoke), KV events (Shared, Own), and lifecycle events (Pause, Terminate) — is authorized through the manifest. See Section 5 for manifest format and Section 6 for validation rules. #### 1.1 Terminology The old unified "role" is retired. Three column types replace it: | Concept | Convention | Examples | Encoding | Changed by | Semantics | | ----------- | ----------- | ------------------------ | ---------------- | -------------------------------- | --------------------------------------------------------- | | **State** | UPPER\_CASE | PENDING, MEMBER, BLOCKED | 8-bit enum | Move | WHERE the actor is. Base permissions. Mutually exclusive. | | **trait** | lower\_case | owner, admin, muted | Flag bits | Grant / Revoke / Transfer / init | WHAT modifies the actor. Additive or deny ops. Ranked. | | **Context** | PascalCase | Self, Sender, Public | System-evaluated | (implicit) | System condition. Determined at authorization time. | #### 1.2 Operations Six operations apply to all event types: | Op | Meaning | Deny | | -- | --------------------------------------- | ---- | | C | Create | \_C | | R | Read | \_R | | U | Update | \_U | | D | Delete | \_D | | N | Notify (lightweight, human clients) | \_N | | P | Push (full delivery, service endpoints) | \_P | Positive ops grant capabilities. Negative (deny) ops revoke capabilities. The sign comes from the schema entry, not the bitmask. *** *** ### Bitmask Encoding All RBAC state for an identity is stored in a single bitmask value in the SMT (namespace `0x00`), keyed by the identity's public key. ``` bits 0-7: State enum bits 8+: trait flags ``` #### 2.1 State Enum (bits 0-7) 8-bit integer field. All 8 bits together encode a single value. Mutually exclusive by structure — an identity is in exactly one State. | Value | Name | Description | | ----- | ------------- | ------------------------------------------------------------------------ | | 0 | OUTSIDER | Not in the enclave. No SMT leaf exists. Protocol-reserved. | | 1-255 | (app-defined) | Custom States defined in manifest. E.g., 1=PENDING, 2=MEMBER, 3=BLOCKED. | OUTSIDER (`0`) is the only protocol-reserved State. All other States — including blocking/banning — are app-defined. State values `1`-`255` are assigned sequentially from the manifest `states` array. The first State in the array gets value `1`, the second gets value `2`, etc. #### 2.2 trait Flags (bits 8+) Independent flag bits. Each bit represents one trait. Multiple traits can be set simultaneously. Bit positions are assigned sequentially from the manifest `traits` array: the first trait gets bit `8`, the second gets bit `9`, etc. Each trait declares a **rank** using the syntax `name(N)` where `N` is a non-negative integer. Lower number = higher rank. Multiple traits can share the same rank (peers). Rank determines targeting authority: an operator can only target identities ranked strictly below them (see §7 Rank Rule). All traits are managed uniformly: * Assigned via `init`, `Grant`, or `Transfer`. * Removed via `Revoke`, `Transfer`, or `Move` (clears all trait flags unless `preserve: true`). * Rank determines targeting authority (lower number = higher authority). The bitmask does not distinguish positive from negative traits. A "muted" trait flag at bit `10` is stored identically to an "admin" trait flag at bit `9`. The sign (positive or negative ops) comes from the schema entry, not the bitmask. #### 2.3 SMT Leaf Lifecycle * **Join**: `Move(OUTSIDER, X)` creates a new SMT leaf with State=X and no trait flags. * **State change**: `Move(X, Y)` updates the State enum and clears all trait flags (unless `preserve: true`). * **Leave**: `Move(X, OUTSIDER)` sets State=0 and clears all traits. If bitmask becomes `0`, leaf is deleted. * **Service account**: Granting a trait to an OUTSIDER creates a leaf with State=0 and trait bit set. * **Leaf deletion**: SMT leaf is deleted when the **entire bitmask == 0** (no State, no traits). * **No leaf**: An identity with no SMT leaf is implicitly OUTSIDER with no traits. Historical membership is provable via the CT log. *** *** ### Contexts Contexts are system-evaluated conditions used as columns in the schema. They are not stored — they are determined at authorization time. #### 3.1 Self * **Matches when**: The actor is targeting their own identity (`event.from == content.target`). * **Use case**: AC events — leaving a group (Move Self:C), stepping down from a trait (Revoke Self:C). #### 3.2 Sender * **Matches when**: The actor authored the referenced event (`event.from == original_event.from`). * **Use case**: Content events — editing/deleting own messages (message Sender:UD), removing own reactions (reaction Sender:D), updating own KV slot (Own Sender:U). #### 3.3 Public * **Matches when**: Always. Any identity, any State, including OUTSIDER. * **Use case**: Public read access to group messages, public enclave manifests. *** *** ### SMT Namespace Table | Namespace | Purpose | Key | Value | | --------- | ------------- | ---------------------------------- | ---------------------------- | | `0x00` | RBAC | `H(identity)` | Bitmask (State + traits) | | `0x01` | `EventStatus` | `H(event_hash)` | Status flags (Update/Delete) | | `0x02` | KV State | `H(key)` or `H(key \|\| identity)` | `H(content)` | Gate state lives in `0x02` with the `gate:` key prefix. Lifecycle state lives in `0x02` with the `lifecycle` key. *** *** ### Appendix: Removed Events Four event types from the v1 spec are removed. Each is replaced by a more general mechanism already in the registry. | Removed | Replacement | Rationale | | ------------ | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Move\_Self | Move with `operator: "Self"` | Self is a Context, not an event type. The node enforces `event.from == content.target` when the moves entry has `operator: "Self"`. No expressiveness lost. | | Revoke\_Self | Revoke with `operator: "Self"` | Same pattern. Self-targeting is an operator constraint, not a separate event type. | | Grant\_Push | Grant | The node detects P (Push) ops from the schema for the granted trait. If the trait has P ops and the Grant content includes an `endpoint` field, the node registers the push endpoint. No separate event type needed. | | Force\_Move | (removed, no replacement) | Owner can define all necessary transitions in `moves`. State is an 8-bit enum (not a combinatorial bitmask), so Owner can always move anyone to any State by having the right moves entries. Force\_Move added no capability that moves entries cannot express. | *** ### Manifest Format #### 5.1 Manifest Sections A manifest declares all RBAC rules for an enclave. Ten sections: | Section | Purpose | Entry format | | ----------- | -------------------------- | ----------------------------------------------------------------------------------------------------- | | `states` | App-defined States | Array of UPPER\_CASE names. Assigned enum values 1+ sequentially. | | `traits` | App-defined traits | Array of `name(rank)` strings. Assigned bit positions 8+ sequentially. Lower rank = higher authority. | | `readers` | Read access | `[{ type, reads }]`. Each entry declares read authority for one column. | | `init` | Initial identities | `{ identity, state, traits[] }`. Bootstrap SMT at enclave creation. | | `moves` | State transitions | `{ event, from, to, operator, ops }`. Optional `alias` and `gate`. | | `grants` | trait assignment rules | `{ event (Grant/Revoke), operator[], scope[], trait[] }` | | `transfers` | Atomic trait movement | `{ trait, scope[] }`. Operator MUST hold the trait. | | `slots` | KV state (Shared/Own) | `{ event (Shared/Own), operator, ops, key }` | | `lifecycle` | Enclave lifecycle | `{ event (Pause/Resume/Migrate/Terminate), operator, ops }` | | `customs` | App-defined content events | `{ event, operator, ops }` | #### 5.2 Section Details **`readers`** — Column-oriented read declarations. Each entry is `{ type, reads, retention? }`: * `type`: A declared State, trait, or Context. * `reads`: Array of event type names this column gets R on, or `"*"` for all events. * `retention` (OPTIONAL; State / trait entries only): `"current"` (default) or `"snapshot"`. Governs the temporal scope at which the node evaluates this reader against the requester's RBAC history. See §5.3 Reader Retention. A `retention` field on a `Context` reader (`Self`, `Sender`, `Public`) is a manifest validation error; Contexts are always evaluated per-event. **`moves`** — Each entry authorizes one State transition for one operator. **`grants`** — Each entry authorizes Grant or Revoke of specific traits, scoped to specific States. * `operator`: Who can grant/revoke (array — Contexts, traits, or States). * `scope`: Target State scope (array). * `trait`: Which traits can be granted/revoked (array). **`transfers`** — Each entry authorizes atomic movement of a trait from one identity to another. * `trait`: Which trait can be transferred. * `scope`: Target State scope (array). * The operator is implicit: MUST hold the trait being transferred. **`slots`** — KV state entries. Each entry requires a `key` field (lowercase, app-defined). * `Shared`: Enclave-wide singleton per key. Last write wins. * `Own`: Per-identity slot per key. Node enforces per-identity isolation. * Protocol-reserved keys: `gate:*`, `lifecycle`. Apps cannot write to reserved keys. **`lifecycle`** — Protocol-defined enclave lifecycle events. State stored in `Shared("lifecycle")`. **`customs`** — App-defined content events (lowercase names). Standard `{ event, operator, ops }` entries. #### 5.3 Reader Retention A `readers` entry declares a *column* (a State or trait) that grants read authority on a set of event types. The `retention` field selects between two evaluation modes — declaring it makes the policy explicit instead of implicit. | `retention` | When the column is required to be in the requester's bitmask | Effect when the requester loses the column | | --------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `"current"` (default) | At query time (the bitmask in the current SMT) | All access (historical and live) stops immediately. Behavior matches Implementations. | | `"snapshot"` | At the seq of each event being read | Pre-loss events stay accessible; live tail stops at loss. Forward-secrecy on confidentiality is unaffected (it lives in the encryption plugins, not in `readers`). | The default value of `retention` (when absent) is `"current"`. The two modes deliberately commute with the encryption plugins ([`ratchet-pair`](/spec/app/plugins/ratchet-pair), [`mls-lazy`](/spec/app/plugins/mls-lazy)) — those rotate epochs on RBAC removal, so post-removal events are opaque to the removed identity regardless of which retention mode is in force. `retention` therefore governs ONLY whether the node serves pre-removal ciphertext to a former member; it never controls confidentiality. Context readers (`Self`, `Sender`, `Public`) MUST NOT carry `retention`. They are evaluated per-event against `requester` identity at serve time (not over RBAC history); a `Sender` reader thus passes for any event the requester authored, regardless of their current or historical RBAC state in the enclave. When multiple `readers` entries apply (union semantics — rbac-v2 §4 already declares effective-ops = ⋃ per-column-ops), each entry is evaluated under its own `retention`. A manifest MAY mix modes per event type: ```json "readers": [ { "type": "MEMBER", "reads": ["message", "reaction"], "retention": "snapshot" }, { "type": "MEMBER", "reads": ["notice", "Pause", "Resume"], "retention": "current" } ] ``` Above: a kicked former member retains their conversational transcript (snapshot) but loses access to admin notices and lifecycle events (current). **Examples.** A transcript-portable group (kicked members keep history): ```json "readers": [ { "type": "MEMBER", "reads": "*", "retention": "snapshot" } ] ``` A security-sensitive private group (today's implicit default, now explicit — kicked = audit-trail amnesia): ```json "readers": [ { "type": "MEMBER", "reads": "*", "retention": "current" } ] ``` The operational algorithm — how the node computes the served seq set per query, how subscriptions split into historical and live phases, and how close reasons signal loss of access — is normative in [`node-api.md` §Read Authorization](/spec/node/node-api#read-authorization). #### 5.2.1 Naming Convention * Protocol events: PascalCase (Move, Grant, Shared, Pause, etc.) * App events: lowercase, optionally `_`-segmented for namespaced records (message, request, sent, reg\_identity, reg\_node, reg\_enclave, etc.) * States: UPPER\_CASE (MEMBER, PENDING, BLOCKED, etc.) * traits: lower\_case (admin, muted, etc.) * Contexts: PascalCase (Self, Sender, Public) * KV keys: lowercase (topic, profile, etc.) The convention is non-normative — the kernel does not validate event-name format. Implementations MAY follow stricter local rules (e.g. enforce the regex `^[a-z][a-z0-9_]*$` for `customs` entries) but conformance does not require it. #### 5.2.2 Common Entry Properties Any entry in any section MAY include: * `alias`: Optional. Names the entry for referencing (Gate keys, UI, logs, API). * `gate`: Optional. `{ operator[] }` — declares who can toggle the gate. Requires `alias`. Gate state stored in `Shared("gate:")`. When gate is closed, the event is rejected before RBAC runs. * `preserve`: Optional (`moves` only). If `true`, trait flags are kept after the Move. Default `false` (clear all traits). *** *** ### Validation Rules 1. **In and Out** — Every State in `states` MUST appear as `to` in at least one `moves` entry or be assigned in at least one `init` entry (in). Any State with no ops MUST also appear as `from` in at least one `moves` entry (out). 2. **No Stuck Traits** — Every trait in `traits` MUST have at least one assign path (Grant entry or Transfer entry) and at least one remove path (Revoke entry or Transfer entry). Traits only assigned in `init` are exempt from the assign path requirement. 3. **Valid Operators** — Every `operator` MUST be a declared State, declared trait, or a Context (Self, Sender, Public). 4. **Write and Reader Coverage** — Every event MUST have at least one writer authorization path with C ops, and read access MUST be covered by at least one matching `readers` entry. Operator R ops are OPTIONAL and only model profiles that deliberately encode read authority as an operation. 5. **Reserved Keys** — App-defined `slots` keys MUST NOT use reserved prefixes (`gate:`, `lifecycle`). 6. **Gate Requires Alias** — If an entry has `gate`, it MUST have `alias`. 7. **Valid Ranks** — Every trait must declare a rank via `name(N)` syntax. All rank values MUST be non-negative integers. 8. **Complete States** — Every State name referenced in the manifest (`moves` from/to, `grants` scope, `transfers` scope, `init` state) MUST be either declared in `states` or be OUTSIDER. 9. **Naming Convention** — Identifier strings declared in the manifest MUST conform to §5.2.1: * State names: `^[A-Z][A-Z0-9_]*$` * trait names (rank-stripped): `^[a-z][a-z0-9_]*$` * `customs.event`: `^[a-z][a-z0-9_]*$` OR a known protocol event name (`Manifest` / `Grant` / `Revoke` / `Move` / `Transfer` / `Gate` / `Shared` / `Own` / `AC_Bundle` / `Pause` / `Resume` / `Terminate` / `Migrate` / `Update` / `Delete`) * `slots.key`: `^[a-z][a-z0-9_]*$` Nodes MUST reject Manifest events whose content fails this rule with `ProcessResult.invalidManifest`. The conformance reference impl is `Enc.Core.ManifestValidator.checkNamingConvention`. Nodes MUST run all 9 rules on every Manifest event and reject the event (and not apply the manifest to enclave state) if any rule fails. The rejection result is `ProcessResult.invalidManifest`; implementations SHOULD log the specific rule violated. *** *** ### Example Manifest This is the Group Chat enclave manifest from [group.md](/spec/app/enclaves/group). For other examples, see [personal.md](/spec/app/enclaves/personal) and [dm.md](/spec/app/enclaves/dm). ```json { "states": ["PENDING", "MEMBER", "BLOCKED"], "traits": ["owner(0)", "admin(1)", "muted(2)", "dataview(3)"], "readers": [ { "type": "MEMBER", "reads": "*" } ], "moves": [ { "event": "Move", "from": "OUTSIDER", "to": "PENDING", "operator": "Self", "ops": ["C"], "alias": "applications", "gate": { "operator": ["owner", "admin"] } }, { "event": "Move", "from": "OUTSIDER", "to": "MEMBER", "operator": "Self", "ops": ["C"], "alias": "auto_join", "gate": { "operator": ["owner"] } }, { "event": "Move", "from": "OUTSIDER", "to": "MEMBER", "operator": "admin", "ops": ["C"] }, { "event": "Move", "from": "OUTSIDER", "to": "BLOCKED", "operator": "admin", "ops": ["C"] }, { "event": "Move", "from": "PENDING", "to": "MEMBER", "operator": "admin", "ops": ["C"] }, { "event": "Move", "from": "PENDING", "to": "OUTSIDER", "operator": "admin", "ops": ["C"] }, { "event": "Move", "from": "MEMBER", "to": "OUTSIDER", "operator": "Self", "ops": ["C"] }, { "event": "Move", "from": "MEMBER", "to": "OUTSIDER", "operator": "admin", "ops": ["C"] }, { "event": "Move", "from": "MEMBER", "to": "BLOCKED", "operator": "admin", "ops": ["C"] }, { "event": "Move", "from": "BLOCKED", "to": "OUTSIDER", "operator": "admin", "ops": ["C"] } ], "grants": [ { "event": "Grant", "operator": ["admin"], "scope": ["MEMBER"], "trait": ["muted"] }, { "event": "Grant", "operator": ["owner"], "scope": ["MEMBER"], "trait": ["admin"] }, { "event": "Grant", "operator": ["owner"], "scope": ["OUTSIDER", "MEMBER"], "trait": ["dataview"] }, { "event": "Revoke", "operator": ["admin"], "scope": ["MEMBER"], "trait": ["muted"] }, { "event": "Revoke", "operator": ["owner"], "scope": ["MEMBER"], "trait": ["admin"] }, { "event": "Revoke", "operator": ["owner"], "scope": ["OUTSIDER", "MEMBER"], "trait": ["dataview"] }, { "event": "Revoke", "operator": ["Self"], "scope": ["MEMBER"], "trait": ["admin"] } ], "transfers": [ { "trait": "owner", "scope": ["MEMBER"] } ], "slots": [ { "event": "Shared", "operator": "admin", "ops": ["C", "U"], "key": "topic" }, { "event": "Shared", "operator": "dataview", "ops": ["P"], "key": "topic" }, { "event": "Own", "operator": "MEMBER", "ops": ["C"], "key": "profile" }, { "event": "Own", "operator": "Sender", "ops": ["U"], "key": "profile" } ], "lifecycle": [ { "event": "Pause", "operator": "owner", "ops": ["C"] }, { "event": "Resume", "operator": "owner", "ops": ["C"] }, { "event": "Migrate", "operator": "owner", "ops": ["C"] }, { "event": "Terminate", "operator": "owner", "ops": ["C"] } ], "customs": [ { "event": "message", "operator": "MEMBER", "ops": ["C"] }, { "event": "message", "operator": "admin", "ops": ["D"] }, { "event": "message", "operator": "muted", "ops": ["_C", "_U"] }, { "event": "message", "operator": "dataview", "ops": ["P"] }, { "event": "message", "operator": "Sender", "ops": ["U", "D"] }, { "event": "message", "operator": "BLOCKED", "ops": ["_U", "_D"] }, { "event": "reaction", "operator": "MEMBER", "ops": ["C"] }, { "event": "reaction", "operator": "Sender", "ops": ["D"] }, { "event": "reaction", "operator": "muted", "ops": ["_C"] }, { "event": "reaction", "operator": "BLOCKED", "ops": ["_D"] }, { "event": "notice", "operator": "admin", "ops": ["C", "D"] }, { "event": "rotate", "operator": "admin", "ops": ["C"] } ], "init": [ { "identity": "", "state": "MEMBER", "traits": ["owner", "admin"] } ] } ``` #### Event-Operator Matrix | Event | MEMBER | OUTSIDER | PENDING | BLOCKED | owner(0) | admin(1) | muted(2) | dataview(3) | Self | Sender | | ----------------------- | ------ | -------- | ------- | ------- | -------- | -------- | -------- | ----------- | ---- | ------ | | message | CR | | | \_U\_D | | D | \_C\_U | P | | UD | | reaction | CR | | | \_D | | | \_C | | | D | | notice | R | | | | | CD | | | | | | rotate | R | | | | | C | | | | | | Shared(topic) | R | | | | | CU | | P | | | | Own(profile) | CR | | | | | | | | | U | | Move(OUTSIDER, PENDING) | R | | | | | | | | C | | | Gate(applications) | R | | | | C | C | | | | | | Move(OUTSIDER, MEMBER) | R | | | | | C | | | C | | | Gate(auto\_join) | R | | | | C | | | | | | | Move(OUTSIDER, BLOCKED) | R | | | | | C | | | | | | Move(PENDING, MEMBER) | R | | | | | C | | | | | | Move(PENDING, OUTSIDER) | R | | | | | C | | | | | | Move(MEMBER, OUTSIDER) | R | | | | | C | | | C | | | Move(MEMBER, BLOCKED) | R | | | | | C | | | | | | Move(BLOCKED, OUTSIDER) | R | | | | | C | | | | | | Grant(muted) | R | | | | | C | | | | | | Grant(admin) | R | | | | C | | | | | | | Grant(dataview) | R | | | | C | | | | | | | Revoke(muted) | R | | | | | C | | | | | | Revoke(admin) | R | | | | C | | | | C | | | Revoke(dataview) | R | | | | C | | | | | | | Transfer(owner) | R | | | | C | | | | | | | Pause | R | | | | C | | | | | | | Resume | R | | | | C | | | | | | | Migrate | R | | | | C | | | | | | | Terminate | R | | | | C | | | | | | Columns: States (MEMBER, OUTSIDER, PENDING, BLOCKED) → traits (owner, admin, muted, dataview) → Contexts (Self, Sender). *** *** ### Authorization Algorithm The authorization algorithm determines whether an identity can perform a specific operation on a specific event type. ``` isAuthorized(identity_bitmask, event_type, operation, is_self, is_sender) → bool 1. Extract State from bitmask (bits 0-7). 2. Collect allowed ops: a. State ops (from State column for identity's current State) b. trait positive ops (OR across all held trait flags) c. Self ops (if is_self == true: event.from == content.target) d. Sender ops (if is_sender == true: event.from == original_event.from) e. Public ops (always applies) 3. Collect denied ops: negative ops from all sources (State _X, trait _X, Self _X, Sender _X, Public _X) 4. Compute effective: effective = allowed − denied 5. Return: operation ∈ effective ``` No State gate. traits can extend any State with new capabilities. Safety against invalid State+trait combinations comes from operational constraints: * Move clears all traits * Grant validates target State (schema-defined) #### 4.1 Deny Override Negative ops always win over positive ops regardless of source. If the effective set has both C (from State) and \_C (from a trait, State, or Context), the result is: C is removed. *** *** ### Processing Pipeline The full event processing pipeline, from submission to commitment: ``` 1. Signature verification (is the event signed by the claimed identity?) 2. Duplicate check (has this event already been committed?) 3. Lifecycle check (is the enclave active, not paused/terminated?) 4. Gate check (is this event type/transition currently enabled?) 5. RBAC authorization (Section 4 algorithm) 6. Rank check (for AC events targeting another identity — see Rank Rule below) 7. Content validation (event-type-specific format checks) 8. State mutation (apply bitmask changes for AC events) 9. SMT update (write new bitmask, compute new root) 10. CT append (add event to the certificate transparency log) 11. Commit (finalize sequence number, return receipt) ``` **Rank Rule.** For any AC event (Move, Grant, Revoke) targeting another identity: if the operator holds any trait, and the target holds any trait, the operator's best rank (lowest rank number among held traits) MUST be strictly less than the target's best rank. If either party holds no traits, or the operator is Self-targeting, the rank check is skipped. Violation → reject (`RANK_INSUFFICIENT`). *** *** ### Event Processing When reading a target's bitmask from SMT, if no leaf exists, treat the bitmask as `0x00` (OUTSIDER, no traits). #### 8.1 Move Changes an identity's State. Clears all traits. The core lifecycle operation. **Event content**: ```json { "target": "", "from": "PENDING", "to": "MEMBER", "preserve": true } ``` The `preserve` field is a manifest entry selector, not a behavioral directive. The node matches the content's `from`, `to`, and `preserve` against the `moves` entries to find the authorizing entry. Two entries with the same `from→to` but different `preserve` values are distinct Moves. **Processing**: ``` 1. Resolve from/to State names to enum values. 2. Match against moves entries: find the entry where from, to, and preserve all match. If no match → reject (UNAUTHORIZED). 3. Read target's current bitmask from SMT. 4. Verify: current State enum == from value. If mismatch → reject (STATE_MISMATCH). 5. Compute new bitmask: a. Set State enum (bits 0-7) to the "to" value. b. If the matched moves entry has preserve: true → keep all trait flags. Otherwise (default): clear all trait flags. 6. If entire bitmask == 0: delete SMT leaf. Else: write new bitmask to SMT. ``` **Authorization**: Move events are authorized via the `moves` section of the manifest. Each entry specifies `from`, `to`, `operator`, `ops`, and optionally `preserve`. The content's `from`, `to`, and `preserve` fields together select the matching entry. **Move matching**: State is an enum. The check is exact: `current_state == from`. trait bits are ignored. No CAS on the full bitmask — only the State enum is compared. **Application payload**: Move content MAY carry additional application-defined fields alongside the required `target`, `from`, and `to` fields. The node does not interpret these fields — they are opaque payload for the application layer. Example: DM enclaves piggyback per-contact epoch key-establishment material on `Move(O→F)` via the [`ratchet-pair`](/spec/app/plugins/ratchet-pair) plugin (see [enclaves/dm.md](/spec/app/enclaves/dm)); Group enclaves piggyback MLS commits on admin-created membership Moves via the [`mls-lazy`](/spec/app/plugins/mls-lazy) plugin (see [enclaves/group.md](/spec/app/enclaves/group)). #### 8.2 Grant Adds a trait flag to an identity's bitmask. **Event content**: ```json { "target": "", "trait": "admin" } ``` If the trait has P (Push) ops in the schema, the event includes an endpoint: ```jsonc { "target": "", "trait": "dataview", "endpoint": "https://..." } ``` **Processing**: ``` 1. Resolve trait name to bit position (from manifest traits array). 2. Read target's current bitmask from SMT. 3. Verify: target's current State is in the allowed scope. If not → reject (INVALID_STATE_FOR_GRANT). 4. Rank check (see §7 Rank Rule). 5. Set the trait bit: new_bitmask = current | (1 << trait_bit). 6. If trait has P ops in schema AND endpoint is provided: register push endpoint (operational state, not in SMT). 7. Write new bitmask to SMT (create leaf if it doesn't exist). ``` **Authorization**: Grant events are authorized via Grant schema entries. Each entry specifies `operator` (who can grant), `scope` (what States the target MUST be in), and `trait` (what traits can be granted). **No separate `Grant_Push` event type.** The node detects P ops from the schema and handles endpoint registration as part of regular Grant. #### 8.3 Revoke Removes a trait flag from an identity's bitmask. **Event content**: ```json { "target": "", "trait": "admin" } ``` **Processing**: ``` 1. Resolve trait name to bit position. 2. Read target's current bitmask from SMT. 3. Rank check (see §7 Rank Rule). 4. Clear the trait bit: new_bitmask = current & ~(1 << trait_bit). 5. If new_bitmask == 0: REMOVE the leaf from the SMT (per smt.md §Zero Bitmask). Else: write new_bitmask to the SMT leaf as 32-byte big-endian bytes. ``` Revoking a trait the target doesn't have is a no-op (bit was already `0`). A zero-bitmask leaf MUST NOT exist in the SMT (smt.md §Zero Bitmask: "no roles" is semantically identical to "not in tree"). Implementations that write `0` instead of removing the leaf produce divergent SMT roots. Self is allowed as operator for Revoke (voluntarily drop own trait). #### 8.4 Transfer Atomically moves a trait from one identity to another. The operator MUST hold the trait being transferred. **Event content**: ```json { "target": "", "trait": "owner" } ``` **Processing**: ``` 1. Verify operator holds the trait being transferred. 2. Verify target is not the operator (no self-transfer). If same → reject (INVALID_TRANSFER_TARGET). 3. Read target's current bitmask from SMT. 4. Verify target does not already hold the trait. If already held → reject (TRAIT_ALREADY_HELD). 5. Verify: target's current State is in the allowed scope. If not → reject (INVALID_STATE_FOR_TRANSFER). 6. Clear trait bit on operator's bitmask. 7. Set trait bit on target's bitmask. 8. Write both bitmasks to the SMT — but if the operator's new bitmask is 0, REMOVE the operator's leaf instead (per smt.md §Zero Bitmask). The target side always writes (Transfer adds a trait, so the target's bitmask is non-zero by construction). ``` Every numbered step above is normative; the listed reject codes (`INVALID_TRANSFER_TARGET`, `TRAIT_ALREADY_HELD`, `INVALID_STATE_FOR_TRANSFER`) MUST be used verbatim. **Authorization**: Transfer events are authorized via the `transfers` section. Each entry specifies `trait` (which trait can be transferred) and `scope` (which States the target MUST be in). #### 8.5 Gate Pre-RBAC check that enables/disables specific events at the enclave level. Gates are open by default. **Event content**: ```json { "gate": "applications", "open": false } ``` **Processing**: ``` 1. Verify submitter is an allowed operator for this gate (from manifest). 2. Update gate state in KV Shared: Shared("gate:") = open value. ``` Gate state is checked BEFORE the RBAC authorization algorithm. If a gated event type/transition is closed, the event is rejected before RBAC runs. #### 8.6 AC\_Bundle Atomic multi-event bundle. All events succeed or none apply. **Event content**: ```json { "events": [ { "event": "Move", "target": "", "from": "PENDING", "to": "MEMBER" }, { "event": "Grant", "target": "", "trait": "admin" }, { "event": "Grant", "target": "", "trait": "moderator" } ] } ``` **Processing**: ``` 1. For each event in order: a. Validate against simulated intermediate state. b. Apply to simulated state. If any event fails → reject entire bundle. 2. If all pass: apply all changes atomically to SMT. ``` #### 8.7 Lifecycle Events Lifecycle events control enclave-level state. They store their state in KV Shared (SMT namespace `0x02`) with the reserved `lifecycle` key. ##### Pause Stops the enclave from accepting new events (except Resume). **Event content:** ```json {} ``` (Pause carries an empty content; the event type lives on the commit/event `type` field per spec.md §Commit Structure.) **Processing:** `processPause(active) ≡ Sum.inr(paused)`; every other input state yields `Sum.inl(invalidState)` (reject with `INVALID_LIFECYCLE_STATE`). On success, the node sets `Shared("lifecycle") = "paused"` via `applyPause(tree) ≡ tree.insert(LIFECYCLE_KV_KEY, encodeLifecycle(.paused))`. ##### Resume Lifts a pause, restoring normal operation. **Event content:** ```json {} ``` (Resume carries an empty content; the event type lives on the commit/event `type` field per spec.md §Commit Structure.) **Processing:** `processResume(paused) ≡ Sum.inr(active)`; every other input state yields `Sum.inl(invalidState)` (reject with `INVALID_LIFECYCLE_STATE`). On success, the node sets `Shared("lifecycle") = "active"` via `applyResume(tree) ≡ tree.insert(LIFECYCLE_KV_KEY, encodeLifecycle(.active))`. ##### Migrate Initiates migration of the enclave to a new sequencer node. Canonical content schema: see [`spec.md` §Migration](/spec/kernel/spec) — the authoritative shape is `{ new_sequencer, prev_seq, ct_root }`. Reproduced here for reference: **Event content:** ```json { "new_sequencer": "", "prev_seq": , "ct_root": "" } ``` **Processing:** `processMigrate(active) ≡ Sum.inr(migrated)`; every other input state yields `Sum.inl(invalidState)` (reject with `INVALID_LIFECYCLE_STATE`). The node additionally MUST verify that `prev_seq` matches the highest seq finalized by the current sequencer and `ct_root` matches the CT root at that seq (see spec.md §Migration for forced-takeover semantics). On success, the node sets `Shared("lifecycle") = "migrated"` (terminal) via `applyMigrate(tree) ≡ tree.insert(LIFECYCLE_KV_KEY, encodeLifecycle(.migrated))`. The `migrated` lifecycle state is terminal — no further events are accepted by the migrated sequencer. ##### Terminate Permanently shuts down the enclave. Irreversible. **Event content:** ```json {} ``` (Terminate carries an empty content; the event type lives on the commit/event `type` field per spec.md §Commit Structure.) **Processing:** `processTerminate(s) ≡ Sum.inr(terminated)` for every `s ∈ {active, paused, migrated}`, and `processTerminate(terminated) ≡ Sum.inl(invalidState)` (reject with `INVALID_LIFECYCLE_STATE`). On success, the node sets `Shared("lifecycle") = "terminated"` via `applyTerminate(tree) ≡ tree.insert(LIFECYCLE_KV_KEY, encodeLifecycle(.terminated))`. ##### Lifecycle Check Lifecycle state is checked at step 3 of the processing pipeline (Section 7), before Gate and RBAC checks: ``` Lifecycle check → Gate check → RBAC authorization ``` | State | Accepts events? | | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | active | All events | | paused | Only Resume (from owner) | | migrated | No events (terminal — sequencer has been replaced; subsequent activity belongs to the migrated sequence and is recorded in the new sequencer's CT) | | terminated | No events | #### 8.8 KV Events (Shared / Own) Mutable key-value state stored in SMT namespace `0x02`. KV events are NOT content events — they form their own category. Authorization uses the same schema matrix as all other events. ##### 8.8.1 Problem Content events are append-only (the CT is a log). To represent "current topic" or "my display name", apps would need to scan the CT for the latest event. KV events provide first-class mutable state with SMT inclusion proofs for the current value. ##### 8.8.2 Shared Enclave-wide singleton. One slot per key per enclave. Last write wins. **Event content:** ```json { "key": "topic", "value": "General Discussion" } ``` **SMT key:** `H(0x02 || key_name)` **SMT value:** `H(content)` Any actor with C on Shared (for matching key) can overwrite. History preserved in CT. ##### 8.8.3 Own Per-identity slot. One slot per key per identity. Each identity can only write to their own slot. **Event content:** ```json { "key": "profile", "value": { "display_name": "Alice", "status": "Available" } } ``` **SMT key:** `H(0x02 || key_name || commit.from)` **SMT value:** `H(content)` Per-identity isolation enforced by the node — the `from` in the SMT key is always `commit.from`. No identity field in content needed. ##### 8.8.4 Schema Representation KV events use the standard schema format with an additional `key` field: ```json { "event": "Shared", "operator": "admin", "ops": ["C"], "key": "topic" } { "event": "Shared", "operator": "MEMBER", "ops": ["R"], "key": "topic" } { "event": "Own", "operator": "MEMBER", "ops": ["C"], "key": "profile" } { "event": "Own", "operator": "Sender", "ops": ["U"], "key": "profile" } ``` The `key` field constrains which KV slot the operator can access. **Operations for KV:** | Op | Meaning | | -- | --------------------------------- | | C | Create or overwrite the value | | R | Read the current value | | U | Update an existing value | | D | Clear the value (remove SMT leaf) | | P | Push delivery on value change | | N | Notify on value change | ##### 8.8.5 Delete (D) * **Shared:** Remove SMT leaf at `H(0x02 || key)`. Value cleared. * **Own:** Remove SMT leaf at `H(0x02 || key || commit.from)`. Only the actor's own slot. ##### 8.8.6 Protocol-Reserved KV Keys Gate state (Section 8.5) is stored as KV Shared with the `gate:` key prefix: ``` Shared("gate:applications") = true/false ``` Gate and lifecycle KV slots are protocol-managed — written by Gate and lifecycle events, not arbitrary Shared writes. Reserved keys: `gate:*` prefix and `lifecycle`. ##### 8.8.7 Bounding No dynamic keys. Only keys declared in schema entries can be written: * **Shared:** bounded by manifest (only declared keys) * **Own:** bounded by RBAC (one slot per key per identity, identities controlled by Move) ##### 8.8.8 KV vs Content Events | Aspect | Content Event | KV Event | | -------- | -------------------- | ------------------------------------- | | History | Full history matters | Only current value matters | | SMT | No SMT entry | SMT leaf tracks current hash | | Proofs | CT inclusion only | SMT inclusion proof for current state | | Use case | Messages, reactions | Topic, settings, profiles, status | Both live in the same CT. KV events maintain a "current state" view in SMT `0x02`. ##### 8.8.9 Read API * Shared: `GET /enclave/{id}/kv/{key}` * Own: `GET /enclave/{id}/kv/{key}/{identity}` The SMT provides inclusion proofs for the current value hash. *** ## Sparse Merkle Tree (SMT) This document specifies the Enclave State SMT: a 168-bit-deep sparse Merkle tree keyed by `namespace || sha256(raw_key)[0:20]`, storing RBAC bitmasks, event status, and KV state. The SMT commits to current enclave state and is bound into the CT log via the per-bundle `state_hash`. *** ### Table of Contents 1. [Tree Structure](#tree-structure) 2. [Namespaces](#namespaces) 3. [Key Construction](#key-construction) 4. [Leaf Values](#leaf-values) 5. [Hash Construction](#hash-construction) 6. [Proofs](#proofs) 7. [Collision Analysis](#collision-analysis) 8. [Performance](#performance) 9. [State-Changing Events](#state-changing-events) 10. [State Binding](#state-binding) 11. [Proof Binding (CT + SMT)](#proof-binding-ct-smt) 12. [Storage](#storage) *** ### Tree Structure The following dimensions are normative; implementations MUST use these exact values. | Property | Value | | ---------------- | ------------------------------------------------------------------------ | | Total depth | 168 bits (21 bytes) | | Namespace prefix | 8 bits (1 byte) | | Entry path | 160 bits (20 bytes) | | Hash function | **Poseidon2 over BN254** (leaf/node hashing); SHA-256 for key derivation | | Empty value | `sha256("")` (empty-subtree sentinel, retained) | The 160-bit entry path matches Ethereum address length, providing the same collision safety properties. *** ### Namespaces The 1-byte namespace prefix separates independent state domains: | Namespace | Hex | Purpose | | ------------ | ----------- | ------------------------------------------------------------------------------------ | | RBAC | `0x00` | State + trait bitmask per identity | | Event Status | `0x01` | Update / Delete tracking | | KV State | `0x02` | Shared / Own application state (see [State-Changing Events](#state-changing-events)) | | Reserved | `0x03–0xff` | Future use | Namespaces `0x03` through `0xff` are RESERVED and MUST NOT be written by any current event. *** ### Key Construction #### RBAC Key ``` path = 0x00 || sha256(id_pub)[0:160 bits] ``` Where `id_pub` is the raw 32-byte secp256k1 x-only public key. Implementations MUST always compute `sha256(id_pub)` even though `id_pub` is already 32 bytes; this ensures uniform distribution across the tree. #### Event Status Key ``` path = 0x01 || sha256(event_id)[0:160 bits] ``` **Notation:** `sha256(x)[0:160 bits]` means the first 160 bits (20 bytes) of the SHA-256 hash. The full key is 21 bytes: 1-byte namespace prefix + 20-byte truncated hash. #### API Key Construction When calling the State Proof API, clients provide the 32-byte raw key (`id_pub` or `event_id`). The node internally constructs the 21-byte SMT key by: 1. Selecting namespace based on the `namespace` parameter. 2. Computing `sha256(key)[0:160 bits]`. 3. Concatenating: `namespace_byte || truncated_hash`. The response `k` field contains the full 21-byte SMT key for verification. *** ### Leaf Values #### RBAC ``` value = ``` RBAC bitmasks (State enum + trait flags packed per [`rbac/overview.md` §1.1](/spec/kernel/rbac)) MUST be encoded as **32-byte big-endian unsigned-integer byte strings**: * Pad with leading zeros to exactly 32 bytes. * Example: bitmask `0x100000002` → `0x0000.00100000002` (32 bytes). These 32 bytes are the SMT leaf value verbatim — they MUST NOT be wrapped in CBOR before hashing. The leaf hash is `leaf_hash = H(0x20 || key || value)` where `value` is the 32-byte byte string directly. In JSON proofs (see [proof.md](/spec/kernel/ct)), the same 32-byte value is hex-encoded as 64 characters. #### Zero Bitmask When the bitmask reduces to `0` (State is OUTSIDER AND no trait flags set — see [`rbac/overview.md`](/spec/kernel/rbac)), the leaf MUST be **removed** from the SMT. An identity with bitmask `0` is semantically equivalent to "not in tree". This follows the same pattern as Event Status where "(not in tree) = Active". A non-membership proof for an identity means "currently has bitmask 0 (OUTSIDER with no traits)"; it does NOT distinguish "never had any State / trait" from "had State / traits, all cleared." The SMT tracks current state only, not history. To prove historical assignment, use a CT inclusion proof for the originating Move or Grant event. #### Event Status The Event Status leaf values are normative; implementations MUST encode each status exactly as shown. The length difference unambiguously distinguishes Deleted from Updated. | Value | Meaning | | ------------------------------ | ------- | | (not in tree) | Active | | `0x00` (1 byte) | Deleted | | `` (32 bytes) | Updated | > **Wire format disambiguation.** In CBOR encoding, the 1-byte deleted marker (`0x00`) and the 32-byte `update_event_id` are distinguishable by their length prefix: > > * Deleted: CBOR encodes as `0x40 0x00` (1-byte bytestring) or `0x00` (integer zero). > * Updated: CBOR encodes as `0x58 0x20 <32 bytes>` (32-byte bytestring). > > This prevents any collision between deleted status and an `update_event_id` that happens to start with zeros. #### Proof Interpretation \| Proof type | SMT proof result | Interpretation | \|---|---| \| Event Status | Non-membership (null) | Active OR never existed | \| Event Status | Membership (`0x00`) | Deleted | \| Event Status | Membership (32 bytes) | Updated to this event | \| RBAC | Non-membership (null) | OUTSIDER with no traits (or never had any State / trait) | \| RBAC | Membership (32 bytes) | Has this State + trait bitmask | To distinguish "Active" from "never existed" for events, combine with a CT inclusion proof. To prove historical State / trait assignment, use a CT inclusion proof for the originating Move / Grant event. > **Distinguishing event outcomes.** > > For **Event Status proofs**, a non-membership proof (`null`) means: > > * The event is currently Active OR it never existed. > * To distinguish: obtain a CT inclusion proof for the event. > * If CT inclusion succeeds: event was created → currently Active. > * If CT inclusion fails: event never existed. > > For **RBAC proofs**, a non-membership proof means: > > * Identity currently has bitmask `0` (OUTSIDER with no traits). > * To prove historical State / trait assignment, obtain a CT inclusion proof for the originating Move / Grant event. *** ### Hash Construction The hash constructions below are normative; implementations MUST use these exact domain separators and operand orders. #### Leaf Hash ``` leaf_hash = H(0x20, key, value) ``` #### Internal Node Hash ``` node_hash = H(0x21, left_child, right_child) ``` #### Empty Node ``` empty_hash = sha256("") = 0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ``` Implementations MUST hardcode the empty-node constant and MUST NOT compute it at runtime. *** ### Proofs SMT proof structure and verification are defined in [proof.md](/spec/kernel/ct). *** ### Collision Analysis | Entries | Collision probability | | ------------ | --------------------- | | 1 billion | \~10⁻³⁰ | | 1 trillion | \~10⁻²⁴ | | 100 trillion | \~10⁻²⁰ | Same security margin as Ethereum addresses. *** ### Performance | Operation | Cost | | ------------------ | --------------------- | | Single SHA-256 | \~1 μs | | SMT update | 168 hashes ≈ \~170 μs | | Proof verification | 168 hashes ≈ \~170 μs | | Throughput | \~6 000 updates / sec | > Note: computation is always O(168) regardless of tree sparsity. Empty siblings are still hashed. *** ### State-Changing Events Only certain events modify the SMT. #### RBAC Namespace (`0x00`) Each RBAC event MUST update the SMT exactly as specified below. | Event | SMT Update | | ---------- | ------------------------------------------------------------------------------------------------------------------ | | Manifest | Initialize leaves from `init` entries (State + trait bits). | | Move | Set State enum (bits 0-7); clear trait flags unless `preserve: true`. | | Grant | Set trait bit for target identity; optionally registers push endpoint. | | Revoke | Clear trait bit for target identity. | | Transfer | Clear trait bit from operator, set trait bit on target (atomic). | | Gate | `applyGate(tree, α, s) ≡ tree.insert(gateKVKey(α), s)`, where `gateKVKey(α) ≡ buildKVKey("gate:" ++ α)` in NS\_KV. | | AC\_Bundle | Batch updates (atomic). | #### KV State Namespace (`0x02`) | Event | SMT Update | | ------ | ---------------------------------------------------------------------- | | Shared | `SMT[H(0x02 \|\| key)] = H(content)` — enclave-wide singleton | | Own | `SMT[H(0x02 \|\| key \|\| identity)] = H(content)` — per-identity slot | #### Event Status Namespace (`0x01`) | Event | SMT Update | | ------ | ---------------------------------------- | | Update | `SMT[target_event_id] = update_event_id` | | Delete | `SMT[target_event_id] = 0` | > **Update chaining.** Multiple Updates to the same event are allowed. Each Update overwrites the previous value: > > * First Update: `SMT[event_A] = update_event_B`. > * Second Update: `SMT[event_A] = update_event_C`. > * Final state: `SMT[event_A] = update_event_C`. > > The SMT always stores the latest `update_event_id`. To trace the full Update history, use CT inclusion proofs to find all Update events referencing the original `target_event_id`. #### Lifecycle Events (KV State Namespace `0x02`) | Event | SMT Update | | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Pause | `applyPause(tree) ≡ tree.insert(LIFECYCLE_KV_KEY, encodeLifecycle(.paused))` — writes byte `0x01` to the lifecycle slot. | | Resume | `applyResume(tree) ≡ tree.insert(LIFECYCLE_KV_KEY, encodeLifecycle(.active))` — writes byte `0x00`. | | Terminate | `applyTerminate(tree) ≡ tree.insert(LIFECYCLE_KV_KEY, encodeLifecycle(.terminated))` — writes byte `0x02`. | | Migrate | `applyMigrate(tree) ≡ tree.insert(LIFECYCLE_KV_KEY, encodeLifecycle(.migrated))` — writes byte `0x03` and binds the new sequencer / checkpoint (see [`spec.md` §9](/spec/kernel/spec)). | Lifecycle events MUST write the same `Shared("lifecycle")` KV slot used by other Shared singletons (see [`spec.md` §6](/spec/kernel/spec) and [`rbac.md` §8.7](/spec/kernel/rbac)). Formally: `LIFECYCLE_KV_KEY = buildKVKey("lifecycle", none)`, and all four `applyPause / applyResume / applyTerminate / applyMigrate` target this key — only the encoded byte differs. The SMT carries the current lifecycle state directly — it is NOT derived from the event log. #### Events that do NOT modify SMT * Content events (e.g., a custom `message` event) MUST NOT modify the SMT directly. *** ### State Binding The SMT root (`state_hash`) is bound to the event log via the Certificate Transparency (CT) structure, not via the event hash itself. #### CT Leaf Hash The CT leaf hash construction below is normative. ``` leaf_hash = H(0x00, events_root, state_hash) ``` Where: * `events_root` — Merkle root of event IDs in this bundle (see [spec.md](/spec/kernel/spec) for bundle structure). * `state_hash` — SMT root AFTER the last event in this bundle is applied. > **Note.** With bundling, `state_hash` is recorded per bundle, not per event. For `bundle.size == 1`, `events_root` equals the single `event_id`. *** ### Proof Binding (CT + SMT) The CT root proves both log integrity AND state integrity. To verify a state claim, the client needs: 1. **CT inclusion proof** — proves the event exists at position `N` in the log. 2. **SMT proof** — proves the state against `state_hash` at position `N`. #### Verification Flow 1. Obtain the event, bundle membership proof, and CT inclusion proof from the node. 2. Verify bundle membership: confirm `event_id` is in the bundle's `events_root`. 3. Verify CT inclusion proof: recompute path from `leaf_hash = H(0x00, events_root, state_hash)` to CT root. 4. Verify SMT proof against the `state_hash` from step 3. > **Unified checkpoint.** The CT root alone is sufficient to prove both: > > * Event log integrity (which events exist, in what order). > * State integrity (what the SMT root was after each event). > > This simplifies migration and audit: a single `ct_root` value commits to the entire enclave history and state. *** ### Storage * Implementations SHOULD store only non-empty nodes. * Empty subtrees MUST be computed on-demand from the precomputed `empty_hash` constant. * Historical states MAY be pruned. *** ### Enclave State SMT The enclave maintains a **single Sparse Merkle Tree (SMT)** that stores both: * **RBAC state** (namespace `0x00`) — identity → bitmask (State enum + trait flags). * **Event status state** (namespace `0x01`) — event ID → status (active/updated/deleted). * **KV state** (namespace `0x02`) — key → value hash (Shared/Own slots, lifecycle, gates). **Design:** The SMT uses: * **Trimmed depth** (shorter than 256 bits) for efficiency * **Flag bits** to distinguish entry types (RBAC vs Event Status) *Implementation details (depth, flag encoding, proof format) are specified in [smt.md](/spec/kernel/smt).* The SMT leaf/node hash is **Poseidon2 over BN254** (SNARK-native), which lets the node emit a **zero-knowledge validity proof** that a state transition `old_root → new_root` is valid under the RBAC rules — verifiable by a client in O(1) without replaying the log. This works either as a bounded per-transition proof or as a single **folded** (Nova IVC) proof attesting the enclave's entire *unbounded* transition history in O(1). See [zk.md](/spec/node/zk) → Folding. **RBAC Entries:** * **Path:** derived from `id_pub` (trimmed + flag) * **Leaf value:** identity bitmask (State enum in bits 0-7, trait flags in bits 8+) **Event Status Entries:** * **Path:** derived from `event_id` (trimmed + flag) * **Leaf value:** status + reference to U/D event **Root Hash:** The **SMT root hash** represents the complete enclave state (both RBAC and event status). This single root can be used to verify any state query. *** ### SMT Proofs SMT proofs verify state claims (RBAC assignments, event status) against the `state_hash`. #### Proof Structure ``` { key: <21 bytes>, value: , bitmap: <21 bytes>, siblings: [, ...] } ``` Where: * **key** — 21-byte SMT key (namespace + truncated path) * **value** — leaf value (null for non-membership proof) * **bitmap** — 168 bits indicating which siblings are present (1) vs empty (0) * **siblings** — only non-empty sibling hashes, in order Empty siblings are omitted; verifier uses `empty_hash` for missing slots. **Bitmap Bit Ordering (LSB-first):** Bit N corresponds to depth N in the tree, where depth 0 is closest to the root and depth 167 is the leaf level. **Bit numbering within bytes:** LSB-first. Bit 0 is the least significant bit (rightmost). Bit 7 is the most significant bit (leftmost). **Bitmap Example:** For a proof with non-empty siblings at depths 0, 10, and 167: * Bitmap bit 0 = 1 (sibling at root level) * Bitmap bit 10 = 1 (sibling at depth 10) * Bitmap bit 167 = 1 (sibling at leaf level) * All other bits = 0 Serialized as 21 bytes (168 bits), with bit 0 = LSB of byte 0. The `siblings` array contains exactly 3 hashes, in **deepest-first** order: index 0 is the sibling at depth 167 (leaf level), index 1 at depth 10, index 2 at depth 0 (root level). This ordering matches the verification walk (leaf-to-root), which consumes `siblings` front-to-back.Reference: `verify` / `_smtVerifyStep` and `prove` / `verify`. **Bit-to-Byte Mapping:** Depth D maps to: `byte[D / 8]`, bit `(D % 8)` where bit 0 is LSB. Example: depth 10 → byte\[1], bit 2 (since 10 / 8 = 1, 10 % 8 = 2) **Hex Serialization:** The 21-byte bitmap is serialized as a hex string in standard byte order: * Byte 0 (containing bits 0-7) is the first two hex characters * Byte 20 (containing bits 160-167) is the last two hex characters Example: Depths 0, 10, 167 have siblings: * Byte 0 = `0x01` (bit 0 set) * Byte 1 = `0x04` (bit 10 = bit 2 of byte 1) * Byte 20 = `0x80` (bit 167 = bit 7 of byte 20) * Hex: `"010400.80"` (42 chars total) #### Verification 1. Compute leaf hash: `H(0x20, key, value)` (or `empty_hash` if value is null) 2. For each depth from 167 to 0: * If bitmap bit is 1: use next sibling from array * If bitmap bit is 0: use `empty_hash` * Compute: `H(0x21, left, right)` 3. Compare with expected root (`state_hash`) #### Non-Membership Verification A non-membership proof proves that a key does not exist in the SMT. **Verification:** 1. Verify that `value` is `null` 2. Compute the expected leaf hash as `empty_hash` (the key has no value) 3. Follow the same path computation as membership verification 4. Compare result with expected root (`state_hash`) If the computed root matches and the path is valid with an empty leaf, the key does not exist in the tree. #### Empty Node Hash ``` empty_hash = sha256("") = 0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ``` Hardcoded constant — do not compute at runtime. *** ### SMT Proof Wire Format (JSON) ```json { "k": "", "v": "", "b": "", "s": ["", ...] } ``` | Field | Encoding | | ----- | -------------------------------------------------- | | k | Hex string, 42 chars (21 bytes) | | v | Hex string or JSON null (see Value Encoding below) | | b | Hex string, 42 chars (21 bytes) | | s | Array of hex strings, 64 chars each (32 bytes) | **Value Encoding by Proof Type:** | Proof Type | v Field | | ---------------------- | ------------------------------------------------ | | RBAC membership | Hex string, 64 chars (32-byte padded bitmask) | | Event Status (deleted) | `"00"` (1 byte) | | Event Status (updated) | Hex string, 64 chars (32-byte update\_event\_id) | | Non-membership | `null` (JSON null, not string) | ## ENC Protocol Specification ENC (Encode, Encrypt, Enclave) is a protocol for building **log-based**, **verifiable**, **sovereign** data structures with **Role-Based Access Control**. This document specifies the core protocol: cryptographic primitives, the Commit / Event / Receipt wire protocol, event semantics (P / N / U / D), and the enclave lifecycle (Manifest / Update / Delete / Pause / Resume / Terminate). Companion docs cover [RBAC v2](/spec/kernel/rbac), the storage layer ([`smt.md`](/spec/kernel/smt), [`ct.md`](/spec/kernel/ct), [`zk.md`](/spec/node/zk)), enclave [migration](/spec/node/migration), the [node API](/spec/node/node-api), and the [enclave profiles](/spec/app/enclaves) / [confidentiality plugins](/spec/app/plugins). *** ### Table of Contents 1. [Protocol Summary](#protocol-summary) 2. [Trust Model](#trust-model) 3. [Enclave](#enclave) 4. [Node](#node) 5. [Client](#client) 6. [Signature Schemes](#signature-schemes) 7. [Hash Function](#hash-function) 8. [Hash Encoding](#hash-encoding) 9. [Hash Prefix Registry](#hash-prefix-registry) 10. [Identity Key](#identity-key) 11. [Delegated Sub-keys](#delegated-sub-keys) 12. [Wire Format](#wire-format) 13. [Commit](#commit) 14. [Event Finalization](#event-finalization) 15. [Event](#event) 16. [Receipt](#receipt) 17. [P (Push) and N (Notify)](#p-push-and-n-notify) 18. [U (Update) and D (Delete)](#u-update-and-d-delete) 19. [Event Categories](#event-categories) 20. [Predefined Event Type Registry](#predefined-event-type-registry) 21. [AC Events](#ac-events) 22. [Content Events](#content-events) 23. [Event Type Registry (Appendix)](#event-type-registry-appendix) 24. [Enclave Lifecycle](#enclave-lifecycle) 25. [Manifest](#manifest) 26. [Update](#update) 27. [Delete](#delete) 28. [Templates](#templates) 29. [Migration](#migration) *** ### Protocol Summary **ENC** (encode, encrypt, enclave) is a protocol for building **log-based, verifiable, sovereign data structures** with **Role-Based Access Control (RBAC)**. Key properties: * **Append-only event log** — immutable sequence of finalized events * **Verifiable state** — Sparse Merkle Tree (SMT) for RBAC and event status * **Cryptographic proofs** — Certificate Transparency (CT) for log integrity * **Single sequencer** — one node orders and finalizes events per enclave *** ### Trust Model The ENC protocol defines two query paths with different trust properties: #### Enclave (Source of Truth) * Queries to the enclave node return **verifiable proofs** * Clients can verify RBAC state, event existence, and event status (U/D) against the SMT root * The enclave is the **final arbiter** when disputes arise #### DataView (Convenience) A **DataView** is a separate service that indexes, aggregates, and transforms enclave data for efficient querying. * Clients **trust** DataView responses without cryptographic verification * DataView is optimized for performance and flexibility, not provability * If a client suspects incorrect data, they SHOULD verify against the enclave directly. **Data Access Methods:** A DataView can receive enclave data through three methods, each requiring a State or trait assignment: | Method | Permission | Description | | ------ | ---------- | ------------------------------------------------------------------- | | Query | R (Read) | DataView queries the enclave directly as a member | | Push | P | Node delivers full events to DataView proactively | | Notify | N | Node delivers lightweight notifications; DataView fetches as needed | **Recommendation:** Applications SHOULD use DataView for routine queries and reserve direct enclave queries for dispute resolution, high-value transactions, and audit trails. *** ### Enclave An **Enclave** is a log-based, verifiable, sovereign data structure with **Role-Based Access Control (RBAC)**. An enclave maintains: * An **append-only event log** — immutable sequence of finalized events * A **verifiable state** (SMT) — unified tree storing RBAC state and event status * A **structural proof** — append-only Merkle tree proving log integrity (see [`ct.md`](/spec/kernel/ct)) *** ### Node A **Node** is a host for one or more enclaves. A node is responsible for: * Storing data for enclave * Serving queries * Producing cryptographic proofs * Sequencing commits into events (if acting as sequencer) #### Sequencer Model Each enclave has a **single sequencer** — the node responsible for ordering and finalizing commits. **Assignment:** * The node that accepts and finalizes the Manifest event becomes the sequencer for that enclave. * The sequencer's identity key is recorded in the Manifest event's `sequencer` field. * Sequencer discovery is out of scope for the core protocol. The Registry enclave is one discovery mechanism (see [`enclaves/registry.md`](/spec/app/enclaves/registry)); other mechanisms (DNS, well-known URLs, bootstrap config) are equally valid. **Responsibilities:** * Accept valid commits and assign `seq`, `timestamp`. * Sign events with `seq_sig`. * Maintain the append-only Merkle tree. * Maintain the Enclave State SMT. * Deliver P (Push) and N (Notify) to registered recipients. **Sequencer change:** * See [`migration.md`](/spec/node/migration) for the sequencer handoff protocol. > **Note:** Multi-sequencer (consensus-based) models are out of scope for v2. *** ### Client A **Client** is a software agent that **controls an Identity Key** by holding (or having authorized access to) the corresponding private key `id_priv`, and can **produce signatures or decrypt/encrypt** on behalf of the identity `id_pub` according to the ENC protocol. Notes: * A client can be an app, service, or device component. * `id_priv` can be held directly or accessed via a secure signer (e.g., OS keystore, HSM, enclave, wallet). ### Identity Custody An identity has a **custody** descriptor naming how `id_priv` is reached by the client. Two values are defined: ```text local — the client holds `id_priv` directly in memory (or in an equivalent process-local store accessible synchronously and without consent gating). delegated — the client has no direct access to `id_priv`. Sign / encrypt / decrypt are exposed as an oracle by an external signer (NIP-07 / NIP-46, WebAuthn, OS keystore with biometric consent gating, KMS / HSM, threshold signer, hardware wallet, …). Every operation that needs `id_priv` is one round trip through the oracle. ``` Custody is **orthogonal to the signature scheme and to the ciphersuite**. A local-custody client and a delegated-custody client running the same signature scheme produce byte-identical signatures; the same plugin running the same suite produces byte-identical ciphertexts under either custody. The visible difference is operational: under delegated custody every `id_priv`-using operation requires an online round-trip to the signer, and the signer MAY refuse (consent gating). #### Signer oracle interface When custody is `delegated`, the signer exposes exactly these four operations: | Operation | Input | Output | Note | | ---------------------------------- | -------------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------- | | `getPublicKey()` | — | 32-byte `id_pub` | Returned per-identity; the signer MAY manage many identities. | | `sign(message)` | message bytes | signature (per `alg`) | One online round-trip; the signer MAY require user consent. | | `encrypt(peer, plaintext, suite)` | peer pubkey, plaintext bytes, suite id | ciphertext per the suite's wire framing | Encrypts under the suite's KDF chain rooted in `ECDH(id_priv, peer)`. One round-trip per ciphertext. | | `decrypt(peer, ciphertext, suite)` | peer pubkey, ciphertext, suite id | plaintext bytes | One round-trip per ciphertext; the signer MAY refuse or rate-limit. | A signer that does not expose all four operations is treated as `local`-only for the missing operations — typically a hardware signer that exposes `sign` but not `encrypt`/`decrypt` cannot serve a delegated-custody encrypted enclave at all. #### Custody-conditional invariants The plugin catalog's "CT-replayable from `identity_priv`" guarantee (see [`plugins.md` §Capability matrix](/spec/app/plugins#capability-matrix)) is **custody-conditional**: * Under `local` custody: every plugin in the catalog is CT-replayable from `identity_priv` offline. The "NO" column on the `Post-compromise security vs. identity_priv` row reflects the protocol's accepted trust-root tradeoff. * Under `delegated` custody: `identity_priv` is unreachable, so offline bulk re-derivation is impossible. Recovery is **decrypt-on-demand**: one signer round-trip per ciphertext, consent-gated by the oracle. Plugins in the catalog MUST NOT assume offline bulk re-derivation. The `Multi-device via identity_priv` row (capability axis 7) and the `Post-compromise security vs. identity_priv` row (capability axis 10) are likewise custody-conditional: their guarantees, as stated in the matrix, apply only under `local` custody. A delegated client that wants to share decryption across devices does so by sharing access to the signer oracle, NOT by exporting `id_priv`. #### Authentication of custody Custody is NOT advertised on the wire, NOT carried in commit signatures, and NOT inferable from the ciphertext. It is a **client-local property** — the receiver cannot tell from a ciphertext whether the sender's `id_priv` is local or delegated. The wire shape is the same under both custodies, so a bridge or a custody migration is invisible to peers. The signer interface MAY change over an identity's lifetime (a fresh wallet may start `local` and later move to a hardware signer; a Nostr-bridged identity is `delegated` from the start). Plugins MUST NOT key any of their on-the-wire framing on custody. *** ### Signature Schemes The ENC protocol supports multiple signature schemes over the **secp256k1** curve. The scheme is declared by the `alg` field on commits and events. #### Supported Algorithms | `alg` | Scheme | Specification | Signature Format | Note | | | | ----------- | ------- | ------------------------ | ---------------- | ---- | ---------- | ------------------------------------- | | `"schnorr"` | Schnorr | BIP-340 | 64 bytes (R | | s) | Default | | `"ecdsa"` | ECDSA | SEC 1 v2 §4.1 + RFC 6979 | 64 bytes (r | | s compact) | For hardware / secure-element signers | When the `alg` field is absent, `"schnorr"` is assumed. Nodes MUST reject commits where `alg` is present but not one of the supported values. Nodes MUST NOT skip signature verification for any `alg` value. #### Identity Key Identity keys (`id_pub`) are always **32-byte x-only secp256k1 public keys** (BIP-340 format), regardless of which signature algorithm is used. This is the canonical identity. To verify an ECDSA signature against an identity key, the node derives the compressed ECDSA public key: `0x02 || id_pub`. This works because BIP-340 x-only keys always have even y-coordinate, so the compressed prefix is always `0x02`. #### Schnorr (BIP-340) * All `schnorr(message, private_key)` operations MUST use the secp256k1 elliptic curve. * Signatures MUST conform to BIP-340 (32-byte x-only public keys, 64-byte signatures). **Deterministic Signing:** All Schnorr signatures MUST be deterministic. Implementations MUST use the default nonce derivation specified in BIP-340, which derives the nonce from: * The private key * The message being signed * Auxiliary randomness MUST be set to 32 zero bytes. Implementations MUST NOT use random auxiliary data. #### ECDSA (secp256k1) * All `ecdsa(message, private_key)` operations MUST use the secp256k1 elliptic curve. * Signatures MUST use **compact encoding**: raw `r || s` (32 bytes each, big-endian, total 64 bytes). NOT DER encoding. * Both `r` and `s` MUST be in the range `[1, n-1]` where `n` is the secp256k1 curve order. * Signatures MUST use **low-s normalization** (s ≤ n/2) per BIP-62 / BIP-146. * Signing MUST be deterministic per RFC 6979. * The `from` field still contains the BIP-340 x-only public key (32 bytes), not the ECDSA compressed key. * Implementations MUST use the BIP-340-adjusted private key (negated if the public point has odd y) for ECDSA signing, ensuring consistency with the `0x02 || id_pub` derivation. #### Determinism Both schemes produce deterministic signatures: the same message signed with the same key always produces the same signature. This ensures event IDs are deterministic and verifiable. #### Verification Verifiers MUST check the `alg` field and use the corresponding algorithm. Verifiers MUST NOT attempt multiple algorithms as a fallback. The verification path is always deterministic: * `"schnorr"` (or absent): `schnorr_verify(hash, sig, from)` * `"ecdsa"`: `ecdsa_verify(hash, sig, 0x02 || from)` #### Sequencer and Server Signatures The `alg` field applies only to **client signatures** (`sig`). Sequencer signatures (`seq_sig`), STH signatures, and session token signatures always use **Schnorr (BIP-340)**. Sequencers are server nodes without hardware signing constraints. #### Security Considerations Using the same secp256k1 private key for both Schnorr and ECDSA is safe under the following conditions, which this spec enforces: 1. **Deterministic nonce generation** — BIP-340 and RFC 6979 derive nonces from different domain-separated functions. The same `(key, message)` pair produces different nonces in each scheme, preventing nonce reuse across algorithms. 2. **No cross-algorithm forgery** — Schnorr and ECDSA have structurally different verification equations. A valid signature under one scheme cannot be reinterpreted as valid under the other. 3. **`alg` field integrity** — If `alg` is tampered in transit, signature verification fails. This is a denial-of-service (commit rejected), not a forgery. *** ### Hash Function * All `sha256()` operations use SHA-256 as defined in FIPS 180-4. *** ### Hash Encoding All hash pre-images MUST be serialized using **CBOR** (RFC 8949) with **deterministic encoding** before hashing. **Canonical Hash Function:** This specification defines: ``` H(fields...) = sha256(cbor_encode([fields...])) ``` All hash formulas in this document use `H()` implicitly. The notation: ``` sha256([0x10, enclave, from, type, content_hash, exp, tags]) ``` it means: ``` sha256(cbor_encode([0x10, enclave, from, type, content_hash, exp, tags])) ``` **CBOR Encoding Rules:** * Implementations MUST use deterministic CBOR encoding (RFC 8949 Section 4.2). * Integer prefixes (`0x00`, `0x10`, etc.) are encoded as CBOR unsigned integers. * Binary data (hashes, public keys) are encoded as CBOR byte strings. * Strings (content, type) are encoded as CBOR text strings (UTF-8). * Empty string `""` is encoded as CBOR text string with length 0 (`0x60`). * Arrays are encoded as CBOR arrays with definite length. * The `tags` field is a CBOR array of tags, and **each tag is itself a definite-length CBOR array of one or more CBOR text strings**. A tag's element count is its actual arity — it is **NOT fixed at 2**. Tags are variable-length (Nostr-style): a tag MAY carry more than a `[key, value]` pair (e.g. `[mention, pubkey, relay]`), and **every** element participates in the hash pre-image. Encoders MUST NOT assume a fixed tag arity nor truncate elements past index 1; the per-tag array header MUST carry the tag's true length. *** ### Hash Prefix Registry To prevent hash collisions between different data types, all hash operations use a unique single-byte prefix: | Prefix | Purpose | | ------ | --------------------------- | | `0x00` | CT leaf (RFC 9162) | | `0x01` | CT internal node (RFC 9162) | | `0x10` | Commit hash | | `0x11` | Event hash | | `0x12` | Enclave ID | | `0x20` | SMT leaf | | `0x21` | SMT internal node | Prefixes `0x02`–`0x0f`, `0x13`–`0x1f`, and `0x22`–`0x2f` are reserved for future use. **String Prefixes for Signatures:** Signature contexts use string prefixes (not byte prefixes) for domain separation: | Context | Prefix String | | ------------- | ---------------- | | STH signature | `"enc:sth:"` | | Session token | `"enc:session:"` | String prefixes are concatenated with data before hashing: `sha256(prefix || data)`. *** ### Identity Key An **Identity Key** is a 256-bit public key (`id_pub`) used to represent an identity in the ENC protocol. *** ### Delegated Sub-keys An identity MAY delegate a **scoped, revocable** capability to a separate **sub-key** — for example, to authorize an autonomous agent or a per-device operating key without exposing the identity key. The sub-key holds its own keypair; the identity issues a **grant** binding it. #### Cert (Cosigned Sub-key Authorization) A sub-key authorization is a **cosigned cert** — both the parent identity and the sub-key sign, so a verifier knows the identity authorized the sub-key AND the sub-key holder accepted the binding. The cosignature shape is required so MetaMask-style ECDSA-only parent identities (which sign via EIP-191 and cannot perform BIP-340 Schnorr) can authorize Schnorr-native sub-keys without exposing the parent private key. Wire shape: ```json { "subkey": "<32-byte hex; sub-key's x-only public key>", "parentId": "<32-byte hex; parent identity x-only public key>", "expiry": 1706000000, "ackSig": "", "authSig": "" } ``` | Field | Description | | | | | | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | - | - | - | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `subkey` | The sub-key's 32-byte x-only public key (the operating key the sub-key holder controls). | | | | | | `parentId` | The parent identity's `id_pub` (32-byte x-only). On verification this MUST equal `commit.from` — a cert binds the sub-key to **one specific parent identity**. | | | | | | `expiry` | Unix-seconds expiry. The cert is rejected if `expiry < now`. | | | | | | `ackSig` | Sub-key acknowledgment: `schnorr_sign("ENC sub-key ack v1\|\|", subkey_priv)`. Proves the sub-key holder accepted the binding to this parent. Domain separator string: `"enc:subkey-ack:v1\|"`. | | | | | | `authSig` | Parent authorization: `ECDSA-recoverable_sign( eip191_hash("enc:authorize-subkey:v1\|\|"), parent_priv )`. 65 bytes (\`r | | s | | v`where`v`∈ {27, 28} or {0, 1} with EIP-1559 offset).`ecrecover`MUST yield a 33-byte compressed pub whose x-coordinate equals`parentId\`. The EIP-191 hash makes this signable by MetaMask without granting ENC any access to the parent private key. | The cert as a whole is a one-shot artifact: the parent doesn't need to be online for the sub-key to operate; the sub-key submits the cert alongside the commit and the node verifies it offline. #### Cert-Authorized Commits When a commit is signed by a sub-key (not the parent identity directly), the commit MUST carry a `cert` field: ```json { "hash": "", "enclave": "", "from": "", "type": "", "content": "", "content_hash": "", "exp": 1706000000000, "tags": [...], "cert": { subkey, parentId, expiry, ackSig, authSig }, "sig": "", "alg": "schnorr" } ``` * `commit.from` is the **parent identity** (the cert's `parentId`). This is what RBAC, `reg_identity` lookups, and CT membership are keyed to — the sub-key's existence is transparent to authorization at that layer. * `commit.sig` is the SUB-KEY's Schnorr signature over `commit.hash` (using the standard commit hash construction). The sub-key signs the same digest the parent identity would have, with the cert proving the parent delegated this capability. * `commit.alg` for the sub-key signature is `"schnorr"` (sub-keys are always Schnorr — see Status note below). #### Verification A verifier accepts a cert-signed commit only if **all** hold: 1. `commit.cert.parentId` equals `commit.from` (case-insensitive hex). 2. `commit.cert.expiry > now()` (current wall-clock seconds). 3. `commit.cert.ackSig` is a valid Schnorr signature by `commit.cert.subkey` over `sha256("enc:subkey-ack:v1|" || parentId_hex || "|" || expiry_decimal)`. 4. `commit.cert.authSig` is a valid recoverable ECDSA signature over `eip191_hash("enc:authorize-subkey:v1|" || subkey_hex || "|" || expiry_decimal)`, AND the recovered uncompressed public key's x-only encoding equals `commit.cert.parentId`. 5. `commit.sig` is a valid Schnorr signature by `commit.cert.subkey` over `commit.hash`. If any check fails, the commit is rejected. The `alg` field on the outer commit is `"schnorr"` (the sub-key's signature scheme); `alg = "ecdsa"` is NOT compatible with cert-authorized commits — direct-ECDSA commits use the parent ECDSA path (no cert), not cosign. #### Rotation, Revocation * **Rotation:** issue a new cert for a new sub-key; the parent identity (`id_pub`) is unchanged so peers continue to recognise the same identity. Sub-keys rotate freely beneath the stable identity. * **Revocation:** a cert is revoked by `expiry`, or explicitly via a future revocation event (post-v2 — see Status note below). A compromised sub-key thus has a bounded blast radius and never exposes the parent private key. > **Note — derivation vs. authorization.** A cert proves the identity *authorized* the sub-key, and (via `ackSig`) that the sub-key is *held*. It does **not** prove the sub-key was *derived from* the identity key; an x-only-only authorization cannot enforce derivation. Verifier-enforced derivation requires a key-tweak relationship or a zero-knowledge proof and is out of scope here. *** ### Wire Format **Transport Encoding:** The normative wire format is **JSON**. CBOR is used only for hash computation, not transport. **JSON Encoding:** When serializing for transport or storage as JSON: | Type | Encoding | Example | | --------------------- | --------------------------- | ------------------------- | | Hash (32 bytes) | Hex string, no prefix | `"a1b2c3..."` (64 chars) | | Public key (32 bytes) | Hex string, no prefix | `"a1b2c3..."` (64 chars) | | Signature (64 bytes) | Hex string, no prefix | `"a1b2c3..."` (128 chars) | | RBAC bitmask | Hex string with `0x` prefix | `"0x100000002"` | | Content | UTF-8 string | `"hello world"` | | Binary in content | Base64 encoded | `"SGVsbG8="` | | Integers | JSON number | `1706000000` | **Consistency:** All bitmask values (State + trait flags) in JSON (event content, proofs, API responses) MUST use the `0x` hex prefix format. **CBOR Encoding (commit / event pre-image):** When serializing for hashing or binary transport: | Type | Encoding | | --------------------------- | ------------------------------- | | Hash, public key, signature | CBOR byte string (major type 2) | | Content, type | CBOR text string (major type 3) | **RBAC bitmask (SMT leaf, NOT CBOR):** RBAC bitmasks (State + trait flags) are NOT encoded as CBOR; they are written into SMT leaves as **32-byte big-endian unsigned-integer byte strings** and hashed via `leaf_hash = H(0x20 || key || value)` (raw concatenation, not CBOR-wrapped). See [smt.md](/spec/kernel/smt) §Role-Based Access Control. Outside the SMT (event content, JSON proofs, API responses), bitmasks render as `0x`-prefixed hex per the JSON table above. *** ### Commit A **Commit** is a client-generated, signed message proposing an event. A commit: * Is authored by a client * Is cryptographically bound to its content and metadata * Has **not yet** been ordered or finalized by a node #### Commit Structure ``` { hash: , enclave: , from: , type: , content_hash: , content: , exp: , tags: [ , ... ], alg: <"schnorr" | "ecdsa">, sig: } ``` Where: * **hash**: commitment hash of the commit fields * **enclave**: destination enclave * **from**: identity key of the author * **type**: commit type, defined within the enclave scope * **content**: payload (UTF-8 string; MAY be empty `""` for events with no payload like Pause/Resume; binary data SHOULD be base64 encoded; large binaries SHOULD use external resource URLs). * **exp**: latest time at which the node may accept the commit (Unix epoch **milliseconds**; also acts as a nonce). * **tags**: node-level metadata (see [Tags](#tags)) * **alg**: OPTIONAL signature scheme — `"schnorr"` (default if absent) or `"ecdsa"`. See [Signature Schemes](#signature-schemes). NOT bound in `hash` (see §"alg field integrity"); tampering causes signature verification to fail, not forgery. * **sig**: signature by `from`, proving authorship #### Commit Hash Construction This formula applies to **ALL event types** (Manifest, Grant, `message`, etc.): ``` _content_hash = sha256(utf8_bytes(content)) hash = H(0x10, enclave, from, type, _content_hash, exp, tags) sig = schnorr(hash, from_priv) ``` Where `utf8_bytes()` returns the raw UTF-8 byte sequence. No Unicode normalization (NFC/NFD) is performed at the protocol level. The `content` structure varies by event type, but the hash formula is identical. Note: `_content_hash` uses raw SHA-256 on bytes, not `H()`, because content is already a byte string. **Binary Data in Content:** If `content` contains binary data (e.g., images, files), the data MUST be base64-encoded before inclusion. The `content_hash` is computed over the **base64-encoded string**, not the decoded binary bytes. Example: * Binary data: `0x48656c6c6f` (5 bytes, "Hello") * Base64 encoded in content: `"SGVsbG8="` * `content_hash = sha256(utf8_bytes("SGVsbG8="))` — hash of 8 UTF-8 bytes This ensures the hash matches what is transmitted and stored. Implementations MUST NOT decode base64 before hashing. This establishes: * Integrity of content * Binding between author and intent **Wire Format:** The client transmits BOTH `content` AND `content_hash` in the commit (see §Commit Structure — `content_hash` is a required commit field, bound in the signature via the CBOR pre-image at byte position 5). On receipt the node MUST recompute `expected = sha256(utf8_bytes(content))` and reject the commit with `CONTENT_HASH_MISMATCH` if `expected ≠ content_hash`. Without this server-side check, a misbehaving client could attach a signed `content_hash` to a different `content` payload and the receipt would still validate; the node's verification closes that gap. **Note on the finalized event:** The event MUST carry `content_hash` as a clear-text field (it's inherited from the commit per §Event Structure). Recipients verify the commit signature against the same pre-image; they MAY also recompute `sha256(utf8_bytes(content))` and confirm the match for defense-in-depth. **Content Integrity:** The node MUST store and serve `event.content` exactly as received in the commit — byte-for-byte, with no normalization or transformation. Any modification would invalidate the commit hash. **Application-Level Canonicalization:** If applications treat `content` as structured data (e.g., JSON), they MUST define their own canonical encoding rules to ensure cross-client byte-identical hashing. The protocol treats `content` as an opaque byte string. #### Tags Tags provide **node-level metadata** that instructs the node how to process an event. **Key distinction:** * **Content** — opaque to the node; the node stores and serves it without interpretation * **Tags** — understood by the node; predefined tags trigger specific node behaviors Unlike protocols where tags serve as query indexes, ENC data is already scoped to enclaves. Tags exist primarily to convey processing instructions to the node. **Structure:** Tags are an array of arrays. Each tag has: * **Position 0:** tag name (key) * **Position 1:** primary value * **Position 2+:** additional values (optional) All tag values MUST be strings. Numeric or other typed values are encoded as their string representation. ```json [ ["", "", "", ...], ... ] ``` **Hash Ordering:** When computing the commit hash, tags are hashed as a CBOR array in their original order. The order of tags in the array is significant for hash computation. **Empty Tags:** An empty tags array `[]` is valid. **Tags MUST be replicated unchanged.** Nodes store the tags array byte-for-byte alongside the rest of the event and return it on every read path (HTTP `GET`, WebSocket `Event` frames, push delivery, backfill). Tags carry plugin-level routing data (`["to", ""]` on `dm:sent`, `["enclave_id", ""]` on cross-enclave `notice`, etc.); plugin decryption fails if tags are dropped or mutated in the store. An implementation that backs events with a relational table MUST include a `tags` column (or equivalent encoding) so storage round-trip preserves them — the commit hash binds them into event identity, so any divergence corrupts every downstream proof. **Example:** ```json [ ["auto-delete", "1706000000000"], ["r", "abc123...", "reply"] ] ``` **Predefined Tags:** | Tag | Description | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `r` | Reference — references another event. Format: `["r", "", ""]`. See context values below. | | `auto-delete` | Auto-delete timestamp — node SHOULD delete the event content after this Unix timestamp (**milliseconds**, same as `exp` and `timestamp`) | **`r` Tag Context Values:** | Context | Meaning | | ----------- | --------------------------------------------- | | *(omitted)* | General reference (default) | | `"target"` | Target for Update/Delete operations | | `"reply"` | Reply to referenced event | | `"quote"` | Quote/repost of referenced event | | `"thread"` | Part of a thread starting at referenced event | Custom context values are allowed for application-specific semantics. Nodes treat unrecognized contexts as general references. > **Note:** The `exp` field (commit acceptance window) and `auto-delete` tag (event retention) serve different purposes. `exp` controls when a commit can be accepted; `auto-delete` controls when the finalized event should be removed from storage. **Auto-delete vs Delete event:** | Aspect | Auto-delete (tag) | Delete (event) | | ----------- | --------------------------------------------- | ---------------------------------------------- | | Mechanism | Node silently removes content after timestamp | Explicit Delete event updates SMT | | Verifiable | No — trust-based | Yes — auditable proof | | SMT state | Unchanged (remains "active") | Updated to "deleted" | | Audit trail | No | Yes (who, when, why) | | Use case | Ephemeral content, disappearing messages | Compliance, moderation, user-initiated removal | > **Important:** Auto-delete is **trust-based**. Clients trust the node to honor the timestamp. A malicious node could delete content early without detection. For verifiable deletion with audit trail, use Delete events instead. **Relationship with exp:** The `auto-delete` timestamp MUST be greater than `exp`. The commit MUST be accepted before auto-delete takes effect. Nodes SHOULD reject commits where `auto-delete <= exp` as semantically invalid. **Auto-delete and SMT:** Auto-delete does NOT update the SMT. The event remains "active" in SMT state (no entry in Event Status namespace). Auto-delete only affects storage — the node removes content while event metadata can remain. Auto-delete is trust-based, not verifiable via SMT proofs. Future protocol or application schemas can define additional predefined tags. *** ### Event Finalization Upon receiving a valid commit, a node performs: 1. **Expiration check** — reject if `exp` \< current time 2. **Deduplication check** — reject if commit `hash` was already processed (see [Replay Protection](#replay-protection)) 3. **RBAC authorization check** — verify sender has C permission for this event type 4. **Sequencing and timestamp assignment** If accepted, the node finalizes the commit into an event by adding node-generated fields. #### Replay Protection The node MUST reject commits with a `hash` that has already been processed for this enclave. **Implementation:** * Node maintains a set of **accepted** commit hashes per enclave * Before accepting a commit, check if `hash` exists in the set * If duplicate, reject the commit * Only add hash to set AFTER successful acceptance * Hashes MAY be garbage collected after `exp + 60000` ms (60 seconds buffer for clock skew). > **Note:** Rejected commits are NOT added to the deduplication set. A commit that was rejected for authorization failure can be resubmitted (e.g., after the sender is granted the required State or trait). This prevents replay attacks where an attacker resubmits a valid commit multiple times within the expiration window. **Expiration Window Limit:** Nodes MUST reject commits where the expiration is too far in the future: ``` exp - current_time > MAX_EXP_WINDOW → reject ``` The protocol defines `MAX_EXP_WINDOW = 3600000` (1 hour in milliseconds). Implementations MAY use a shorter window. This prevents storage DoS attacks where clients submit commits with extremely large `exp` values, forcing indefinite hash retention. **Clock Skew Tolerance:** The protocol tolerates bounded clock skew (±60 seconds) between client and sequencer: | Component | Tolerance | Behavior | | ------------------ | ---------------- | ----------------------------------------------- | | Commit expiration | ±60 seconds | Accept commits up to 60s "early" (client ahead) | | Hash deduplication | +60 seconds | GC buffer after `exp + 60000` ms | | Bundle timeout | event timestamps | Uses event.timestamp, not wall clock | | Session expiry | ±60 seconds | Node checks token expiry with tolerance | Implementations SHOULD sync clocks via NTP and log warnings if skew exceeds 60 seconds. *** ### Event An **Event** is the fundamental, immutable record within an enclave. Conceptually, an event represents a **user-authorized action** that has been: 1. Authored and signed by a client, and 2. Accepted, ordered, and finalized by a node. An event is **derived from a Commit**, after validation and sequencing by a node. #### Event Structure ``` { id: , hash: , enclave: , from: , type: , content: , exp: , tags: [ , ... ], timestamp: , sequencer: , seq: , alg: <"schnorr" | "ecdsa">, sig: , seq_sig: , } ``` Where: * **timestamp**: time at which the event was finalized (Unix epoch **milliseconds**; MUST be ≥ previous event's timestamp; equal timestamps allowed, ordering determined by `seq`). * **sequencer**: identity key of the sequencing node * **seq**: monotonically increasing sequence number within the enclave (starts at 0; Manifest is seq=0). * **seq\_sig**: signature by the sequencer over the finalized event * **id**: canonical identifier of the event **Sequencer Continuity:** For all events except Migrate (forced takeover), the `sequencer` field MUST match the current sequencer recorded in the Manifest. If a different key signs `seq_sig`, the event is invalid and clients MUST reject it. Exception: Migrate in forced takeover mode — the new sequencer finalizes the Migrate event (see Migration section). **Field inheritance from Commit:** The following fields are copied directly from the original Commit: * `hash` — the commit hash (`Event.hash == Commit.hash`). * `enclave`, `from`, `type`, `content`, `exp`, `tags`, `sig`. The node adds: `id`, `timestamp`, `sequencer`, `seq`, `seq_sig`. #### Event Hash Chain The event's cryptographic commitments are constructed as follows: ``` _event_hash = H(0x11, timestamp, seq, sequencer, sig) seq_sig = schnorr(_event_hash, sequencer_priv) id = sha256(seq_sig) ``` The resulting `id`: * Commits to **both client intent and node ordering** * Serves as the leaf identifier for Merkle trees and proofs * Is immutable and globally referencable *** ### Receipt A **Receipt** is a **node-signed acknowledgment** proving that a commit has been **accepted, sequenced, and finalized** into an event. It provides the client with the **canonical event identifier** and **sequencing metadata**, without including the event content. #### Structure ``` { id: , hash: , timestamp: , sequencer: , seq: , sig: , seq_sig: } ``` Where: * **sig**: the client's signature from the original commit (proves client intent) * **seq\_sig**: the sequencer's signature over the finalized event (proves node acceptance) The receipt cryptographically binds **client intent** and **node ordering**, and allows the client to verify successful finalization of its commit. > **Note:** The Receipt intentionally omits the `enclave` field. The client already knows which enclave it submitted to, and omitting it provides privacy when receipts are broadcast or shared. **`alg` on receipts:** The Receipt also intentionally omits `alg`. The receipt's `sig` IS the commit's `sig` (proving client intent) and is verified by the same `alg` the client used; the client receiving the receipt already knows that value. A third-party verifier obtains `alg` from the finalized event (which DOES inherit `alg` from the commit per §Event Structure), then verifies `sig` against the event's `hash` per [§Signature Schemes](#signature-schemes). The receipt's `seq_sig` is always Schnorr (sequencer signature — see [§Sequencer and Server Signatures](#sequencer-and-server-signatures)) and needs no `alg` discriminator. *** ### P (Push) and N (Notify) **P (Push)** and **N (Notify)** enable proactive delivery from nodes to external services or clients. **P (Push):** * Node delivers the **full event** to identities with P permission for that event type. * Use case: DataView services that index, aggregate, or transform enclave data * DataView does not need R permission — it receives data via push, not query **N (Notify):** * Node delivers a **lightweight notification** (metadata only, not content). * Use case: Clients that want to know when to refresh, or services tracking enclave activity **Registration:** * Identities receiving P or N MUST be granted a trait with P/N ops in the manifest (e.g., admin grants a dataview trait with P permission). * The delivery endpoint is carried in the `Grant` event itself (`endpoint` field on the Grant content); the node registers it as operational state at Grant time. The Registry enclave is NOT consulted for P/N destinations. **Transport:** \::: extension-point id=push-notify-transport class=deployment\_choice reason: P/N delivery uses operator-deployment transport (HTTPS webhook, WebSocket, queue); the wire shape of the delivered event is unchanged across transports The transport mechanism for Push (P) / Notify (N) delivery is a deployment choice. Nodes SHOULD support HTTPS POST (webhook) delivery; nodes MAY additionally support WebSocket for real-time streaming. \::: **Delivery Guarantees:** | Type | Guarantee | Behavior | | ---------- | ------------- | ---------------------------------------------------------------------------------------------------------- | | P (Push) | At-least-once | Node retries with exponential backoff on failure. Recipient MUST handle duplicates (dedupe by event `id`). | | N (Notify) | Best-effort | Fire and forget. N is a hint; recipients can poll for missed events. | **P (Push) Retry Policy:** * Initial retry: 1 second after failure * Exponential backoff: 2x multiplier per retry * Maximum interval: 5 minutes * Maximum retries: 10 attempts * After max retries: drop event, log failure, SHOULD alert enclave owner. Implementations MAY use different parameters but MUST implement retry with backoff. Nodes SHOULD NOT retry indefinitely to avoid resource exhaustion. **Push Endpoint Failure:** When P delivery to a registered endpoint fails persistently (max retries exhausted): * The endpoint registration is kept (not removed). * The node SHOULD alert the enclave owner. * The owner can revoke the trait if the endpoint is permanently invalid * The node retries with a fresh retry cycle on subsequent events (stateless — no failure tracking between events) **P/N Delivery Independence:** Each event's P/N delivery is independent. If delivery fails for one event, it does not affect delivery of other events. Events in the same bundle are delivered separately, each with its own retry cycle. **P (Push) Payload:** The P payload is the complete Event structure (see [Event Structure](#event-structure)). No wrapper is needed — the event already contains the `enclave` field. **N (Notify) Payload:** ```json { "enclave": "", "event_id": "", "type": "", "seq": 123, "type_seq": 45, "timestamp": 1706000000000 } ``` Where: * **enclave** — the enclave identifier * **event\_id** — the canonical event identifier * **type** — the event type * **seq** — the global sequence number of the event within the enclave * **type\_seq** — the sequence number of this event within its type (1-indexed, continuous per type) * **timestamp** — the event timestamp (milliseconds) **Per-Type Sequence Counter:** Nodes MUST maintain a per-type sequence counter for each enclave. When an event of type T is finalized, the node increments the counter for T and assigns it as `type_seq`. This allows N recipients to detect missed notifications by checking continuity of `type_seq`. **type\_seq Rules:** * Starts at 1 for the first event of each type (not 0). * Increments by 1 for each subsequent event of that type. * Never resets — even after migration, counters continue from their last value. * Each event type has an independent counter. **Gap Recovery:** When a recipient detects a `type_seq` gap (e.g., received 5, then 8): * **If R permission available:** Use Query (`/query`) to fetch missed events by type and sequence range. This is the preferred recovery method. * **If N permission only:** Gaps cannot be recovered through the protocol. N-only recipients SHOULD design their applications to tolerate gaps (e.g., treat notifications as hints rather than authoritative state). Consider upgrading to R permission if complete event history is required. **DataView with P+N:** A role MAY have both P and N permissions for the same event type. P takes precedence — if P delivery succeeds, N is not sent separately. If P fails (max retries exhausted), N MAY be sent as fallback notification. *** ### U (Update) and D (Delete) **U (Update)** and **D (Delete)** are **logical operations** implemented as new events that reference prior events. **Semantics:** * The event log remains **append-only**; original events are never mutated. * A **Delete event** marks a target event as logically deleted * An **Update event** marks a target event as superseded and provides new content * An event MAY be updated multiple times, forming an update chain. * Only identities with U or D permission (per schema) for that event type can issue these operations. **Event Status State:** The current status of each event (active, updated, deleted) is tracked in the **Enclave State SMT** alongside RBAC state (see [`smt.md` §Enclave State SMT](/spec/kernel/smt#enclave-state-smt)). When a U or D event is finalized: 1. The node updates the SMT entry for the target event. 2. The SMT root reflects the new state 3. Clients can verify event status via SMT proof **Content Integrity Proofs:** | SMT Proof Result | Interpretation | | --------------------- | ---------------------------------- | | Non-membership (null) | Event is Active OR never existed | | `0x00` (1 byte) | Event was Deleted | | `<32-byte id>` | Event was Updated to this event ID | **Verification Flow:** 1. Call `POST /state` with `namespace: "event_status"` and `key: ` → get SMT proof 2. If proof value = `0x00` → event is **Deleted** (conclusive) 3. If proof value = 32-byte ID → event is **Updated** to that ID (conclusive) 4. If proof value = null (non-membership): * Call `POST /bundle` with `event_id` → get bundle membership proof * If proof succeeds → event is **Active** * If proof fails (`EVENT_NOT_FOUND`) → event **never existed** Clients MUST perform step 4 to distinguish Active from never-existed. The 1-byte vs 32-byte value length unambiguously distinguishes Deleted from Updated. **Content Handling:** When an event is updated or deleted: * The node SHOULD delete the original content from storage. * However, there is **no guarantee** of content deletion — the node operates on a best-effort basis. * Clients MUST NOT assume original content is irrecoverable. **Querying:** * **Enclave queries:** Return event + status + SMT proof (verifiable) * **DataView queries:** Return event + status (trusted, no proof) *** ### Event Categories | Category | Description | Can be U/D | | ------------------- | --------------------------------------- | ---------- | | **AC Events** | Modify RBAC state (Grant, Revoke, etc.) | No | | **Content Events** | Application data with no state effect | Yes | | **Update / Delete** | Modify event status of Content Events | No | **Key rules:** * Only **Content Events** can be Updated or Deleted. * AC Events are irreversible — they represent state transitions. * Update/Delete events cannot themselves be Updated or Deleted. The node determines the category by event type — all AC event types and Update/Delete are predefined by the protocol. *** ### Predefined Event Type Registry | Type | Category | Modifies SMT | Description | | ----------- | ---------- | ------------ | ---------------------------------------- | | `Manifest` | Lifecycle | Yes (RBAC) | Initialize enclave | | `Move` | AC | Yes (RBAC) | Change identity's State | | `Grant` | AC | Yes (RBAC) | Add trait to identity | | `Revoke` | AC | Yes (RBAC) | Remove trait from identity | | `Transfer` | AC | Yes (RBAC) | Atomically move trait between identities | | `Gate` | AC | Yes (KV) | Toggle pre-RBAC event gate | | `AC_Bundle` | AC | Yes (RBAC) | Atomic batch of AC ops | | `Shared` | KV | Yes (KV) | Enclave-wide singleton key-value | | `Own` | KV | Yes (KV) | Per-identity key-value slot | | `Update` | Event Mgmt | Yes (Status) | Supersede content event | | `Delete` | Event Mgmt | Yes (Status) | Delete content event | | `Pause` | Lifecycle | Yes (KV) | Pause finalization | | `Resume` | Lifecycle | Yes (KV) | Resume finalization | | `Terminate` | Lifecycle | Yes (KV) | Close enclave | | `Migrate` | Lifecycle | Yes (KV) | Transfer to new sequencer | **Content Events:** Any type NOT in this registry is a Content Event (e.g., `message`, `reaction`). Content Events do not modify SMT state. For full AC event processing rules, see [`rbac/events.md`](/spec/kernel/rbac). *** ### AC Events AC (Access Control) events modify the RBAC state or lifecycle of an enclave. All AC event types are predefined by the ENC protocol and understood by the node. For full processing rules, see [`rbac/events.md`](/spec/kernel/rbac). #### AC Event Summary | Type | Effect | | ---------- | ---------------------------------------------------------------------------- | | Move | Change identity's State (8-bit enum). Clears traits unless `preserve: true`. | | Grant | Add trait flag to identity's bitmask. Scoped by target State. | | Revoke | Remove trait flag. Self allowed as operator (voluntary step-down). | | Transfer | Atomically move a trait from operator to target. | | Gate | Toggle pre-RBAC event gate (open/closed). | | AC\_Bundle | Atomic batch of AC operations (all-or-nothing). | #### Lifecycle Events | Type | Effect | | --------- | ------------------------------ | | Pause | Transition to Paused state | | Resume | Transition to Active state | | Terminate | Transition to Terminated state | | Migrate | Transfer to new sequencer | Lifecycle state is stored in KV Shared (`Shared("lifecycle")`) in SMT namespace `0x02`. #### KV Events | Type | Scope | Effect | | ------ | ------------------------------ | -------------------------------- | | Shared | Enclave-wide singleton per key | Last-write-wins mutable state | | Own | Per-identity slot per key | Each identity writes to own slot | KV events maintain current state in SMT namespace `0x02`. History preserved in CT. *** ### Content Events **Content Events** are application-defined event types that carry data without affecting RBAC state. * Defined in the manifest `customs` section (not predefined by the protocol) * Do not modify RBAC state or enclave lifecycle * Can be Updated or Deleted (if schema permits) An event is a Content Event if its `type` is NOT in the Predefined Event Type Registry above. Determined by string comparison — no schema lookup needed. *** ### Event Type Registry (Appendix) Machine-readable registry of predefined event types. Content Events are any type NOT in this list. ```json { "version": 2, "predefined_types": { "ac_events": [ "Manifest", "Move", "Grant", "Revoke", "Transfer", "Gate", "AC_Bundle" ], "kv_events": [ "Shared", "Own" ], "lifecycle_events": [ "Pause", "Resume", "Terminate", "Migrate" ], "mutation_events": [ "Update", "Delete" ], "registry_events": [ "reg_node", "reg_enclave", "reg_identity" ] } } ``` **Usage:** To determine if an event is a Content Event: ``` is_content_event = type NOT IN any predefined_types category ``` > **Note:** `registry_events` (reg\_node, reg\_enclave, reg\_identity) ARE Content Events within the Registry enclave. They can be Updated or Deleted per the Registry's RBAC schema. They are listed separately for documentation purposes. For event processing rules, see [`../rbac.md`](/spec/kernel/rbac) Section 8. *** ### Enclave Lifecycle An enclave exists in one of four states, derived from the event log: | State | Condition | Accepted Commits | Reads | | -------------- | ------------------------------------------------------- | ------------------------------- | ----------------- | | **Active** | No Terminate/Migrate; last lifecycle event is not Pause | All (per RBAC) | Yes | | **Paused** | No Terminate/Migrate; last lifecycle event is Pause | Resume, Terminate, Migrate only | Yes | | **Terminated** | Terminate event exists | None | Until deleted\* | | **Migrated** | Migrate event exists | None | No obligation\*\* | \*After Terminate, the node SHOULD delete enclave data. Reads MAY be allowed until deletion completes. \*\*After Migrate, the old node is not obligated to serve reads. Clients SHOULD query the new node. **State Derivation:** ``` migrated = exists(Migrate) terminated = !migrated && exists(Terminate) paused = !migrated && !terminated && last_of(Pause, Resume).type == "Pause" active = !migrated && !terminated && !paused ``` **Mutual Exclusion:** Terminate and Migrate are mutually exclusive terminal states. The node MUST reject: * Migrate commit if Terminate already exists → error `ENCLAVE_TERMINATED` * Terminate commit if Migrate already exists → error `ENCLAVE_MIGRATED` This prevents ambiguous state derivation. The first terminal event wins. **last\_of() Semantics:** `last_of(Pause, Resume)` returns the most recent event of either type. If neither exists, the result is `null`, and the `paused` condition evaluates to `false`. Lifecycle state is stored in KV Shared (`Shared("lifecycle")`) in SMT namespace `0x02`. The lifecycle events themselves are also recorded in the append-only log, providing an audit trail. **Lifecycle Events:** | Event | Transition | Reversible | | --------- | -------------------------- | ---------- | | Pause | Active → Paused | Yes | | Resume | Paused → Active | Yes | | Terminate | Active/Paused → Terminated | No | | Migrate | Active/Paused → Migrated | No | **Behavior in Paused State:** * Node MUST reject all commits except Resume, Terminate, and Migrate. * In-flight commits at the moment of Pause are rejected * Read queries continue to work normally * In-flight P (Push) and N (Notify) deliveries SHOULD complete (events already finalized). **P/N Delivery in Paused State:** Events finalized before Pause can already have in-flight P/N deliveries; the paused-state rule above preserves completion for those deliveries. No new events are finalized during Pause (except Resume/Terminate/Migrate), so no new P/N deliveries are initiated. P/N is not "paused" — there are simply no new events to trigger deliveries. *** ### Manifest A **Manifest** is a predefined event type used to **initialize an enclave**. The acceptance and finalization of a Manifest event marks the **creation of the enclave** and establishes its initial configuration. **Type:** `Manifest` #### Manifest Commit Content The Manifest RBAC section uses the v2 manifest format (see [`rbac/manifest.md`](/spec/kernel/rbac)). Simplified example: ```json { "enc_v": 2, "states": ["MEMBER"], "traits": ["owner(0)", "admin(1)"], "readers": [{ "type": "MEMBER", "reads": "*" }], "moves": [], "grants": [ { "event": "Grant", "operator": ["owner"], "scope": ["MEMBER"], "trait": ["admin"] }, { "event": "Revoke", "operator": ["owner"], "scope": ["MEMBER"], "trait": ["admin"] } ], "transfers": [], "slots": [], "lifecycle": [ { "event": "Terminate", "operator": "owner", "ops": ["C"] } ], "customs": [ { "event": "message", "operator": "MEMBER", "ops": ["C"] }, { "event": "message", "operator": "Self", "ops": ["U", "D"] } ], "init": [ { "identity": "", "state": "MEMBER", "traits": ["owner", "admin"] } ], "meta": { "description": "a simple group" }, "bundle": { "size": 256, "timeout": 5000 } } ``` *For production manifest examples, see the app specs: [Personal](/spec/app/enclaves/personal), [DM](/spec/app/enclaves/dm), [Group Chat](/spec/app/enclaves/group).* **Manifest Content Canonicalization:** Unlike Content Events, the node parses Manifest content as JSON for validation. For commit hash verification: * Client sends `content` as UTF-8 JSON string * Node hashes `content` byte-for-byte (does not re-serialize). * Node parses JSON only for validation, not for hashing Clients SHOULD use deterministic JSON serialization (sorted keys, no extra whitespace) for reproducibility, but this is not enforced by the protocol. #### Fields * **enc\_v** — Version of the ENC protocol. * **states** — Declared States (UPPER\_CASE). See [`rbac/manifest.md`](/spec/kernel/rbac) for the full manifest format. * **traits** — Declared traits with rank: `name(N)`. * **readers** — Which columns grant read authority on which events. * **init** — Initial identities bootstrapped at enclave creation. * **moves, grants, transfers, slots, lifecycle, customs** — Authorization entries for each event category. * **meta** — Optional application-defined metadata (object). The serialized `meta` field (as JSON) MUST NOT exceed 4 KB (4096 bytes). Nodes MUST reject Manifests with larger `meta` fields. * **bundle** — Optional bundle configuration (size, timeout). If omitted, defaults apply. **Schema Immutability:** The RBAC schema is **immutable** after the Manifest is finalized. To change the schema, a new enclave MUST be created. This ensures: * Role bit positions remain stable for the enclave's lifetime * RBAC proofs remain valid across the entire event history * No ambiguity about which schema version applies to which events **No Schema Version Field:** The schema has no explicit version field. The enclave ID is derived from Manifest content (which includes the schema), so any schema change produces a different enclave ID; changing the schema therefore requires creating a new enclave. #### Bundle Configuration The `bundle` field controls how events are grouped for CT and SMT efficiency. | Field | Type | Default | Description | | --------- | ------ | ------- | -------------------------------------- | | `size` | number | 256 | Max events per bundle | | `timeout` | number | 5000 | Max milliseconds before closing bundle | **Bundle closing rules:** * Close when `size` events accumulated, OR * Close when `timeout` ms passed since bundle opened * Whichever comes first **Timeout Clock:** Timeout is measured using event timestamps, not wall clock. The bundle opens when the first event's timestamp is recorded. The bundle closes when a new event arrives with `timestamp >= first_event.timestamp + timeout`. **Determinism:** Bundle boundaries depend only on the finalized event sequence (`seq` order) and timestamps, which are immutable once sequenced. Out-of-order network delivery does NOT affect bundle boundaries — replaying the same log always produces identical bundles. > **Note:** Timeout is only checked when a new event arrives. If no events arrive, the bundle remains open indefinitely. A bundle MUST contain at least one event (empty bundles are not valid). **Idle Bundles:** If an enclave receives no new events, the current bundle remains open (no timeout trigger); bundles are only closed when needed for new events. An open bundle has no negative effect — CT/SMT are still current in-memory. The bundle closes immediately when the next event arrives (if timeout elapsed). **Concurrent Events:** The sequencer processes commits serially. "Simultaneous" arrival is resolved by the sequencer's internal ordering. Bundle boundaries are deterministic given the final event sequence. **Semantics:** * All events in a bundle share the same `state_hash` (SMT root after last event in bundle). * CT leaf is created per bundle, not per event. * Set `size: 1` for per-event state verification (no bundling). **state\_hash Computation Timing:** The `state_hash` is computed when the bundle closes: 1. Process all events in bundle sequentially by `seq` order 2. Apply each state-changing event to SMT (RBAC updates, Event Status updates) 3. After last event is applied, capture current SMT root 4. This root becomes the bundle's `state_hash` **Immutability:** Bundle configuration is **immutable** after the Manifest is finalized, like the RBAC schema. #### Enclave Identifier The enclave identifier is derived from the Manifest commit: ``` enclave = H(0x12, from, type, content_hash, tags) ``` For a Manifest commit, the client MUST: 1. Compute `enclave` using the formula above 2. Set the `enclave` field in the commit to this value 3. Compute the commit `hash` (which includes `enclave`) **Derivation Order (critical for implementation):** ``` 1. content_hash = sha256(utf8_bytes(content)) 2. enclave_id = H(0x12, from, "Manifest", content_hash, tags) 3. commit.enclave = enclave_id 4. commit_hash = H(0x10, enclave_id, from, "Manifest", content_hash, exp, tags) 5. sig = sign(commit_hash, id_priv, alg) where sign() dispatches per `alg`: - "schnorr" (or absent) : schnorr_sign(commit_hash, id_priv) - "ecdsa" : ecdsa_sign(commit_hash, id_priv) (recoverable; r || s || v) ``` This two-step derivation ensures the enclave ID is self-referential and deterministic. > **Note:** Two Manifests with identical `from`, `content`, and `tags` produce the same enclave ID — identical inputs represent the same enclave intent. To create distinct enclaves with similar configurations, include a unique value in `meta` (e.g., a UUID or timestamp). **Enclave Identity and Collision:** The enclave ID is deterministic and independent of which node hosts it. If two nodes receive identical Manifest commits, they compute the same enclave ID. **Collision Handling:** If a node receives a Manifest commit for an enclave ID that already exists on that node, the node MUST reject the commit. This is detected by checking if the enclave already has a seq=0 event. If different nodes independently create enclaves with the same ID, both enclaves are technically valid on their respective nodes. Discovery mechanisms (Registry, DNS, well-known URLs, bootstrap config) decide which the client reaches; canonicalization lives outside the core protocol. The Registry enclave's specific rules for handling such collisions are in [`enclaves/registry.md`](/spec/app/enclaves/registry). **Manifest exp Field:** The `exp` field in a Manifest commit follows the generic commit rule in §Commit Object: it defines the latest time at which the node is allowed to accept the commit. After enclave creation, the Manifest's `exp` has no ongoing effect. #### Manifest Validation **Validation Order:** 1. Verify commit structure (fields present, types correct) 2. Verify commit hash and signature 3. Verify enclave ID derivation matches 4. Parse and validate content JSON 5. Apply content-specific rules below The node MUST reject a Manifest commit if: 1. `enc_v` is not a supported protocol version 2. `states` is not a non-empty array of UPPER\_CASE strings 3. `traits` is not an array of `name(rank)` strings with valid non-negative integer ranks 4. `init` is empty or contains invalid entries (each MUST have `identity`, `state`, `traits[]`) 5. Any identity in `init` is not a valid 32-byte public key 6. Any State in `init` is not declared in `states` 7. Any trait in `init` is not declared in `traits` 8. Manifest fails any validation rule defined in [`rbac/manifest.md` §Validation Rules](/spec/kernel/rbac) #### Initialization Semantics If a node accepts the Manifest commit and returns a valid receipt, the client can conclude that: * The enclave has been successfully initialized * The RBAC schema and initial state are finalized * The enclave identifier is canonical and immutable *** ### Update An **Update** event replaces the content of a previously finalized event. **Type:** `Update` #### Structure | Field | Value | | ------- | ----------------------------------------- | | type | `Update` | | content | The replacement content | | tags | MUST include `["r", ""]` | #### Semantics * The target event MUST be a Content Event (AC events cannot be updated). * The target event (referenced by `r` tag) is marked as **updated** in the Enclave State SMT. * The original content SHOULD be deleted by the node (best-effort, no guarantee). * Only identities with **U** permission for the target event's type can issue an Update. * An event can be updated multiple times; each Update MUST reference the **original** event (not the previous Update). * The SMT leaf for the original event points to the latest Update. **SMT Update Tracking:** When an Update event is finalized: 1. Node looks up the target event ID from the `r` tag 2. Node writes `SMT[target_event_id] = update_event_id` 3. If a subsequent Update targets the same original event, the SMT entry is overwritten The SMT always stores the **most recent** Update event ID for each target. Clients can follow the chain by querying the Update event, which contains the new content. **Update Lookup:** All Update events target the **original** Content Event, not previous Updates. The SMT stores only one entry per original event, always pointing to the most recent Update. No chain following is needed — one SMT lookup returns the latest content. **Concurrent Updates in Bundle:** If multiple Updates target the same original event within one bundle, they are processed serially by `seq` order. Each Update overwrites the previous SMT entry. Only the last Update's event\_id is stored in SMT after the bundle closes. **Empty Content:** An Update event with `content: ""` (empty string) is valid. This clears the content while preserving the update chain. Use case: author wants to retract content but preserve the event record. #### Authorization The node checks U permission against the **target event's type**, not `Update`. For example, if the schema grants `Sender` the U permission for `message`, only the original author can update their own messages. #### Update Target Validation The node MUST reject Update if: * Target event does not exist * Target event is a Delete or Update event (U/D events cannot be U/D'd) * Target event is an AC Event * Target event is already deleted (has Delete status in SMT) **Updating an Already-Updated Event:** Updating an event that was previously updated is allowed. The new Update supersedes the previous one — the SMT entry is overwritten with the new Update's event\_id. The target MUST always be the original Content Event, not any intermediate Update event. *** ### Delete A **Delete** event marks a previously finalized event as logically deleted. **Type:** `Delete` #### Structure | Field | Value | | ------- | ----------------------------------------- | | type | `Delete` | | content | JSON object (see below) | | tags | MUST include `["r", ""]` | > **Note:** The `content` field in events is always a UTF-8 string. The JSON structure shown is the content when parsed. The actual event stores: `content: "{\"reason\":\"author\",\"note\":\"...\"}"` **Content fields:** | Field | Required | Description | | ------ | -------- | ----------------------------------------------------------------- | | reason | Yes | `"author"` (self-deletion) or `"moderator"` (admin/role deletion) | | note | No | Optional explanation (e.g., "policy violation") | #### Semantics * The target event MUST be a Content Event (AC events cannot be deleted). * The target event (referenced by `r` tag) is marked as **deleted** in the Enclave State SMT. * The original content SHOULD be deleted by the node (best-effort, no guarantee). * Only identities with **D** permission for the target event's type can issue a Delete. * Delete events provide a **verifiable audit trail** of who deleted content and why. #### Authorization The node checks D permission against the **target event's type**, not `Delete`. For example, if the schema grants `Sender` the D permission for `message`, only the original author can delete their own messages. **Self Evaluation Example:** If the manifest customs section defines: ```json { "event": "message", "operator": "Sender", "ops": ["D"] } ``` Alice (`id_alice`) can delete a message only if `target_event.from == id_alice`. The `Sender` context matches the target event's author. If the manifest also grants admin the D permission: ```json { "event": "message", "operator": "admin", "ops": ["D"] } ``` An admin can delete any message regardless of authorship. The `Sender` entry only applies when the actor's identity matches the target event's `from`. #### Delete Target Validation The node MUST reject Delete if: * Target event does not exist * Target event is a Delete or Update event (U/D events cannot be U/D'd) * Target event is an AC Event * Target event is already deleted **Deleting an Already-Updated Event:** Deleting an event that was previously updated is allowed. Delete targets the original Content Event, and the SMT status changes from "updated" to "deleted". All associated Update events become orphaned — they remain in the log but reference a deleted event. Clients querying the original event will see "deleted" status. *** ### Templates Predefined RBAC templates for common enclave patterns. When `use_temp` is set in Manifest content, the node uses the referenced template instead of the explicit `schema` field. | Template | Description | | -------- | -------------------------------- | | `none` | No template; use explicit schema | **v2 Scope:** In ENC v2, only `none` is supported. Additional templates (e.g., `chat`, `forum`, `personal`) are planned for future versions. Nodes MUST reject Manifests with unrecognized `use_temp` values. For the recommended `personal` enclave schema using explicit `none` template, see [`enclaves.md`](/spec/app/enclaves). *** ### Migration Enclave migration (the `Migrate` event, eager / lazy / fork modes, checkpoint verification, split-brain prevention, backup pattern) is specified in [`migration.md`](/spec/node/migration). ## Enclave Design Patterns This document catalogs application-level **enclave design patterns** — Shared, Personal, Group Chat, and DM. The patterns are informative: the core protocol does not mandate any of them. They illustrate how the [RBAC v2](/spec/kernel/rbac) state/trait model and the [confidentiality plugins](/spec/app/plugins) compose into recognisable application archetypes. *** ### Table of Contents 1. [Shared Enclave](#shared-enclave) 2. [Personal Enclave](#personal-enclave) 3. [Group Chat](#group-chat) 4. [How to Choose](#how-to-choose) 5. [DM (Direct Message)](#dm-direct-message) 6. [Enclave Kind Metadata](#enclave-kind-metadata) *** ### Shared Enclave A **Shared Enclave** is an enclave whose data and access-control are intended to be jointly used by **multiple identities**. **Typical characteristics** * **Multi-writer / multi-reader** by design * RBAC is centered on **group roles** (e.g., Admin/Member/Moderator) * Data is considered **collective** (not owned by a single identity) **Examples** * Group chat enclave * A protocol-level registry/directory enclave * DAO / project coordination enclave * Shared public feed with moderators ### Personal Enclave > For the full RBAC v2 design, see **[Personal Enclave](/spec/app/enclaves/personal)**. A **Personal Enclave** is an enclave whose data is logically **owned and controlled by a single identity**, even if many others can read or contribute under permission. **Typical characteristics** * A single "owner" identity is the **final authority** for RBAC and lifecycle * Designed for **portable identity-scoped data** with a unified API * Others can write *into* it (e.g., comments, reactions) but the enclave remains **owner-governed** **Examples** * A user's posts / profile / settings enclave * A user's inbox / notifications enclave * Personal "data vault" enclave used across apps Confidentiality is split across two plugins: [`identity-aead`](/spec/app/plugins/identity-aead) seals owner-only `private` events (deterministic HKDF from `identity_priv`), and [`ecdh-envelope`](/spec/app/plugins/ecdh-envelope) seals cross-enclave inbound `notice` events (one-shot sender→recipient ECDH with optional handoff sub-encryption for group-invite bootstrap). For the full RBAC v2 manifest, state transitions, and event-operator matrix, see **[Personal Enclave](/spec/app/enclaves/personal)**. #### Registration After creating a personal enclave, the owner can publish a discovery record so others can find it by public key. The Registry enclave is one such mechanism (see [`enclaves/registry.md`](/spec/app/enclaves/registry) → `reg_identity` with the `enclaves.personal` field). Other mechanisms (a well-known URL on the owner's domain, a custom directory enclave) are equally valid; the core protocol does not mandate Registry-based discovery: ``` id_pub → reg_identity → enclaves.personal → enclave_id → reg_enclave → node ``` ### Group Chat A **Group Chat Enclave** is a shared space for multi-party messaging. It uses the RBAC v2 State/trait model with PENDING, MEMBER, and BLOCKED states, plus owner/admin/muted/dataview traits. Multiple join paths (application, auto-join, direct invite), gated transitions, and trait-based moderation. Confidentiality is provided by the [`mls-lazy`](/spec/app/plugins/mls-lazy) plugin (lazy-MLS binary ratchet tree with O(log N) epoch distribution and an additive OR-wrap fallback for sub-key wallets). For the full design pattern, see **[Group Chat Enclave](/spec/app/enclaves/group)**. ### How to Choose Use **Shared** when: * The enclave represents a **group object** (a room, project, DAO, registry) * Governance and policy are **collective** or moderator-driven * Many identities are **primary contributors** Use **Personal** when: * The enclave represents **identity-scoped state** (my profile, my posts, my inbox) * You want data to be **portable across apps** * One identity should remain the **owner-of-record** ### DM (Direct Message) > For the full RBAC v2 design, see **[DM Enclave](/spec/app/enclaves/dm)**. Confidentiality is provided by the [`ratchet-pair`](/spec/app/plugins/ratchet-pair) plugin (per-contact epochs with a per-sender ratchet inside each epoch). **DM** is a **sibling enclave kind** — a personal mailbox dedicated to direct messaging. It is distinct from the Personal Enclave (the identity-anchor enclave above) and lives behind its own `enclave_id`. When the [Registry enclave](/spec/app/enclaves/registry) is used for discovery, the DM enclave's id is published under `reg_identity.enclaves.dm`. Per [enclaves/dm.md](/spec/app/enclaves/dm), the DM enclave has its own RBAC schema (states `OWNER`, `FRIEND`, `BLOCKED`; customs `invite`, `message`, `sent`, `rotate`) — entirely separate from the Personal enclave's `public` / `private` / `notice` events. Each identity therefore maintains TWO enclaves: a Personal (identity, profile, private docs, inbound notices) and a DM (incoming messages from contacts). Cross-enclave notices (e.g. group-invite) land in the Personal `notice` rail; per-contact messages land in the DM `message` rail. **How it works** * Alice sends a message event to **Bob's DM enclave** * Bob replies by sending a message event to **Alice's DM enclave** * Bob's client MAY also store a **local copy** of his outgoing reply in **Bob's own enclave**. **Properties** * Messages are **asymmetric by default** (each direction is a separate write) * Each participant maintains a **complete local history** inside their own enclave * Ownership and RBAC are enforced **per enclave**, independently * No shared or mutually-owned log is required **Implication** DM is modeled as: > "write into the recipient's enclave, optionally mirror into the sender's enclave" —not: > "append to a shared conversation log" ### Enclave Kind Metadata There is no mandatory "type" field. You MAY include a hint in metadata (e.g., `meta.enclave_kind = "shared" | "personal"`), but clients must not rely on it for security decisions. ## 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 1. [Catalog](#catalog) 2. [Capability matrix](#capability-matrix) 3. [Reference consumers (current apps)](#reference-consumers-current-apps) 4. [What each plugin spec contains](#what-each-plugin-spec-contains) 5. [How an app spec references a plugin](#how-an-app-spec-references-a-plugin) 6. [Versioning across the catalog](#versioning-across-the-catalog) 7. [Catalog Implementation Names](#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`](/spec/app/plugins/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`](/spec/app/plugins/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`](/spec/app/plugins/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`](/spec/app/plugins/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`](/spec/app/suites) — 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](/spec/app/suites#downgrade-resistance) and [`suites.md` §Peer capability advertisement](/spec/app/suites#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`](/spec/app/enclaves/dm) | [`ratchet-pair`](/spec/app/plugins/ratchet-pair) | `invite`, `message`, `sent`, `rotate`, `Move(O→F)` epoch payload | | [`enclaves/group.md`](/spec/app/enclaves/group) | [`mls-lazy`](/spec/app/plugins/mls-lazy) | `message`, `reaction`, `notice`, `rotate`, admin membership `Move` epoch payload | | [`enclaves/personal.md`](/spec/app/enclaves/personal) | [`identity-aead`](/spec/app/plugins/identity-aead) + [`ecdh-envelope`](/spec/app/plugins/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`: 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 (`-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. ## Suites (Ciphersuite Registry) ### Table of Contents 1. [Purpose](#purpose) 2. [The shared ECDH floor](#the-shared-ecdh-floor) 3. [Registered suites](#registered-suites) 4. [Suite descriptor](#suite-descriptor) 5. [Wire binding](#wire-binding) 6. [Downgrade resistance](#downgrade-resistance) 7. [Peer capability advertisement](#peer-capability-advertisement) 8. [Adding a new suite](#adding-a-new-suite) *** ### Purpose A **plugin** in this catalog is the pair `(role, suite)`. The **role** is the trust-shape — pairwise, single-owner, N-party, one-shot-directed — and the **suite** is the concrete ciphersuite that pins the AEAD, the nonce length, the KDF, and the in-circuit framing of the ciphertext above the ECDH floor. Roles and suites are **orthogonal**. The same pairwise role runs over multiple suites today (`enc-xchacha-v1` in native DM and `nostr-nip44-v2` in Nostr-bridged DM). The same suite slot can host different roles (a pairwise channel and a one-shot directed envelope can both use `enc-xchacha-v1`). The registry below is what makes that orthogonality concrete: a plugin spec says `"role: pairwise, suite: enc-xchacha-v1"`, and the wire envelope carries the suite id as an authenticated field. Before this registry, the plugin catalog welded suite into role — every plugin opened with a fixed *§Primitives (no negotiation)* table — and the only way to switch a suite was to fork the whole plugin. The mls-lazy plugin's explicit `not XChaCha20` rule (ChaCha20-Poly1305 with 12-byte nonce, per RFC 9420) is the first evidence the catalog already needed factoring; NIP-44 is the second. ### The shared ECDH floor Every registered suite operates above the same ECDH primitive: * Curve: `secp256k1` * Shared output: the 32-byte x-coordinate of the compressed point (`x_only_secret = compressed[1..33]`) * x-only public-key inputs (32 bytes) are extended with the `0x02` prefix per BIP-340 even-y convention. This is what makes suites swappable. A pairwise role gets the same `x_only_secret` whether it is wrapped under `enc-xchacha-v1` or `nostr-nip44-v2`; only the KDF and AEAD above the shared floor differ. New suites that introduce a different curve are out of scope of THIS registry and require a separate `enc-curve-vN` registry. ### Registered suites | Suite id | AEAD | Nonce | KDF | Wire framing | Source / notes | | ---------------- | ------------------------------------------ | -------- | ---------------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | | `enc-xchacha-v1` | XChaCha20-Poly1305 (libsodium) | 24 bytes | HKDF-SHA-256, `salt = b""`, `info` = ASCII domain separator | `base64( nonce(24) ‖ ct+tag )` | ENC native pairwise / single-owner / one-shot. Used by `ratchet-pair`, `ecdh-envelope`, `identity-aead` | | `mls-chacha-v1` | ChaCha20-Poly1305 (RFC 8439) | 12 bytes | HKDF-SHA-256, `salt = SHA-256 zero block`, `info` = ASCII domain | `hex( nonce(12) )` ‖ `hex( ct+tag )` | MLS-style N-party. Used by `mls-lazy`. Matches RFC 9420 conventions (explicitly NOT XChaCha20) | | `nostr-nip44-v2` | ChaCha20 + HMAC-SHA-256 (encrypt-then-MAC) | 32 bytes | HKDF-SHA-256, `salt = b"nip44-v2"`, `info` = `b""` | `base64( v ‖ nonce(32) ‖ ct ‖ tag )`, `v = 0x02` | Nostr NIP-44 v2. Externally versioned by Nostr; the suite id pins to v2 explicitly. | Implementations MUST recognize `enc-xchacha-v1` as a registered suite id. Implementations MUST recognize `mls-chacha-v1` as a registered suite id. Implementations MUST recognize `nostr-nip44-v2` as a registered suite id. Each suite id is a stable string. Future revisions of an existing suite (e.g. a NIP-44 v3) get a NEW suite id (`nostr-nip44-v3`), not a mutation of the existing one. The id is the unit of versioning. ### Suite descriptor The normative descriptor for a suite is the structured record: ```yaml id: nostr-nip44-v2 aead: chacha20-poly1305 # bytes of ct + 16-byte Poly1305 tag (or 32-byte HMAC for v2) nonce_len: 32 # bytes kdf: hkdf-sha256 kdf_salt: "nip44-v2" # ASCII; b"" when empty; SHA-256 zero block when "zero" kdf_info: "" ecdh_curve: secp256k1 wire_framing: base64(v || nonce || ct || tag) external_version: { spec: "NIP-44", revision: "v2" } # optional ``` The id MUST be lowercase ASCII, MAY use `-` separators, and MUST NOT use `_` or whitespace. Suite-id uniqueness is global; a suite intended for one role can be reused under another role unchanged. ### Wire binding Every encrypted wire envelope MUST carry the suite id as an **authenticated** field. The suite id is the discriminator the receiver uses to dispatch the decryption path; if it is not authenticated, an attacker who replaces only the suite id can force the receiver into a weaker dispatch (downgrade — see §[Downgrade resistance](#downgrade-resistance)). Authenticated can mean: (a) signed alongside the ciphertext under a credential the receiver verifies, (b) prefixed to the ciphertext and bound through the AEAD's associated-data input, or (c) tagged through HMAC under the shared KDF output. The choice is suite-local; the requirement is that the suite id and the ciphertext succeed or fail TOGETHER. The receiver MUST NOT tag-sniff (guess the suite from the ciphertext layout) and MUST NOT accept a suite id that is not in the receiver's locally-validated capability set for the sender. ### Downgrade resistance A downgrade attack is: an active attacker takes a ciphertext encrypted under suite `nostr-nip44-v2` and presents it to the receiver with the suite id rewritten to `enc-xchacha-v1` (or any weaker suite). If the receiver's dispatch accepts a fresh suite id without authentication, the attacker has substituted a path the sender did not consent to. Normative requirement: * Suite-id authentication follows §[Wire binding](#wire-binding). * A receiver MUST reject any envelope whose suite id is not present in the sender's advertised capability set (per §[Peer capability advertisement](#peer-capability-advertisement)). * Receiver tag-sniffing is forbidden by §[Wire binding](#wire-binding). ### Peer capability advertisement Senders MUST NOT probe send-side suite selection from client-local heuristics (e.g., browser `localStorage`). The protocol exposes peer capabilities through the `reg_identity` registry: an identity's `reg_identity` content MAY list a `suites` array of suite ids the identity accepts. ```json { "id_pub": "", "suites": ["enc-xchacha-v1", "nostr-nip44-v2"] } ``` A sender's suite selection process: 1. Resolve the recipient's `reg_identity` (cached or freshly fetched). 2. Intersect the recipient's `suites` with the sender's own capability set. 3. Pick the highest-preference suite in the intersection (sender-local preference order). 4. If the intersection is empty, the sender MUST fail closed — do NOT pick a default. A sender's preference order is a local policy choice (not protocol). The intersection rule and the fail-closed-on-empty rule are normative. When a recipient's `reg_identity` does not declare a `suites` array, the recipient MUST be assumed to support `enc-xchacha-v1` only. Bridges (Nostr in particular) advertise the bridged suite (`nostr-nip44-v2`) by publishing a `reg_identity` with that suite listed. ### Adding a new suite A new suite is added by: 1. Defining a new entry in §[Registered suites](#registered-suites) with the full descriptor. 2. Specifying the wire framing and the authenticated-id binding. 3. Bumping any plugin spec that opts into the new suite (the plugin's spec lists which suite ids it accepts; an existing plugin doc remains valid for its current suite set). 4. Conformance vectors: a per-suite KAT (known-answer test) directory under `claims/vectors/suites//*.json` with at least one encrypt and one decrypt vector exercising the ECDH floor and the AEAD. Adding a curve below the ECDH floor is OUT of scope; that requires a separate `enc-curve-vN` registry coordinated with this catalog. ## 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`](/spec/app/enclaves/personal) — 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)](#primitives-fixed-no-negotiation) 2. [2. Domain separators (exhaustive)](#domain-separators-exhaustive) 3. [3. Outer envelope (content only)](#outer-envelope-content-only) 4. [4. Payload field contract](#payload-field-contract) 5. [5. Inner `handoff` sub-encryption (group-invite path secret)](#inner-handoff-sub-encryption-group-invite-path-secret) 6. [6. Cross-enclave reconciliation (recipient client behavior)](#cross-enclave-reconciliation-recipient-client-behavior) 7. [7. Error & rejection rules (normative)](#error-rejection-rules-normative) 8. [8. Security properties](#security-properties) 9. [9. Compliance vectors (required in plugin tests)](#compliance-vectors-required-in-plugin-tests) 10. [10. Versioning](#versioning) *** ### 1. Primitives (fixed; no negotiation) The primitives below are normative; implementations MUST use the concrete choice specified for every row. | Primitive | Concrete choice | | ------------------------ | ------------------------------------------------------------------------------------------------------------ | | AEAD | XChaCha20-Poly1305 — **24-byte nonce, 16-byte Poly1305 tag** | | KDF | HKDF-SHA-256 — `salt = b""` (empty), `info` = ASCII domain separator, `L = 32` | | ECDH | secp256k1; `shared = 32-byte x-coordinate`; x-only inputs extended with `0x02` per BIP-340 even-y convention | | CSPRNG | `globalThis.crypto.getRandomValues` | | Pubkey encoding | lowercase hex, 32 bytes x-only | | Outer envelope encoding | `{ ciphertext: , nonce: }` — **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](/spec/kernel/spec)) 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`](/spec/kernel/spec) 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", ""], ...] # OPTIONAL plaintext routing tags ``` #### 3.3 Wire fields on the `notice` event ```jsonc { // protocol commit envelope (clear-text — see spec.md §Commit Structure): "type": "notice", "from": "", // identical to envelope.sender_pub "content": "", // the JSON-encoded envelope object below "tags": [ ["", "", ...], ... ], // protocol-standard, plaintext // ... (hash, exp, alg, sig as usual) } ``` The `content` string parses to: ```jsonc { "ciphertext": "", "nonce": "", "sender_pub": "", // 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. ```jsonc { "kind": "group_invite" | "dm_invite" | "x-", // REQUIRED — discriminator "enclave_id": "", // REQUIRED — hex64; the enclave the notice refers to "enclave_kind": "group" | "dm" | "", // REQUIRED — manifest hint "inviter": "", // REQUIRED — identity that produced the addressed action "topic": "", // OPTIONAL — group display name (for group_invite) "greeting": "", // OPTIONAL — human-readable message "manifest_hash": "", // OPTIONAL — sha256 of the source enclave's Manifest event content "move_ref": "", // OPTIONAL — for client-side cross-verification "handoff": { ... PathSecretEntry ... }, // OPTIONAL — present for kind == "group_invite"; see §5 "epoch_n": // REQUIRED iff handoff is present — the source enclave's epoch.n that the handoff secret was wrapped at } ``` | Field | Requirement | | -------------- | ------------------------------------------------------ | | `kind` | REQUIRED — discriminator | | `enclave_id` | REQUIRED — hex64; the enclave the notice refers to | | `enclave_kind` | REQUIRED — manifest hint | | `inviter` | REQUIRED — identity that produced the addressed action | | `epoch_n` | REQUIRED 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", ""]`, `["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](/spec/app/plugins/mls-lazy#4-node-key-derivation). 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 = 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 = ``` `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](/spec/app/plugins/mls-lazy#4-node-key-derivation)) 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`](/spec/app/plugins/mls-lazy) §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`](/spec/node/node-api). 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 | Property | Status | | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Envelope (content) confidentiality against the node | YES — node sees the opaque envelope object only; `notice.tags` are NOT plugin-sealed and are intentionally plaintext for routing | | Envelope confidentiality against non-owner readers | YES — `OWNER:R` is the only `R` operator on `notice` in `enclaves/personal.md`'s manifest | | Sender unforgeability of `inviter` claim | NO 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 confidentiality | YES — distinct ECDH + distinct domain separator; addressed to recipient op key. | | Forward secrecy against identity-key compromise | NO — 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 security | NO — same reason. | | Replay protection on notice events | INHERITED from protocol-level commit dedup (`commit.sig` is unique per event); the plugin adds none. | | Sub-key (multi-wallet) support | YES — 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. ## Plugin: `identity-aead` **Role:** Single-owner deterministic confidentiality. The content key is a deterministic HKDF derivation from the owner's `identity_priv` keyed by the enclave id; any device with `identity_priv` re-derives the same key. No ECDH, no ratchet, no rotation. Maximally simple — appropriate when only the owner ever reads. **Reference consumer:** [`enclaves/personal.md`](/spec/app/enclaves/personal) — confidentiality for the `private` content event (owner-only encrypted documents). Any future event whose only reader is the owning identity (per-app preferences, secret notes, wallet backups, anything `OWNER:R`-exclusive) is a candidate consumer. **Slot type (suggested):** `IdentityAeadCryptoFn` (client-side). Implementations MAY alias to their app-specific slot name (the reference impl uses `PersonalPrivateCryptoFn`). This document is the **complete wire-and-key contract**. *** ### Table of Contents 1. [1. Primitives (fixed; no negotiation)](#primitives-fixed-no-negotiation) 2. [2. Domain separator (single, enclave-scoped)](#domain-separator-single-enclave-scoped) 3. [3. Key derivation](#key-derivation) 4. [4. Encrypt / decrypt](#encrypt-decrypt) 5. [5. Event wire shape](#event-wire-shape) 6. [6. Updates (U on `private`)](#updates-u-on-private) 7. [7. Error & rejection rules (normative)](#error-rejection-rules-normative) 8. [8. Security properties](#security-properties) 9. [9. Compliance vectors (required in plugin tests)](#compliance-vectors-required-in-plugin-tests) 10. [10. Versioning](#versioning) *** ### 1. Primitives (fixed; no negotiation) The primitives below are normative; implementations MUST use the concrete choice specified for every row. The content key itself is deterministic — it is derived per §3 and MUST NOT be CSPRNG-sampled. | Primitive | Concrete choice | | --------- | --------------------------------------------------------------------------------------------------------------------------- | | AEAD | XChaCha20-Poly1305 — **24-byte nonce, 16-byte Poly1305 tag** | | KDF | HKDF-SHA-256 — **`salt = undefined`** (i.e. SHA-256 zero block), `info` = enclave-scoped ASCII separator (see §2), `L = 32` | | CSPRNG | `globalThis.crypto.getRandomValues` (for the AEAD nonce only — the key itself is deterministic) | | Encoding | `{ ciphertext: , nonce: }` — two separate hex fields per event | There is no ECDH and no curve operation in this plugin: the IKM is `identity_priv` directly. (The 32-byte secp256k1 private scalar has full entropy and is itself the secret root for the per-identity content domain.) *** ### 2. Domain separator (single, enclave-scoped) ``` info = "enc-personal-private:" || enclave_id_hex_lowercase ``` The literal prefix MUST be **`enc-personal-private:`**. `enclave_id_hex_lowercase` MUST be the consumer enclave's id encoded as a lowercase 64-hex string. The enclave id is baked into the `info` so that two distinct enclaves owned by the **same** identity produce independent content keys. *** ### 3. Key derivation The `content_key` MUST be derived deterministically from `(identity_priv, enclave_id)` by the code block below. ``` info = utf8("enc-personal-private:" || lowercase_hex(enclave_id)) content_key = HKDF(IKM=identity_priv, salt=∅, info, L=32) ``` The `content_key` is fixed for the (identity, enclave) pair and MUST NOT rotate. Implementations MUST NOT cache `content_key` in any persistent store — it MUST be re-derived from `identity_priv` on each encrypt/decrypt. Live-only caching during a session is permitted. *** ### 4. Encrypt / decrypt #### 4.1 Encrypt Encryption MUST follow the code block below; the nonce length MUST be exactly 24 bytes. ``` content_key = HKDF(identity_priv, salt=∅, info="enc-personal-private:" || enclave_id_hex, 32) nonce = CSPRNG(24) ct = XChaCha20-Poly1305(content_key, nonce).encrypt(utf8(plaintext)) event.content = { ciphertext: hex(ct), nonce: hex(nonce) } ``` #### 4.2 Decrypt Decryption MUST follow the code block below. ``` content_key = HKDF(identity_priv, salt=∅, info="enc-personal-private:" || enclave_id_hex, 32) plaintext = utf8_decode( XChaCha20-Poly1305(content_key, fromHex(nonce)).decrypt(fromHex(ciphertext)) ) ``` *** ### 5. Event wire shape `private` events use a JSON `content` whose fields are this plugin's encrypted envelope. The application MAY embed the envelope under a stable key (e.g. `{ "doc": { ciphertext, nonce } }`); implementations MUST agree on the embed shape per application convention. When no application convention dictates otherwise, the envelope MUST be the top-level object below. ```jsonc { "ciphertext": "", "nonce": "" } ``` The `private` event's RBAC is `OWNER: CRUD` — only the owner reads or writes. The node MUST NOT decrypt. *** ### 6. Updates (U on `private`) A `private` event MAY be `Update`d by `OWNER:U`. The Update event MUST carry a fresh `(ciphertext, nonce)` pair under the **same `content_key`** (the key is identity-derived and never rotates). The update mechanism is the protocol's standard Update/Delete (`[r, ]` tag); this plugin does not alter it. *** ### 7. Error & rejection rules (normative) An implementation MUST: 1. Reject when AEAD verification fails (`content_key` is wrong → the loader was passed a different identity). 2. Reject when `ciphertext` or `nonce` are not lowercase hex; when `nonce` does not decode to exactly **24 bytes** (the XChaCha20-Poly1305 nonce length pinned in §1); or when `ciphertext` decodes to fewer than **16 bytes** (the Poly1305 tag minimum — any shorter ciphertext cannot pass AEAD verification). 3. NOT silently re-derive `content_key` under a different `info`. The `enclave_id` used in `info` MUST be the **current enclave** the event is stored in — never a referenced enclave from another field. *** ### 8. Security properties Every row below is normative for compliant implementations. | Property | Status | | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | Confidentiality against the node | YES — node stores opaque ciphertext | | Confidentiality against non-owner readers | YES — `OWNER:R` is the only `R` operator on `private` in `enclaves/personal.md` | | Multi-device support | YES — every device with `identity_priv` re-derives the same `content_key` | | Forward secrecy against identity-key compromise | NO — `identity_priv` is the IKM; compromising it reveals every past, present, and future `private` document. By design. | | Post-compromise security | NO — same reason; no key rotation. The threat model assumes the identity key is the trust root for owner-only data. | | Rotation | NONE — keys never change. Use the protocol's `Delete` event to retire content if the threat model requires it. | | Per-event key isolation | YES — each event has a unique random `nonce`; AEAD provides per-nonce key reuse safety. | This is the simplest of the four encryption plugins on purpose: single-owner, single-key, deterministic. Any future "owner-key-rotation" capability would necessitate a new plugin (`identity-aead-v2`) and a new consuming-event content schema. *** ### 9. Compliance vectors (required in plugin tests) Implementations MUST publish KAT vectors for: 1. Deterministic key derivation: fixed `(identity_priv, enclave_id)` → expected `content_key` bytes. 2. Round-trip encrypt/decrypt under one `(identity, enclave)` for two different plaintexts and verify both `nonce`s differ. 3. Cross-enclave isolation: same `identity_priv`, different `enclave_id` → `content_key` differs and ciphertext from one cannot be decrypted by a key derived for the other. *** ### 10. Versioning This plugin is version `1`. Any change to the domain separator (including switching to the colon-canonical form `enc:identity-aead:`), the AEAD, the nonce length, or the KDF MUST require a new plugin (`identity-aead-v2`) and an application-layer migration path (re-encrypt existing events under the new key, store both in a transition window). ## Plugin: `mls-lazy` **Role:** Shared-secret confidentiality for an N-party enclave. A **binary ratchet tree** keyed to sorted member pubkeys distributes each epoch's root secret with O(log N) per-commit envelope cost, plus a flat **OR-wrap fallback** (`epoch_or_wraps`) for members whose **operating key** differs from the parent `id_pub` keyed into the tree (sub-key wallets, e.g. MetaMask). Per-message confidentiality uses a per-sender HKDF ratchet over the shared epoch secret. **Reference consumer:** [`enclaves/group.md`](/spec/app/enclaves/group) — confidentiality for `message`, `reaction`, `notice`, and the `epoch` payload on admin-created `Move` / `rotate`. Any future app whose enclave has a dynamic N-party membership and needs end-to-end shared-secret messaging (forum, project room, channel, multi-party registry) is a candidate consumer. **Slot type (suggested):** `MlsLazyCryptoFn` (client-side). Implementations MAY alias to their app-specific slot name (the reference impl uses `GroupCryptoFn` for historical reasons). This document is the **complete wire-and-key contract**. Two compliant implementations MUST agree byte-for-byte on the envelope and the message-key derivation for any input set. *** ### Table of Contents 1. [1. Primitives (fixed; no negotiation)](#primitives-fixed-no-negotiation) 2. [2. Domain separators (exhaustive)](#domain-separators-exhaustive) 3. [3. Tree topology](#tree-topology) 4. [4. Node key derivation](#node-key-derivation) 5. [5. Commit envelope — `prepareCommit` / `consumeCommit`](#commit-envelope-preparecommit-consumecommit) 6. [6. Multi-key OR-wrap (`epoch_or_wraps`) — additive fallback](#multi-key-or-wrap-epoch_or_wraps-additive-fallback) 7. [7. Per-sender message ratchet](#per-sender-message-ratchet) 8. [8. Monotonicity (client-validated)](#monotonicity-client-validated) 9. [9. Recovery (CT replay)](#recovery-ct-replay) 10. [10. Error & rejection rules (normative)](#error-rejection-rules-normative) 11. [11. Security properties](#security-properties) 12. [12. Compliance vectors (required in plugin tests)](#compliance-vectors-required-in-plugin-tests) 13. [13. Versioning](#versioning) *** ### 1. Primitives (fixed; no negotiation) | Primitive | Concrete choice | | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | AEAD | ChaCha20-Poly1305 (RFC 8439) — **12-byte nonce, 16-byte Poly1305 tag**. (Note: ChaCha20-Poly1305 with 12-byte nonce, not XChaCha20; matches MLS RFC 9420 conventions.) | | KDF | HKDF-SHA-256 — `salt = undefined` (i.e. SHA-256 zero block), `info` = ASCII domain separator, `L = 32` bytes | | ECDH | secp256k1. `shared = bytes [1..33] of compressed point` (= 32-byte x-coordinate). x-only inputs are extended with `0x02` prefix per BIP-340 even-y convention. | | CSPRNG | `globalThis.crypto.getRandomValues` | | Pubkey encoding | lowercase hex; x-only 32 bytes (64 chars) | | Hex / binary on the wire | Ciphertext + nonce per-entry as **lowercase hex strings** (not base64 — this differs from `ratchet-pair`) | Forbidden: substituting any other AEAD, different nonce length, alternate hex case, non-deterministic member ordering. *** ### 2. Domain separators (exhaustive) ``` enc:mls:node-priv # HKDF → 32-byte secret → mod-n → secp256k1 scalar enc:mls:child:left # HKDF parent_secret → left-child secret enc:mls:child:right # HKDF parent_secret → right-child secret enc:mls:path-wrap # HKDF ECDH(eph_priv, node_pub) → AEAD key for path-secret wrap enc:mls:epoch # HKDF root_secret → epoch_secret enc:group:ratchet:init: # HKDF epoch_secret → sender chain seed enc:group:ratchet:advance # HKDF chain[i] → chain[i+1] enc:group:ratchet:message # HKDF chain[i] → message_key enc:group:epoch_dist # HKDF ECDH(committer_priv, op_pub) → AEAD key for OR-wrap entry (§6) ``` No other separators are used. Forbidden additions or aliases. *** ### 3. Tree topology Members are sorted by hex pubkey **ascending** to deterministic leaf positions; padded to the next power of two. ``` paddedLeafCount(N) = next_power_of_2(N) (1 if N≤1) totalNodes(N) = 2 · L − 1 where L = paddedLeafCount(N) treeDepth(N) = log2(L) ``` Node IDs are **breadth-first, 0-indexed**: root = 0, level-1 = 1, 2, level-2 = 3, 4, 5, 6, …. ``` parent(n) = (n − 1) >> 1 (− if n == 0) sibling(n) = n + 1 if n odd else n − 1 leafNodeId(i) = (L − 1) + i where i is the leaf index in the sorted member list ``` `directPath(leaf)` is the inclusive list `[leaf, parent(leaf), …, 0]`. `copath(leaf)` is `[sibling(leaf), sibling(parent(leaf)), …]` — length = `treeDepth(N)`. `subtreeLeafIndices(node)` returns the sorted leaf indices reachable from `node`. A padded slot (index ≥ N) is treated as absent — implementations MUST NOT emit an entry for an empty subtree. Tree topology is **fully derivable** from the sorted member list; any device replaying the CT reconstructs it bit-for-bit. *** ### 4. Node key derivation Each tree node has a 32-byte **secret** that derives an ephemeral secp256k1 keypair. Direction: parent → child via HKDF; secret → keypair via HKDF + curve-order reduction. ``` deriveChildSecret(parent_secret, side): # side ∈ {"left", "right"} HKDF(IKM=parent_secret, info="enc:mls:child:" || side, L=32) keypairFromSecret(secret): expanded = HKDF(IKM=secret, info="enc:mls:node-priv", L=32) bn = int_be(expanded) bn = bn mod n # secp256k1 order; if bn == 0, set bn = 1 priv = 32-byte big-endian of bn pub = x_only(secp256k1.getPublicKey(priv, compressed=true)) # 32 bytes return { priv, pub } ``` `buildTreeSecrets(root_secret, N)` populates `Map` going DOWN by BFS: ``` secrets[0] = root_secret for n in [0 .. L−2]: secrets[2n+1] = deriveChildSecret(secrets[n], "left") secrets[2n+2] = deriveChildSecret(secrets[n], "right") ``` `epoch_secret = HKDF(root_secret, info="enc:mls:epoch", L=32)` — the input to the per-sender ratchet (§7). The separation is non-negotiable: `root_secret` and `epoch_secret` MUST NOT be reused interchangeably. *** ### 5. Commit envelope — `prepareCommit` / `consumeCommit` #### 5.1 Wire shape The commit envelope is the value of a top-level `epoch` field on the carrying event content (Move with epoch, standalone `rotate`, etc.): ```jsonc { "epoch": { "n": , // strictly monotonic per §8 "committer": "", "encrypted_path_secrets": [ { "node": , "ciphertext": "", "nonce": "", "ecdh_pub": "" }, ... ] }, "epoch_or_wraps": [ // OPTIONAL; see §6 { "recipient": "", "ecdh_pub": "", "ciphertext": "", "nonce": "" }, ... ] } ``` `wireEnvelope(envelope)` returns the inner `{epoch:{…}}` shape; `parseWireEnvelope(content)` is its inverse (returns `null` for content without a well-formed `epoch`). #### 5.2 `prepareCommit` (committer) Inputs: ``` sortedMembers : string[] # current member pubs AFTER the membership change; MUST be sorted ascending committerPubHex : string # MUST be in sortedMembers prevEpochN : int # the highest existing epoch.n; -1 for the very first commit prevTreeState : TreeState? # {members: string[], secrets: Map} from previous commit; null if none newMembers : string[] # pubs newly added in this commit (need Welcome entries) ``` Algorithm: ``` 1. Validate sortedMembers ≠ ∅ ; committerPubHex ∈ sortedMembers ; sortedMembers is strictly ascending hex. 2. reusableSecrets = prevTreeState (if its members == sortedMembers, EXACTLY) else null. 3. ephPriv = CSPRNG(32) (valid secp256k1 scalar) ; ephPub = x_only(getPublicKey(ephPriv)) 4. newRootSecret = CSPRNG(32) 5. newTreeSecrets = buildTreeSecrets(newRootSecret, |sortedMembers|) 6. newEpochSecret = HKDF(newRootSecret, "enc:mls:epoch", 32) 7. entries = [] 8. for cpNode in copath(leafNodeId(committerLeafIdx)): subLeaves = subtreeLeafIndices(cpNode) if subLeaves empty: continue # padded subtree if reusableSecrets ≠ null AND cpNode ∈ reusableSecrets: cpPub = keypairFromSecret(reusableSecrets[cpNode]).pub # steady-state copath else: # bootstrap cpPub = sortedMembers[ subLeaves[0] ] # encrypt to leftmost subtree leaf's identity pub entries += ecdhWrap(newRootSecret, cpPub, ephPriv, ephPub, cpNode) 9. Emit Welcome entries (identity-pub-keyed leaf entries): if reusableSecrets == null: # full bootstrap for i in [0..|sortedMembers|): if i == committerLeafIdx: continue myLeafN = leafNodeId(i) # skip if covered as leftmost-of-subtree above ancestor = first cp in committer copath whose subtree contains i if ancestor exists AND subtreeLeafIndices(ancestor)[0] == i: continue entries += ecdhWrap(newRootSecret, sortedMembers[i], ephPriv, ephPub, myLeafN) else: # steady state: only newMembers need bootstrap for newPub in newMembers: i = sortedMembers.indexOf(newPub) if i < 0 OR i == committerLeafIdx: continue myLeafN = leafNodeId(i) if myLeafN already in entries: continue entries += ecdhWrap(newRootSecret, newPub, ephPriv, ephPub, myLeafN) 10. return: newEpochSecret newTreeState = { members: clone(sortedMembers), secrets: newTreeSecrets } envelope = { n: prevEpochN + 1, committer: committerPubHex, encrypted_path_secrets: entries } ``` `ecdhWrap(plaintext, recipientPub, ephPriv, ephPub, nodeId)`: ``` fullPub = 0x02 || recipientPub # x-only → even-y full point shared = secp256k1.getSharedSecret(ephPriv, fullPub)[1..33] key = HKDF(shared, info="enc:mls:path-wrap", L=32) nonce = CSPRNG(12) ct = ChaCha20-Poly1305(key, nonce).encrypt(plaintext) return { node: nodeId, ciphertext: hex(ct), nonce: hex(nonce), ecdh_pub: hex(ephPub) } ``` #### 5.3 `consumeCommit` (receiver) Inputs: ``` sortedMembers, myPubHex, identityPriv prevTreeState : TreeState? # null for bootstrap or stale member list envelope : { n, committer, encrypted_path_secrets[] } prevHighestN : int? # if provided, enforce envelope.n > prevHighestN strictly expectedCommitter : string? # if provided, enforce envelope.committer == expectedCommitter ``` Algorithm: ``` 1. Validate: envelope.n ≥ 0 integer; if prevHighestN given, envelope.n > prevHighestN; expectedCommitter equality. 2. myLeafIdx = sortedMembers.indexOf(myPubHex); MUST be ≥ 0 3. myLeafNode = leafNodeId(myLeafIdx) 4. myPath = set(directPath(myLeafNode)) 5. reusableSecrets = prevTreeState if its members == sortedMembers else null 6. for entry in envelope.encrypted_path_secrets: if entry.node ∉ myPath: continue candidatePrivs = [] if reusableSecrets[entry.node] exists: candidatePrivs += keypairFromSecret(reusableSecrets[entry.node]).priv # steady-state path-priv # identity priv is only legal for our own leaf entry or the leftmost-leaf-of-subtree bootstrap entry: if entry.node == myLeafNode: candidatePrivs += identityPriv else: subL = subtreeLeafIndices(entry.node) if subL non-empty AND subL[0] == myLeafIdx: candidatePrivs += identityPriv for priv in candidatePrivs: try: rootSecret = ecdhUnwrap(entry, priv) # MUST be 32 bytes newTreeSecrets = buildTreeSecrets(rootSecret, |sortedMembers|) return { newEpochSecret: HKDF(rootSecret, "enc:mls:epoch", 32), newTreeState: { members: clone(sortedMembers), secrets: newTreeSecrets }, } except AEAD failure: continue 7. if no entry decrypted: try OR-wrap fallback (§6); if still none → throw NOT_DECRYPTABLE ``` `ecdhUnwrap(entry, recipientPriv)`: ``` fullPub = 0x02 || fromHex(entry.ecdh_pub) shared = secp256k1.getSharedSecret(recipientPriv, fullPub)[1..33] key = HKDF(shared, "enc:mls:path-wrap", 32) return ChaCha20-Poly1305(key, fromHex(entry.nonce)).decrypt(fromHex(entry.ciphertext)) ``` #### 5.4 Tree-state cache invalidation `prevTreeState` is **reusable only when `members` is EXACTLY the same list (order-equal, length-equal)** as `sortedMembers` for this commit. Any add, remove, or reorder shifts leaf positions → every cached node priv references a stale slot → ignore them and bootstrap from `identityPriv`. A bare `Map` (v2 legacy shape) MUST be treated as stale. *** ### 6. Multi-key OR-wrap (`epoch_or_wraps`) — additive fallback The tree-keyed `encrypted_path_secrets` distribute to each member's **parent identity pub** (`id_pub`). Members whose **operating key** is a derived `sub_pub` (sub-key wallets — see [`spec.md` §Delegated Sub-keys](/spec/kernel/spec)) cannot ECDH against `id_pub` and cannot decrypt their tree entry. The committer additively emits a **flat per-recipient OR-wrap** of the *same* `root_secret` to each member's operating key(s): ``` for member in sortedMembers: ops = unique([ member.id_pub, member.sub_pub ]) # 1 entry if parent == sub for op_pub in ops: shared = ECDH(committer_priv, op_pub) dist_key = HKDF(shared, "enc:group:epoch_dist", 32) nonce = CSPRNG(12) # 12-byte nonce, matching §1 ct = ChaCha20-Poly1305(dist_key, nonce, root_secret) push: { recipient: op_pub, ecdh_pub: hex(committer_pub), ciphertext: hex(ct), nonce: hex(nonce) } ``` The plaintext wrapped is the **same `root_secret`** the tree wraps; receivers feed it through the same `buildTreeSecrets` + `HKDF(…, "enc:mls:epoch")` chain. `epoch_or_wraps` rides alongside `epoch` on the same event content (Move-with-epoch, `rotate`, or a separate Welcome). Receiver fallback (continuation of §5.3 step 6): ``` 7. for w in content.epoch_or_wraps: if w.recipient ≠ my_op_pub: continue try: shared = ECDH(my_op_priv, fromHex(w.ecdh_pub)) dist_key = HKDF(shared, "enc:group:epoch_dist", 32) rootSecret = ChaCha20-Poly1305(dist_key, fromHex(w.nonce)).decrypt(fromHex(w.ciphertext)) return { newEpochSecret: HKDF(rootSecret, "enc:mls:epoch", 32), newTreeState: { members: clone(sortedMembers), secrets: buildTreeSecrets(rootSecret, |sortedMembers|) } } except AEAD: continue ``` #### 6.1 Required recipients (REVISED) The committer's own leaf is **NOT** included in `encrypted_path_secrets` (the tree wraps target the committer's copath, not the committer themselves — see §5.2 step 8). Without a dedicated entry, a fresh device replaying the CT under the committer's `identity_priv` cannot recover any epoch the prior device authored. Therefore `epoch_or_wraps` MUST always carry at least: 1. An entry for the **committer's own operating key** (`committer_op_pub`). When the committer has no distinct sub-key, this is their `committer_pub` itself. Self-ECDH (`ECDH(committer_priv, committer_pub)`) produces a well-defined 32-byte shared and lets a fresh committer device unwrap on CT replay. 2. One entry per member whose **operating key differs from their parent `id_pub`** keyed into the tree (the sub-key-wallet case described in §6). Entries for members whose `parent == sub` (ECDH-capable wallets — dev / passkey / Nostr / NFC) are OPTIONAL — they recover via the tree path. Implementations MAY emit an entry for every member's operating key plus the committer's own pub, accepting the bandwidth cost for simplicity; minimal compliant implementations MAY restrict to (1) + (2) only. `epoch_or_wraps` MUST NOT be omitted entirely from a commit. Implementations MUST tolerate `epoch_or_wraps` arrays of any length ≥ 1. The prior wording — "when every member has parent == sub, the OR-wrap field MAY be omitted entirely" — was wrong: it omitted the committer's self-wrap and broke fresh-device CT replay. Implementations conforming to the prior wording MUST be updated. *** ### 7. Per-sender message ratchet For a member sending `sender_seq = i ≥ 0` under `epoch_secret`: ``` chain[0] = HKDF(IKM=epoch_secret, info="enc:group:ratchet:init:" || sender_pub_hex_lowercase, L=32) chain[i+1] = HKDF(IKM=chain[i], info="enc:group:ratchet:advance", L=32) message_key = HKDF(IKM=chain[i], info="enc:group:ratchet:message", L=32) ``` Each sender has an **independent chain** seeded with their own pub — multiple members sending in the same epoch never collide. Receivers re-derive on demand from `epoch_secret + sender_pub_hex + sender_seq`; no persistent ratchet state is required for read. #### 7.1 Message envelope ```jsonc { "epoch_n": , // the epoch.n that produced message_key "sender_pub": "", "sender_seq": , // sender's own per-epoch counter "ciphertext": "", "nonce": "" // 12 bytes } ``` The application wraps this envelope as it sees fit (in `message.content`, etc.). The receiver resolves `epoch_secret` from `epoch_n` via their per-group epoch map (§9). #### 7.2 `encryptMessage` / `decryptMessage` ``` encryptMessage(epoch_secret, epoch_n, sender_pub, sender_seq, plaintext_bytes): key = deriveSenderMessageKey(epoch_secret, sender_pub, sender_seq) # §7 nonce = CSPRNG(12) ct = ChaCha20-Poly1305(key, nonce).encrypt(plaintext_bytes) return { epoch_n, sender_pub, sender_seq, ciphertext: hex(ct), nonce: hex(nonce) } decryptMessage(epoch_secret, envelope): key = deriveSenderMessageKey(epoch_secret, envelope.sender_pub, envelope.sender_seq) return ChaCha20-Poly1305(key, fromHex(envelope.nonce)).decrypt(fromHex(envelope.ciphertext)) ``` *** ### 8. Monotonicity (client-validated) ``` envelope.n MUST be > prevHighestN # strictly greater ``` `prevHighestN` is the highest `epoch.n` the client has observed for this group from any valid commit. Implementations MUST reject commits whose `n` is ≤ `prevHighestN`. Replay defense. *** ### 9. Recovery (CT replay) To rebuild the per-group epoch map from cold storage: ``` state: { n → epoch_secret } prevTreeState ← null prevHighestN ← -1 for evt in CT (in seq order): env = parseWireEnvelope(evt.content) if env == null: continue try: result = consumeCommit({ sortedMembers: members-at-time-of-evt, myPubHex, identityPriv, prevTreeState, envelope: env, prevHighestN, expectedCommitter: env.committer, }) state[env.n] = result.newEpochSecret prevTreeState = result.newTreeState prevHighestN = env.n except NOT_DECRYPTABLE: # try epoch_or_wraps fallback inline (§6); if still none, this member was kicked # at this epoch — DO NOT advance prevTreeState; future commits may re-include them skip ``` `sortedMembers-at-time-of-evt` is recomputed by replaying RBAC Move events up to the bundle containing `evt`. Membership is **fully derivable from the CT** — no plugin state needs persisting across reloads (the secrets MAY be cached for prover speed, but MUST be reconstructable from `identityPriv` + CT). *** ### 10. Error & rejection rules (normative) An implementation MUST: 1. Throw / reject when `envelope.n` is not a non-negative integer or violates §8. 2. Throw / reject when `envelope.committer` is missing or, if `expectedCommitter` was provided, does not match. 3. Throw / reject when `envelope.encrypted_path_secrets` is not an array. 4. Throw `NOT_DECRYPTABLE` when no entry on this member's `directPath` decrypts under either the node-derived priv (from `prevTreeState`) or `identityPriv`, **and** no `epoch_or_wraps[i].recipient == my_op_pub` decrypts either. 5. Validate `sortedMembers` is strictly ascending; reject otherwise (a malformed group is unspecified). 6. Reject any candidate decryption whose recovered plaintext — whether a tree-path `rootSecret` (§5.3) or an `epoch_or_wraps` `rootSecret` (§6) — is not exactly 32 bytes; continue iterating other entries before raising `NOT_DECRYPTABLE`. Implementations SHOULD log AEAD failures only at debug; iteration is expected. *** ### 11. Security properties | Property | Status | | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Per-message key isolation | YES — unique key per `(sender_pub, sender_seq)` via per-sender chain | | Per-sender chain isolation | YES — `sender_pub` is bound in the seed (`enc:group:ratchet:init:`) | | Per-epoch forward secrecy | YES — independent random `root_secret`/`epoch_secret` per commit | | Forward secrecy of wrap envelopes | YES — every wrap uses a fresh ephemeral `(ephPriv, ephPub)`. Compromising a node priv later does NOT reveal past wraps. | | Forward secrecy on member removal | YES — removed member is not in the new `sortedMembers`, has no entry in the new envelope, and the tree topology shifts so their cached node privs are stale. | | Backward secrecy on member add | YES — new member's first epoch is the one created by the commit that adds them; they have no key material from prior epochs (and the CT-stored prior wraps were not encrypted to them). | | Post-compromise security against identity-key compromise | NO — `identityPriv` recovers every Welcome entry and (via OR-wrap §6) every fallback wrap from the CT. CT-replay tradeoff. | | Stateless decryption | YES — `epoch_secret + sender_pub + sender_seq` is sufficient; no persisted ratchet state required for read. | | Multi-device support | YES — `identityPriv` (and `sub_priv` when applicable) is sufficient. | | Sub-key wallet support | YES (OR-wrap §6) — additive, additive-only, never replaces the tree. | | Strict commit monotonicity (replay defense) | YES — §8. | *** ### 12. Compliance vectors (required in plugin tests) Implementations MUST publish KAT vectors for: 1. Tree shape for `N ∈ {1, 2, 3, 4, 7, 8}` — node IDs, copath, directPath, subtreeLeafIndices. 2. `keypairFromSecret(fixed 32-byte secret)` — deterministic priv + x-only pub. 3. `buildTreeSecrets(fixed root_secret, N=4)` — full secrets map. 4. `prepareCommit` round-trip: each of `N ∈ {2,3,4,8}` members on a fresh bootstrap, then on a steady-state add, then a remove. 5. `consumeCommit` recovers the same `epoch_secret` and `newTreeState` as `prepareCommit`. 6. Per-sender ratchet: `message_key` derivation for `(epoch, sender, seq) ∈ {(E1, S1, 0), (E1, S1, 5), (E1, S2, 0), (E1, S2, 5)}`. 7. OR-wrap (`epoch_or_wraps`): bootstrap fails on tree path for a sub-key recipient; OR-wrap fallback recovers same `epoch_secret`. 8. Strict monotonicity: `consumeCommit` with `prevHighestN ≥ envelope.n` is rejected. Reference test vectors are published alongside the plugin package and are authoritative for conformance. *** ### 13. Versioning This plugin is version `1`. Any change to a domain separator, the AEAD primitive, the nonce length, the KDF, member-sort order, or tree topology requires a new plugin (`mls-lazy-v2`) and a new manifest field — clients negotiate by manifest, never by tag-sniffing. ## 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`](/spec/app/enclaves/dm) — 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)](#primitives-fixed-no-negotiation) 2. [2. Domain separators (exhaustive — case-sensitive ASCII)](#domain-separators-exhaustive-case-sensitive-ascii) 3. [3. Key schedule](#key-schedule) 4. [4. Wire format (event content)](#wire-format-event-content) 5. [5. Per-contact monotonicity (client-validated)](#per-contact-monotonicity-client-validated) 6. [6. Multi-recipient OR-wrap (repeatable `epoch` tag)](#multi-recipient-or-wrap-repeatable-epoch-tag) 7. [7. Epoch recovery (multi-device join / fresh login)](#epoch-recovery-multi-device-join-fresh-login) 8. [8. Error & rejection rules (normative)](#error-rejection-rules-normative) 9. [9. Security properties](#security-properties) 10. [10. Compliance vectors (required in plugin tests)](#compliance-vectors-required-in-plugin-tests) 11. [11. Versioning](#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](/spec/app/enclaves/registry#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) | | ciphertext+tag )\`\*\* as a single string | **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: ``` 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", ""] ``` 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](https://github.com/nostr-protocol/nips/blob/master/44.md)): 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` ```jsonc { "epoch": , // epoch.n that produced the message_key "sender_seq": , // ≥ 0; the sender's per-epoch counter "ciphertext": "" } ``` #### 4.2 `invite` ``` content: "" # the encrypted greeting tags: ["enclave_id", ""] # the sender's DM enclave (for the recipient to reply into) ["epoch", "", "", ""] # 1+ tags — see §6 ``` #### 4.3 `sent` (owner's own copy of an outgoing message) ``` content: "" tags: [["to", ""]] ``` #### 4.4 `rotate` (DM epoch rotation) ```json { "target": "", "epoch": { "n": , "encrypted_secret": "", "ecdh_pub": "" } } ``` 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`](/spec/app/plugins/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) ```json { "target": "", "from": "OUTSIDER", "to": "FRIEND", "epoch": { "n": 0, "encrypted_secret": "...", "ecdh_pub": "" } } ``` 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`](/spec/app/enclaves/registry#reg_identity), 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](/spec/kernel/spec)). This plugin only consumes recipient pubkeys as published in [`reg_identity.sub_pub`](/spec/kernel/spec). #### 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 | 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: 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. ## DM Enclave This document specifies the DM (direct-messaging) enclave application profile: a personal mailbox where the owner reads their own enclave and others write to it, with end-to-end confidentiality provided by the [`ratchet-pair`](/spec/app/plugins/ratchet-pair) plugin. *** ### Table of Contents 1. [Overview](#overview) 2. [Manifest](#manifest) 3. [State Transitions](#state-transitions) 4. [Event-Operator Matrix](#event-operator-matrix) 5. [Content Events](#content-events) 6. [Confidentiality](#confidentiality) *** ### Overview A DM enclave is a personal mailbox for direct messaging. Each user has one DM enclave for ALL conversations. The owner reads from their own enclave; others write to it. * Alice's enclave holds messages from Bob, Charlie, and anyone else she's friends with. * Each contact is a separate identity in the SMT (Bob=FRIEND, Charlie=FRIEND, etc.). * Friends can send messages but cannot read — no FRIEND:R. Only OWNER reads. * Sender:UD isolates per-sender: Bob can edit/delete Bob's messages, not Charlie's. Conversation between Alice and Bob spans two enclaves: Bob writes to Alice's enclave, Alice writes to Bob's enclave. Each owner reads from their own. ### Manifest ```json { "states": ["OWNER", "FRIEND", "BLOCKED"], "traits": [], "readers": [ { "type": "OWNER", "reads": "*", "retention": "current" } ], "moves": [ { "event": "Move", "from": "OUTSIDER", "to": "FRIEND", "operator": "OWNER", "ops": ["C"] }, { "event": "Move", "from": "OUTSIDER", "to": "BLOCKED", "operator": "OWNER", "ops": ["C"] }, { "event": "Move", "from": "FRIEND", "to": "OUTSIDER", "operator": "OWNER", "ops": ["C"] }, { "event": "Move", "from": "FRIEND", "to": "BLOCKED", "operator": "OWNER", "ops": ["C"] }, { "event": "Move", "from": "BLOCKED", "to": "FRIEND", "operator": "OWNER", "ops": ["C"] }, { "event": "Move", "from": "BLOCKED", "to": "OUTSIDER", "operator": "OWNER", "ops": ["C"] } ], "grants": [], "transfers": [], "slots": [], "lifecycle": [ { "event": "Terminate", "operator": "OWNER", "ops": ["C"] } ], "customs": [ { "event": "invite", "operator": "OUTSIDER", "ops": ["C"], "alias": "invites", "gate": { "operator": ["OWNER"] } }, { "event": "invite", "operator": "OWNER", "ops": ["D"] }, { "event": "message", "operator": "OWNER", "ops": ["D"] }, { "event": "message", "operator": "FRIEND", "ops": ["C"] }, { "event": "message", "operator": "Sender", "ops": ["U", "D"] }, { "event": "message", "operator": "BLOCKED", "ops": ["_U", "_D"] }, { "event": "sent", "operator": "OWNER", "ops": ["C", "U"] }, { "event": "rotate", "operator": "OWNER", "ops": ["C"] } ], "init": [ { "identity": "", "state": "OWNER", "traits": [] } ] } ``` The manifest JSON above is normative: every field, every entry, and every ops list is part of the contract implemented by conforming DM implementations. #### Design Rationale **States**: `["OWNER", "FRIEND", "BLOCKED"]` * OUTSIDER (0) = no relationship * OWNER (1) = the enclave owner, bootstrapped via init * FRIEND (2) = accepted contact, can send messages * BLOCKED (3) = blocked by owner **No PENDING state** — the owner adds contacts directly. Move(OUTSIDER→FRIEND) is a unilateral owner decision. No Self-targeting Moves needed. **No traits** — DM is private messaging, no service accounts or push delivery needed. **Operators**: * `OWNER` (State) — reads everything via readers. Manages all contacts (Moves). Deletes invites. Creates rotate events. Admin: Gate, Terminate. * `FRIEND` (State) — can create messages. * `Sender` (Context) — message edit/delete by original sender. * `OUTSIDER` (State) — can create invite events (gated). **Owner-initiated model** — Each owner controls who enters their own enclave. The owner adds a contact by Move(OUTSIDER→FRIEND), then notifies the other party via an `invite` event in their enclave. The other party adds back when ready. No cross-enclave state coupling. **Gate on invites** — The `invites` gate lets the owner close their inbox to new invite events. When closed, invite creation is rejected before RBAC runs. **No moves to OWNER** — OWNER is bootstrapped via init, never changes state. * All state transitions in a DM enclave MUST be operated by `OWNER`. * No Move event in a DM enclave MUST NOT target the `OWNER` state. * A DM enclave MUST NOT define any Self-targeting Moves. * Identities in OUTSIDER, FRIEND, or BLOCKED states MUST NOT read events in a DM enclave. ### State Transitions | Transition | Operator | Meaning | | ------------------ | -------- | --------------------- | | OUTSIDER → FRIEND | OWNER | Add contact | | OUTSIDER → BLOCKED | OWNER | Preemptive block | | FRIEND → OUTSIDER | OWNER | Remove contact | | FRIEND → BLOCKED | OWNER | Block contact | | BLOCKED → FRIEND | OWNER | Unblock to friend | | BLOCKED → OUTSIDER | OWNER | Remove from blocklist | All transitions are OWNER-only. No Self-targeting Moves, no external actors changing state. ### Event-Operator Matrix | Event | OWNER | OUTSIDER | FRIEND | BLOCKED | Sender | | ----------------------- | ----- | -------- | ------ | ------- | ------ | | invite | RD | C | | | | | Gate(invites) | CR | | | | | | message | RD | | C | \_U\_D | UD | | sent | CRU | | | | | | rotate | CR | | | | | | Move(OUTSIDER, FRIEND) | CR | | | | | | Move(OUTSIDER, BLOCKED) | CR | | | | | | Move(FRIEND, OUTSIDER) | CR | | | | | | Move(FRIEND, BLOCKED) | CR | | | | | | Move(BLOCKED, FRIEND) | CR | | | | | | Move(BLOCKED, OUTSIDER) | CR | | | | | | Terminate | CR | | | | | Columns: States (OWNER, OUTSIDER, FRIEND, BLOCKED) → Contexts (Sender). The DM event–operator matrix above is normative: `dmAuthorize(event, operator, op) = true` iff the matrix cell for `(event, operator)` contains `op` (closed-default — any `(event, operator, op)` triple not listed MUST be denied). ### Content Events #### invite Contact initiation from an OUTSIDER. * Only identities currently in `OUTSIDER` state MAY create an `invite` event; `FRIEND`, `OWNER`, and `BLOCKED` MUST NOT create invites. * The owner MUST be able to read and to delete `invite` events. * When the `invites` gate is closed, `invite` creation MUST be rejected before RBAC runs. * The `invite` event's `content` and key-distribution `tags` MUST be sealed using the `ratchet-pair` `invite` envelope (see Confidentiality §App-payload contract below). #### message Incoming DM message. * A `message` event MUST be created by an identity in `FRIEND` state (FRIEND:C). * The original sender MUST be able to update (Sender:U) or delete (Sender:D) their own `message` events. * The `OWNER` MUST be able to delete `message` events (OWNER:D) for local cleanup. * After OWNER:D on a `message`, subsequent Sender:U or Sender:D against that event MUST be rejected with an `EVENT_DELETED` error because the event's `0x01 EventStatus` is terminal. The sender's `sent` copy in their own enclave is unaffected. * A `message` event's `content` MUST be a `ratchet-pair` per-message envelope. * A `message` event's `tags` MAY carry the sender's outgoing `epoch` wrap, with one tag per recipient operating key (REPEATABLE). #### sent Owner's copy of outgoing messages, for cross-device sync. * The `sent` event MUST be readable, creatable, and updatable only by `OWNER` (OWNER:C, OWNER:R, OWNER:U). * A `sent` event's `content` MUST be a `ratchet-pair` `sent` envelope. * A `sent` event MUST include a `["to", ""]` tag identifying the counterparty, which the plugin uses to derive the per-counterparty key. * When the sender retracts a message (Sender:D in the recipient's enclave), the client MUST update the corresponding `sent` event (OWNER:U) in the owner's enclave. #### rotate OWNER-only mid-conversation per-contact epoch rotation without a state change. * A `rotate` event MUST be creatable only by `OWNER` (OWNER:C) and MUST NOT cause a state change. * A `rotate` event MUST carry a `ratchet-pair` self-encrypted epoch wrap for device sync. * After a `rotate`, the owner MUST piggyback the new epoch in the `epoch` tag of the next outgoing `message` to the target contact (lazy cross-enclave delivery). **When to rotate** (client-side heuristics — not protocol-enforced): after N messages from a contact, after time elapsed, after sub-key rotation, on manual user action. ### Confidentiality DM event content and key-distribution material MUST be end-to-end sealed; the node MUST see only opaque ciphertext. **Canonical plugin: [`plugins/ratchet-pair`](/spec/app/plugins/ratchet-pair)** — the canonical confidentiality plugin for the DM enclave MUST be `ratchet-pair`. #### App-payload contract The plugin's wire envelopes appear on these event content / tag fields: | Event | Field | Plugin envelope | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------ | | `invite` | `content` | `invite` envelope (one-shot ECDH-derived) | | `invite` | tag `["epoch", , , ]` | epoch wrap (REPEATABLE — one tag per recipient operating key, §plugin §6) | | `invite` | tag `["enclave_id", ]` | plaintext routing | | `message` | `content` | `message` envelope (per-sender ratchet) | | `message` | tag `["epoch", …]` | epoch wrap, REPEATABLE — present whenever the sender is delivering a new epoch to the peer | | `sent` | `content` | `sent` envelope (self-encrypted, per-counterparty) | | `sent` | tag `["to", ]` | plaintext addressing — input to the plugin's `sent` key derivation | | `rotate` | `content.epoch` | epoch wrap (self-encrypted, `ecdh_pub == owner_pub`) | | `Move(OUTSIDER→FRIEND)` | `content.epoch` | epoch wrap (self-encrypted, for device sync) | The app-payload contract above is normative: every DM implementation MUST realise `dmAppPayloadContract` — for each `PayloadContractRow(event, field, envelope, plaintext?, repeatable?)` it MUST place the envelope on `(event, field)` exactly, mark plaintext rows as plaintext, and treat repeatable rows as zero-or-more. * A new epoch MUST be delivered to the counterparty lazily via the `epoch` tag on the next outgoing `message` (or in the `invite` for the first contact). * The owner's own copy of every epoch (Move, rotate) MUST be self-encrypted with `peer_pub == owner_pub` so any device with `identity_priv` recovers it from CT replay. #### Initial contact (semantic flow) Cross-enclave bidirectional handshake; only OWNER reads each enclave, so all key delivery is cross-enclave write + same-enclave OWNER read. ``` Bob's enclave Alice's enclave │ │ 1. │ epoch₀ᵇᵃ generated │ 2. │ Move(O→F, Alice) │ │ carries epoch₀ᵇᵃ self-wrapped │ │ │ 3. │ ──── invite (OUTSIDER:C) ─────────────► │ │ tags: [enclave_id] │ │ [epoch wrap → Alice's op key] │ (REPEATABLE per plugin §6) │ content: greeting (invite envelope) │ │ 4. │ OWNER:R reads invite │ │ → recovers epoch₀ᵇᵃ │ 5. │ epoch₀ᵃᵇ generated │ 6. │ Move(O→F, Bob) │ │ carries epoch₀ᵃᵇ self-wrapped │ │ 8. │ ◄──── message (FRIEND:C) ──────────── 7.│ │ tags: [epoch wrap → Bob's op key] │ │ content: per-sender ratcheted │ │ OWNER:R reads message │ │ → recovers epoch₀ᵃᵇ │ ▼ ▼ Bob holds: epoch₀ᵇᵃ + epoch₀ᵃᵇ Alice holds: epoch₀ᵇᵃ + epoch₀ᵃᵇ ``` Notation: `epoch₀ᵇᵃ` = epoch 0 in Bob's enclave for the (Bob, Alice) pair. Each contact gets an independent per-pair epoch. **Notes:** * Each user MUST act in their own enclave first (sovereign Move + rotate), then write to the other's enclave (notification: invite or message); if the cross-enclave write fails, the client MUST retry, since the local state is already committed. * After step 4, Alice is FRIEND in Bob's enclave and holds `epoch₀ᵇᵃ`, so she MAY message Bob before completing step 6; full bidirectional begins only after both sides complete. * `Move(BLOCKED → FRIEND)` MUST be a pure state change and MUST NOT carry an epoch; the unblocked contact uses whatever epoch they last received, and the owner delivers a fresh epoch lazily on the next outgoing message. #### Epoch recovery (multi-device join / fresh login) On sync, the client replays the **owner's** CT to rebuild the per-contact epoch map: | Source | What the client does | | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Self-encrypted wraps (own `Move(O→F)`, `rotate`) | Unwrap each with the owner's `identity_priv` against `ecdh_pub == owner_pub` (plugin §3.2). | | Participant-encrypted wraps (received `invite`, `message` `epoch` tags — REPEATABLE) | For each tag, try `ECDH(my_op_priv, sender_ecdh_pub)`. Keep the secret the AEAD verifies; skip mismatches (a parent-only login skips sub-wrapped tags; a sub login skips parent-wrapped tags). | The epoch-recovery procedure above is normative: on sync, the client MUST execute `dmRecoverEpochMap` — apply both `EpochRecoverySource.selfEncrypted` and `EpochRecoverySource.participantEncrypted` row handlers above to the owner's CT to rebuild the per-contact epoch map. * The recovered epoch map MUST be per-contact, per-epoch; the current epoch for each contact MUST be the highest `n` that satisfies the plugin's strict monotonicity rule (§5). #### Security guarantees the DM app relies on The DM enclave MUST rely on the `ratchet-pair` plugin (see [`ratchet-pair` §9](/spec/app/plugins/ratchet-pair#9-security-properties)) for per-message key isolation, per-contact key isolation, per-epoch forward secrecy, multi-device sync via `identity_priv`, sub-key wallet support, and strict per-contact `epoch.n` monotonicity. The DM enclave MUST NOT require post-compromise security against `identity_priv` compromise; the CT-replay tradeoff is accepted (the same constraint applies to every CT-based ENC enclave). Apps that need PCS-grade DMs MUST swap to a future ratchet plugin that ships a side-channel for key material outside the CT (out of scope for `ratchet-pair`). ## Group Chat Enclave This document specifies the group-chat enclave application profile: a shared space for multi-party messaging where multiple members read and write to the same enclave, with end-to-end confidentiality provided by the [`mls-lazy`](/spec/app/plugins/mls-lazy) plugin. *** ### Table of Contents 1. [Overview](#overview) 2. [Manifest](#manifest) 3. [State Transitions](#state-transitions) 4. [Event-Operator Matrix](#event-operator-matrix) 5. [Content Events](#content-events) 6. [Confidentiality](#confidentiality) *** ### Overview A group chat enclave is a shared space for multi-party messaging. Multiple members read and write to the same enclave. * One owner (bootstrapped via init as MEMBER with owner+admin traits). * Members can send messages, react, and set their own profile. * Admins moderate: manage members, set topic, delete messages, post notices. * Muted members can read but not send. * BLOCKED state for bans — no read, no write. Unlike DM (personal mailbox pattern), group chat is a shared enclave: all members see the same CT, and messages exist in one place. ### Manifest ```json { "states": ["PENDING", "MEMBER", "BLOCKED"], "traits": ["owner(0)", "admin(1)", "muted(2)", "dataview(3)"], "readers": [ { "type": "MEMBER", "reads": "*", "retention": "snapshot" } ], "moves": [ { "event": "Move", "from": "OUTSIDER", "to": "PENDING", "operator": "Self", "ops": ["C"], "alias": "applications", "gate": { "operator": ["owner", "admin"] } }, { "event": "Move", "from": "OUTSIDER", "to": "MEMBER", "operator": "Self", "ops": ["C"], "alias": "auto_join", "gate": { "operator": ["owner"] } }, { "event": "Move", "from": "OUTSIDER", "to": "MEMBER", "operator": "admin", "ops": ["C"] }, { "event": "Move", "from": "OUTSIDER", "to": "BLOCKED", "operator": "admin", "ops": ["C"] }, { "event": "Move", "from": "PENDING", "to": "MEMBER", "operator": "admin", "ops": ["C"] }, { "event": "Move", "from": "PENDING", "to": "OUTSIDER", "operator": "admin", "ops": ["C"] }, { "event": "Move", "from": "MEMBER", "to": "OUTSIDER", "operator": "Self", "ops": ["C"] }, { "event": "Move", "from": "MEMBER", "to": "OUTSIDER", "operator": "admin", "ops": ["C"] }, { "event": "Move", "from": "MEMBER", "to": "BLOCKED", "operator": "admin", "ops": ["C"] }, { "event": "Move", "from": "BLOCKED", "to": "OUTSIDER", "operator": "admin", "ops": ["C"] } ], "grants": [ { "event": "Grant", "operator": ["admin"], "scope": ["MEMBER"], "trait": ["muted"] }, { "event": "Grant", "operator": ["owner"], "scope": ["MEMBER"], "trait": ["admin"] }, { "event": "Grant", "operator": ["owner"], "scope": ["OUTSIDER", "MEMBER"], "trait": ["dataview"] }, { "event": "Revoke", "operator": ["admin"], "scope": ["MEMBER"], "trait": ["muted"] }, { "event": "Revoke", "operator": ["owner"], "scope": ["MEMBER"], "trait": ["admin"] }, { "event": "Revoke", "operator": ["owner"], "scope": ["OUTSIDER", "MEMBER"], "trait": ["dataview"] }, { "event": "Revoke", "operator": ["Self"], "scope": ["MEMBER"], "trait": ["admin"] } ], "transfers": [ { "trait": "owner", "scope": ["MEMBER"] } ], "slots": [ { "event": "Shared", "operator": "admin", "ops": ["C", "U"], "key": "topic" }, { "event": "Shared", "operator": "dataview", "ops": ["P"], "key": "topic" }, { "event": "Own", "operator": "MEMBER", "ops": ["C"], "key": "profile" }, { "event": "Own", "operator": "Sender", "ops": ["U"], "key": "profile" } ], "lifecycle": [ { "event": "Pause", "operator": "owner", "ops": ["C"] }, { "event": "Resume", "operator": "owner", "ops": ["C"] }, { "event": "Migrate", "operator": "owner", "ops": ["C"] }, { "event": "Terminate", "operator": "owner", "ops": ["C"] } ], "customs": [ { "event": "message", "operator": "MEMBER", "ops": ["C"] }, { "event": "message", "operator": "admin", "ops": ["D"] }, { "event": "message", "operator": "muted", "ops": ["_C", "_U"] }, { "event": "message", "operator": "dataview", "ops": ["P"] }, { "event": "message", "operator": "Sender", "ops": ["U", "D"] }, { "event": "message", "operator": "BLOCKED", "ops": ["_U", "_D"] }, { "event": "reaction", "operator": "MEMBER", "ops": ["C"] }, { "event": "reaction", "operator": "Sender", "ops": ["D"] }, { "event": "reaction", "operator": "muted", "ops": ["_C"] }, { "event": "reaction", "operator": "BLOCKED", "ops": ["_D"] }, { "event": "notice", "operator": "admin", "ops": ["C", "D"] }, { "event": "rotate", "operator": "admin", "ops": ["C"] } ], "init": [ { "identity": "", "state": "MEMBER", "traits": ["owner", "admin"] } ] } ``` #### Design Rationale **States**: `["PENDING", "MEMBER", "BLOCKED"]` | State | Code | Meaning | | -------- | ---- | --------------------------------- | | OUTSIDER | 0 | Not a member | | PENDING | 1 | Applied, awaiting admin approval | | MEMBER | 2 | Active member, can read and write | | BLOCKED | 3 | Banned by admin | **Traits**: `["owner(0)", "admin(1)", "muted(2)", "dataview(3)"]` | Trait | Rank | Meaning | | -------- | ---- | ---------------------------------------------------------------- | | owner | 0 | Group owner, full control (lifecycle, transfer, top-level admin) | | admin | 1 | Moderator (manage members, topic, delete messages, notice) | | muted | 2 | Content deny (cannot create messages) | | dataview | 3 | Push delivery for service accounts | **Rank Rule protection** (equational form: `rankCanTarget(op, tgt) ≡ decide (op < tgt)`, where `Trait.rank` gives owner→0, admin→1, muted→2, dataview→3): `rankCanTarget(0, 1) = true` and `rankCanTarget(1, 2) = rankCanTarget(1, 3) = true` — owner can target admin, admin can target muted and dataview. `rankCanTarget(1, 1) = false` — admin cannot kick/demote other admins; only owner can. `∀ t, rankCanTarget(Trait.rank t, 0) = false` — owner cannot be kicked by anyone (rank 0, no trait outranks it). If either party holds no traits, rank check is skipped — admin can kick regular members. **Readers**: `{type: "MEMBER", reads: "*", retention: "snapshot"}` — all members can read all events. The `retention: "snapshot"` declaration provides transcript portability: a kicked / left / banned former member retains access to events finalized during their membership window. Encryption forward-secrecy still holds at the plugin layer regardless of node-side retention: mls-lazy rotates the epoch on every membership change, so post-removal messages are cryptographically opaque to the kicked member even if the node served them. Apps that prefer audit-amnesia as the explicit target (kicked = lose all access including history once snapshot lands) MAY override to `retention: "current"`; see [`rbac.md` §5.3](/spec/kernel/rbac). **Operators**: | Operator | Kind | Authority | | ---------- | ------- | ------------------------------------------------------------------------------------------------ | | `MEMBER` | State | message:C, reaction:C, Own(profile):C — base write access | | `admin` | trait | message:D, notice:CD, Shared(topic):CU, rotate:C; manages members via Move/Grant/Revoke | | `owner` | trait | Grant/Revoke admin, Transfer(owner), lifecycle events; superset of admin (init bootstraps both) | | `muted` | trait | message:\_C\_U — deny override prevents message creation and editing while retaining read access | | `dataview` | trait | message:P, Shared(topic):P — push delivery for service accounts | | `Self` | Context | Move(MEMBER, OUTSIDER):C (leave), Revoke(admin):C (step down) — self-targeting | | `Sender` | Context | message:UD, reaction:D, Own(profile):U — per-sender edit/delete | **Join paths**: * **Application**: Move(OUTSIDER→PENDING) via Self:C, gated by `applications`. Self ensures the actor targets themselves; Move step 4 verifies the actor is OUTSIDER. Admin approves via Move(PENDING→MEMBER) or rejects via Move(PENDING→OUTSIDER). * **Auto-join**: Move(OUTSIDER→MEMBER) via Self:C, gated by `auto_join`. Same Self + state verification. Owner controls the gate. * **Direct invite**: Move(OUTSIDER→MEMBER) via admin:C. No gate — admin decides directly. Admin clients SHOULD pair the Move with a `notice` event written into the invitee's Personal enclave (see [personal.md](/spec/app/enclaves/personal)) carrying `kind: "group_invite"`, this group's `enclave_id`, the `inviter` pubkey, and the source group's 32-byte `root_secret` for the current epoch as `handoff` (sub-encrypted under `'enc:personal:notice:epoch'` per [`plugins/ecdh-envelope.md` §5](/spec/app/plugins/ecdh-envelope#5-inner-handoff-sub-encryption-group-invite-path-secret); the receiver derives `epoch_secret = HKDF(root_secret, "enc:mls:epoch", 32)`). The required `epoch_n` field records the source `epoch.n` for Reconciliation. Without this companion write the invitee has MEMBER:R access but no signal pointing them at the enclave — they are silently a member of a group they cannot discover. The companion is a client-layer convention, not node-enforced: the Move is valid on its own, and clients that omit the notice produce a "silent invite" that only works if the invitee learns the `enclave_id` out of band. * **Pre-emptive ban**: Move(OUTSIDER→BLOCKED) via admin:C. Blocks an identity before they join or apply. **No OWNER state** — unlike DM where OWNER is a permanent state, group chat uses MEMBER state with owner trait. The owner is a member who can Transfer(owner), step down, or leave. If the owner leaves without transferring, the group becomes **ownerless** — existing admins continue moderating (members, topic, messages, rotation) but no one can promote new admins, control lifecycle, or manage the auto\_join gate. This is an accepted degradation: a decentralized group with no single authority. ### State Transitions | Transition | Operator | Meaning | | ------------------ | -------- | ----------------------------------- | | OUTSIDER → PENDING | Self | Apply to join (gated) | | OUTSIDER → MEMBER | Self | Auto-join (gated) | | OUTSIDER → MEMBER | admin | Direct invite | | OUTSIDER → BLOCKED | admin | Pre-emptive ban | | PENDING → MEMBER | admin | Approve application | | PENDING → OUTSIDER | admin | Reject application | | MEMBER → OUTSIDER | Self | Leave group | | MEMBER → OUTSIDER | admin | Kick member | | MEMBER → BLOCKED | admin | Ban member | | BLOCKED → OUTSIDER | admin | Unban (can rejoin via normal paths) | Move clears all traits by default. A kicked admin loses admin. An unbanned member MUST be re-invited and re-granted traits. ### Event-Operator Matrix | Event | MEMBER | OUTSIDER | PENDING | BLOCKED | owner(0) | admin(1) | muted(2) | dataview(3) | Self | Sender | | ----------------------- | ------ | -------- | ------- | ------- | -------- | -------- | -------- | ----------- | ---- | ------ | | message | CR | | | \_U\_D | | D | \_C\_U | P | | UD | | reaction | CR | | | \_D | | | \_C | | | D | | notice | R | | | | | CD | | | | | | rotate | R | | | | | C | | | | | | Shared(topic) | R | | | | | CU | | P | | | | Own(profile) | CR | | | | | | | | | U | | Move(OUTSIDER, PENDING) | R | | | | | | | | C | | | Gate(applications) | R | | | | C | C | | | | | | Move(OUTSIDER, MEMBER) | R | | | | | C | | | C | | | Gate(auto\_join) | R | | | | C | | | | | | | Move(OUTSIDER, BLOCKED) | R | | | | | C | | | | | | Move(PENDING, MEMBER) | R | | | | | C | | | | | | Move(PENDING, OUTSIDER) | R | | | | | C | | | | | | Move(MEMBER, OUTSIDER) | R | | | | | C | | | C | | | Move(MEMBER, BLOCKED) | R | | | | | C | | | | | | Move(BLOCKED, OUTSIDER) | R | | | | | C | | | | | | Grant(muted) | R | | | | | C | | | | | | Grant(admin) | R | | | | C | | | | | | | Grant(dataview) | R | | | | C | | | | | | | Revoke(muted) | R | | | | | C | | | | | | Revoke(admin) | R | | | | C | | | | C | | | Revoke(dataview) | R | | | | C | | | | | | | Transfer(owner) | R | | | | C | | | | | | | Pause | R | | | | C | | | | | | | Resume | R | | | | C | | | | | | | Migrate | R | | | | C | | | | | | | Terminate | R | | | | C | | | | | | Columns: States (MEMBER, OUTSIDER, PENDING, BLOCKED) → traits (owner, admin, muted, dataview) → Contexts (Self, Sender). ### Content Events #### message Group message. Created by a MEMBER. Admins can delete any message (admin:D). Muted members are denied creation and editing (muted:\_C\_U). Sender:UD allows the sender to edit or retract their own messages. The `content` is a [`mls-lazy`](/spec/app/plugins/mls-lazy) per-message envelope (per-sender ratchet within the current epoch). #### reaction Emoji reaction to a message. Created by a MEMBER. Sender:D allows the reactor to remove their own reaction. No admin:D — admins cannot remove others' reactions. * **content** (sealed): `ref` (event hash of the target message), `emoji` (reaction emoji) — same `mls-lazy` ratchet envelope as `message`. #### notice Admin-created group-level notice. Created by admin (admin:C). Removed by admin (admin:D). Members read via readers wildcard. Content is app-defined. Examples: a chat app puts `{ "ref": "" }` for a pinned message; an announcement channel puts `{ "text": "..." }` for a text notice. Sealed under the same `mls-lazy` ratchet envelope (sender = the admin). #### Shared(topic) The group's display name / description. KV Shared singleton (`Shared("topic")`); admin:CU, dataview:P, MEMBER:R (via the wildcard reader). Plaintext — visible to anyone with read access including dataview push subscribers. Application-defined shape; the protocol does not constrain it. A common shape is `{ "name": "", "description": "" }`. #### Own(profile) Per-member profile slot (`Own("profile")`, scoped to `MEMBER`); MEMBER:C, Sender:U. Plaintext. Each member writes their own profile entry visible to all members. Application-defined shape; the convention from [personal.md](/spec/app/enclaves/personal) (`display_name`, `bio`, `avatar`) is RECOMMENDED for cross-app interoperability, but apps MAY override. Note this is distinct from the personal-enclave `Shared(profile)` singleton — the group `Own(profile)` is per-member and scoped to this group. #### rotate Epoch rotation event. Created by admin (admin:C). Distributes a new epoch secret to current MEMBERs (see Confidentiality §When new epochs are issued below). Used in any of these protocol situations: * Initial epoch establishment (group creation — no Move involved). * After voluntary leave (Self Move carries no epoch). * Epoch delivery to auto-join members (Self Move carries no epoch). * Standalone rotation (no membership change — security hygiene). The `content` is a [`mls-lazy`](/spec/app/plugins/mls-lazy) commit object (tree copath wraps + additive OR-wraps). ### Confidentiality Group events are sealed end-to-end with a **shared per-epoch secret** distributed via an MLS-style binary ratchet tree, then ratcheted per-sender for each message. The node sees only opaque ciphertext. **Canonical plugin: [`plugins/mls-lazy`](/spec/app/plugins/mls-lazy).** #### App-payload contract The plugin's wire envelopes appear on these event content / tag fields: | Event | Field | Plugin envelope | | ------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------ | | `message` | `content` | per-message envelope (per-sender ratchet within current epoch) | | `reaction` | `content` | per-message envelope (same ratchet schedule as `message`) | | `notice` | `content` | per-message envelope (sender = admin) | | `rotate` | `content.epoch` + `content.epoch_or_wraps` | commit object — tree copath wraps + `epoch_or_wraps` OR-wrap fallback | | `Move(OUTSIDER→MEMBER)` (admin) | `content.epoch` + `content.epoch_or_wraps` | commit object piggy-backed on the Move (see §When new epochs are issued) | | `Move(PENDING→MEMBER)` (admin) | `content.epoch` + `content.epoch_or_wraps` | commit object piggy-backed on the Move | | `Move(MEMBER→OUTSIDER\|BLOCKED)` (admin, removal) | `content.epoch` + `content.epoch_or_wraps` | commit object piggy-backed on the Move | `Shared(topic)` and `Own(profile)` are **plaintext** (visible to all readers per the manifest); they do NOT use this plugin. #### When new epochs are issued Group epochs rotate on **membership change** (additions, removals, approvals) and on demand. The protocol-level rules are: | Membership change | RBAC event | Carries epoch payload? | | ------------------- | ----------------------------------------- | --------------------------------------------- | | Admin invite | `Move(OUTSIDER→MEMBER)` by admin | **YES** — piggybacked on the Move's `content` | | Approve application | `Move(PENDING→MEMBER)` by admin | **YES** — piggybacked on the Move's `content` | | Kick / Ban | `Move(MEMBER→OUTSIDER\|BLOCKED)` by admin | **YES** — piggybacked on the Move's `content` | | Voluntary leave | `Move(MEMBER→OUTSIDER)` by Self | NO — a separate `rotate` MUST follow | | Auto-join | `Move(OUTSIDER→MEMBER)` by Self | NO — a separate `rotate` MUST follow | | Standalone rotation | `rotate` by admin | YES | Self-Moves carry no epoch because the actor either does not hold the current secret (auto-join) or cannot distribute it without read access to other members' keying material (leave). In both Self cases an admin SHOULD follow up with a `rotate` event to close the gap. Members MUST treat the window between a Self-Move and the next admin `rotate` as: a forward-secrecy gap after a leave (the leaver retains the current epoch\_secret until rotate lands), or as undeliverable for the new member after an auto-join (they have MEMBER:R but no key). Receivers MUST tolerate the follow-up rotate landing arbitrarily late or never; messages sent in this window remain decryptable to anyone with the current epoch but are NOT to the auto-joined member. **Trait Grant/Revoke (admin, muted, dataview) does NOT trigger an epoch.** Traits modify permissions, not membership; the identity remains MEMBER and already holds the current epoch. **Backward and forward secrecy** ride on this rule: every join adds the new member to the next epoch (the previous epoch remains opaque to them); every removal cuts the removed member out of the next epoch (the next epoch's messages are opaque to them). **Monotonicity (client-validated):** `epoch.n` MUST be strictly greater than the highest `epoch.n` previously seen in the CT — replay defense (see [plugin §8](/spec/app/plugins/mls-lazy#8-monotonicity-client-validated)). #### Multi-key members (sub-key wallets) The tree path encrypts to each member's **parent** identity pub. Members that operate from a deterministic HKDF sub-key (notably MetaMask, which cannot perform ECDH from the parent) MUST be reached via the plugin's additive `epoch_or_wraps` OR-wrap field. The plugin (see [`plugins/mls-lazy.md` §6.1](/spec/app/plugins/mls-lazy#61-required-recipients-revised)) mandates `epoch_or_wraps` carry at minimum: * An entry for the **committer's own operating key** (so the committer's fresh device can recover via CT replay — `encrypted_path_secrets` never targets the committer). * An entry for **each member whose operating key differs from their tree-keyed parent `id_pub`** (sub-key wallets). Parent-keyed members (dev / passkey / Nostr / NFC, where `parent == sub`) recover via the tree and do NOT require an OR-wrap entry. The app spec's contract is to recognize that **every `rotate` and every membership-changing admin `Move` carries `content.epoch` AND `content.epoch_or_wraps`**, that `epoch_or_wraps` is never empty, and that both fields MUST be replicated unchanged. #### Epoch recovery (multi-device join / fresh login) On sync, the client replays the group's CT and rebuilds the epoch map: 1. Scan admin-created Move events (`OUTSIDER→MEMBER`, `PENDING→MEMBER`, `MEMBER→OUTSIDER|BLOCKED`) with `content.epoch`. 2. Scan `rotate` events with `content.epoch`. 3. For each commit, try the tree copath path first; if no copath entry yields a secret (sub-key wallet, or this device has not yet been added to the tree), fall back to the OR-wrap entry addressed to the operating key (`recipient == my_op_pub`). 4. Validate strict `epoch.n` monotonicity; reject regressions. (Crypto details for steps 1–3 live in the plugin spec.) #### Security guarantees the group app relies on From the plugin (see [`mls-lazy` §11](/spec/app/plugins/mls-lazy#11-security-properties)): | Guarantee | | -------------------------------------------------------------------------- | | Per-message key isolation. | | Per-sender chain isolation. | | Per-epoch forward secrecy (independent random epoch roots). | | Forward secrecy on removal (post-removal epochs opaque to removed member). | | Backward secrecy on join (pre-join epochs opaque to new member). | | Stateless decryption (no persistent ratchet state). | | Multi-device support via `identity_priv` + CT replay. | | Sub-key wallet support via OR-wrap fallback. | Group **does NOT** require post-compromise security against `identity_priv` compromise — the CT-replay tradeoff is accepted (same constraint as DM and every CT-based ENC enclave). Apps requiring PCS-grade groups MUST swap to a future ratchet plugin that ships keying material outside the CT. ## Personal Enclave This document specifies the personal enclave application profile: an identity anchor that holds the owner's identity, where to find the owner's data, and where others reach the owner for cross-enclave addressed messages. The `private` layer is sealed with [`identity-aead`](/spec/app/plugins/identity-aead); the `notice` layer is sealed with [`ecdh-envelope`](/spec/app/plugins/ecdh-envelope). *** ### Table of Contents 1. [Overview](#overview) 2. [Manifest](#manifest) 3. [Event-Operator Matrix](#event-operator-matrix) 4. [Data](#data) 5. [Confidentiality](#confidentiality) 6. [Security Considerations](#security-considerations) *** ### Overview A personal enclave is an identity anchor — holding the owner's identity, where to find the owner's data, and where others reach the owner for cross-enclave addressed messages. Four data layers (`Layer = profile | public | private | notice`); the storage/access split is fixed by `Layer.storage` and `Layer.access`: * **profile** — `Layer.storage profile = kvSharedSingleton` / `Layer.access profile = publicReadable`. KV Shared singleton. Public identity card. SMT-provable. * **public** — `Layer.storage public = contentEvents` / `Layer.access public = publicReadable`. Content events. Dynamic public content. * **private** — `Layer.storage private = contentEvents` / `Layer.access private = ownerOnlyEncrypted`. Content events. Encrypted dynamic documents. Owner-only. * **notice** — `Layer.storage notice = contentEvents` / `Layer.access notice = inboundOwnerReadOnly`. Content events. Inbound cross-enclave addressed messages (e.g., group invitations). Anyone with the owner's pubkey can write one; only the owner reads. ### Manifest ```json { "states": ["OWNER"], "traits": ["dataview(1)"], "readers": [ { "type": "OWNER", "reads": "*", "retention": "current" } ], "moves": [], "grants": [ { "event": "Grant", "operator": ["OWNER"], "scope": ["OUTSIDER"], "trait": ["dataview"] }, { "event": "Revoke", "operator": ["OWNER"], "scope": ["OUTSIDER"], "trait": ["dataview"] } ], "transfers": [], "slots": [ { "event": "Shared", "operator": "OWNER", "ops": ["C", "U", "D"], "key": "profile" }, { "event": "Shared", "operator": "dataview", "ops": ["P"], "key": "profile" } ], "lifecycle": [ { "event": "Terminate", "operator": "OWNER", "ops": ["C"] } ], "customs": [ { "event": "public", "operator": "OWNER", "ops": ["C", "U", "D"] }, { "event": "public", "operator": "dataview", "ops": ["P"] }, { "event": "private", "operator": "OWNER", "ops": ["C", "U", "D"] }, { "event": "notice", "operator": "OUTSIDER", "ops": ["C"], "alias": "notices", "gate": { "operator": ["OWNER"] } }, { "event": "notice", "operator": "OWNER", "ops": ["D"] }, { "event": "notice", "operator": "dataview", "ops": ["P"] } ], "init": [ { "identity": "", "state": "OWNER", "traits": [] } ] } ``` #### Design Rationale **States**: `["OWNER"]` — single State for the sole human identity. OUTSIDER (0) is implicit. **Traits**: `["dataview(1)"]` — push delivery for service accounts. **Operators**: * `OWNER` (State) — all ops. Read on all events via readers. CUD on content and KV. D on notice (cleanup). Administrative: Grant, Revoke, Terminate. * `OUTSIDER` (State) — C on notice only. Anyone with the owner's pubkey can write one inbound message; cannot read or update. Gated by `notices` (OWNER toggle). * `dataview` (trait) — P on profile, public, and notice events for push delivery. **No moves** — OWNER is bootstrapped via init. The owner never changes state. Validation rule 1 ("In and Out") is satisfied by init — OWNER is reachable at enclave creation. **No transfers** — a personal enclave IS the owner's identity; ownership cannot be transferred. **Lifecycle**: Terminate only. No Pause/Resume — single owner, no security value. ### Event-Operator Matrix | Event | OWNER | OUTSIDER | dataview(1) | | ---------------- | ----- | -------- | ----------- | | public | CRUD | | P | | private | CRUD | | | | notice | RD | C | P | | Gate(notices) | CR | | | | Shared(profile) | CRUD | | P | | Grant(dataview) | CR | | | | Revoke(dataview) | CR | | | | Terminate | CR | | | Columns: States (OWNER, OUTSIDER) → traits (dataview). ### Data #### KV Shared: profile Public identity card. Singleton. SMT-provable. Cross-application fields: `display_name`, `bio`, `avatar`. Recommended for interoperability across apps. Apps can add additional fields — the protocol does not constrain the internal structure. #### Content event: public Dynamic public content. Owner creates. Supports U (update) and D (delete). The protocol does not prescribe content structure. Apps define their own content types freely. #### Content event: private Dynamic private documents. Owner-only. Encrypted. Supports U (update) and D (delete). Each event's content is encrypted before submission. The node stores opaque ciphertext. Apps use content events with U for granular updates to independent documents — no single-blob limitation. The protocol does not prescribe what documents are stored. Apps define their own document types freely. #### Content event: notice Inbound cross-enclave addressed message. The Personal enclave's inbox. `notice` is the rail by which any external enclave reaches the owner with an addressed message — most importantly, **group invitations**. When a Group admin executes `Move(OUTSIDER → MEMBER)` to invite the owner, the admin's client SHOULD pair that Move with a `notice` write into the invitee's Personal enclave so the invitee discovers the invitation. Without this companion write, the invitee is silently a member of a group they have no signal pointing them to. The same rail covers any other future cross-enclave addressed flow (channel invites, role assignments, etc.) by varying the `kind` field. **Authorization**: * `OUTSIDER:C` — anyone with the owner's pubkey can create one notice. The sender is by definition not in the owner's Personal enclave; OUTSIDER:C is the only path for them. * `OWNER:D` — the owner deletes notices after acting on them. Read access for OWNER comes via `readers: [{ "type": "OWNER", "reads": "*" }]`. * `dataview:P` — push delivery so the owner's client wakes on arrival. * **Gate `notices`** — OWNER can close the inbox at any time; gated commits are rejected before RBAC evaluation. **No OWNER:C** — the owner is not the producer; producers are external senders. **No OWNER:U / Sender:U** — notices are receipts, not stateful documents. If the sender's situation changes, they write a new notice. **Encrypted content (plaintext tags)**. The `content` field is sealed by the [`ecdh-envelope`](/spec/app/plugins/ecdh-envelope) plugin to the recipient via secp256k1 ECDH between the sender's **operating private key** (parent `identity_priv` or deterministic `sub_priv` when present in `reg_identity`) and the recipient's **published operating public key** (`reg_identity.sub_pub` when present, else `id_pub`). The node sees the opaque envelope object in `content` plus the commit envelope (sender pubkey signature, target enclave\_id). The kind of notice, the source enclave\_id, the inviter, and any handoff secrets are all inside the sealed payload — hidden from the node. The `tags` field is NOT sealed — it remains the protocol-standard `[[key, value, …], …]` plaintext array and MAY carry routing / indexing hints (e.g. `["enclave_id", ""]`). **Required payload fields** (sealed inside `notice.content`): * `kind` — discriminator. Initial registry: `group_invite`, `dm_invite`. Apps may add experimental kinds with the `x-` prefix. * `enclave_id` — the source enclave the notice refers to (e.g., the group the owner was invited to). * `enclave_kind` — manifest hint: `"group"`, `"dm"`, etc. * `inviter` — pubkey of the identity that produced the addressed action (e.g., the admin who performed the Move). **Optional payload fields** (also sealed inside `notice.content`): * `handoff` — kind-specific cryptographic material. For `group_invite`: the source group's `root_secret` for the current epoch, sealed via the plugin's **inner sub-encryption** under the `enc:personal:notice:epoch` domain (separately keyed from the outer envelope — see [`plugins/ecdh-envelope`](/spec/app/plugins/ecdh-envelope) §5). The receiver derives `epoch_secret = HKDF(root_secret, "enc:mls:epoch", 32)` per [`plugins/mls-lazy`](/spec/app/plugins/mls-lazy) §4 — REQUIRED for the invitee to decrypt the group's CT. * `epoch_n` — REQUIRED whenever `handoff` is present. The source enclave's `epoch.n` the `handoff`-wrapped secret belongs to. Used by Reconciliation (below) and by the receiver's per-group epoch map. * `topic` — group display name; lets the invitee's app render the group before subscribing. * `greeting` — human-readable text from the inviter. * `manifest_hash` — hash of the source enclave's manifest. Lets the invitee verify what kind of enclave they are joining before joining. * `move_ref` — pointer to the originating Move event in the source enclave's CT, for client-side cross-verification. **Companion-write contract is SHOULD, not MUST**. The originating action (e.g., the Group's Move) and the `notice` write are two separate commits in two separate enclaves; cross-enclave atomicity is not enforceable at the protocol layer. Recipient clients MUST tolerate either half landing alone — see "Reconciliation" below. **Reconciliation**. On reading a `notice`, the recipient's client MUST fetch the referenced source enclave's CT and confirm a corresponding action exists (for `kind: "group_invite"`, a `Move(OUTSIDER → MEMBER)` targeting the recipient by `inviter` at or before the bundle whose `epoch.n == payload.epoch_n`) before surfacing the notice as actionable. A `notice` without a corresponding source-enclave action is treated as pending; clients retry verification within a tolerance window (default `RECONCILIATION_PENDING_WINDOW_MS = 24 * 60 * 60 * 1000` ms = 24h) before discarding via `OWNER:D`. This client-side check is the spec's primary defense against forged or stale invitations. **Spam considerations**: see the Security section below. ### Confidentiality The four Personal data layers split across **two encryption plugins** plus a plaintext layer: | Event | Layer | Plugin | | ----------------- | ----------------------------------------------- | -------------------------------------------------- | | `Shared(profile)` | Plaintext | — | | `public` | Plaintext | — | | `private` | Owner-only sealed (content) | [`identity-aead`](/spec/app/plugins/identity-aead) | | `notice` | Recipient-only sealed (content; tags plaintext) | [`ecdh-envelope`](/spec/app/plugins/ecdh-envelope) | #### App-payload contract | Event | Field | Sealing | | ----------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Shared(profile)` | all fields | **plaintext** | | `public` | `content` | **plaintext** | | `private` | `content` (JSON `{ciphertext, nonce}`) | [`identity-aead`](/spec/app/plugins/identity-aead) — domain separator `enc-personal-private:`) | | `notice` | `content` (JSON envelope `{ciphertext, nonce, sender_pub, scheme: "personal:notice", encrypted: true}`) | [`ecdh-envelope`](/spec/app/plugins/ecdh-envelope) outer envelope — domain `enc:personal:notice` (see [plugin §3](/spec/app/plugins/ecdh-envelope#3-outer-envelope-content-only)) | | `notice` | `tags` | MUST remain **plaintext** protocol-standard `[[key, value, …], …]` — MAY carry routing hints (e.g. `["enclave_id", ""]`); MUST match the sealed payload when both are present | | `notice` (`kind == "group_invite"`) | sealed `payload.handoff` (`{recipient, ecdh_pub, ciphertext, nonce}`) | [`ecdh-envelope`](/spec/app/plugins/ecdh-envelope) inner sub-encryption — domain `enc:personal:notice:epoch` (carries the source group's 32-byte `root_secret`; see [plugin §5](/spec/app/plugins/ecdh-envelope#5-inner-handoff-sub-encryption-group-invite-path-secret)) | For a `notice` carrying `kind == "group_invite"`, the inner `handoff` is decrypted as part of **joining the group**, not as part of reading the notice. The recovered 32-byte `root_secret` is fed through `HKDF(root_secret, "enc:mls:epoch", 32)` to obtain `epoch_secret` per [`mls-lazy.md` §4](/spec/app/plugins/mls-lazy#4-node-key-derivation). After this bootstrap, the invitee receives every subsequent group epoch via the group's normal in-CT distribution — the ecdh-envelope plugin is bootstrap-only. #### Security guarantees the personal app relies on From [`identity-aead` §8](/spec/app/plugins/identity-aead#8-security-properties): * Owner-only confidentiality of `private` (no `R` operator other than OWNER on `private` per this manifest). ✅ * Multi-device support via deterministic HKDF from `identity_priv`. ✅ * Per-event key isolation (unique random nonces). ✅ * No PCS against `identity_priv` compromise — compromise reveals every past, present, and future `private` document. Accepted by design (identity key is the trust root for owner-only data). From [`ecdh-envelope` §8](/spec/app/plugins/ecdh-envelope#8-security-properties): * Confidentiality of `notice.content` against the node and any third-party indexer (including dataview pushers). ✅ `notice.tags` are intentionally plaintext for routing and MUST NOT carry secrets. * Per-recipient isolation (each notice keyed to one recipient's published operating key). ✅ * Group-invite handoff sub-encryption keyed independently from the outer envelope, so a future leak of the outer envelope material does not directly disclose the group's path secret. ✅ * No PCS — sender→recipient identity-ECDH is one-shot, no ratchet. Cured for `group_invite` by the group's normal epoch rotation immediately after the invitee joins. The Security Considerations section below covers the **app-layer** consequences of these choices (spam surface, dataview metadata, forward-secrecy tradeoff). ### Security Considerations #### Open `OUTSIDER:C` write surface The `notice` event is intentionally writable by any external party who knows the owner's pubkey. This is required: the rail's purpose is cross-enclave addressed delivery, and senders are by definition outside the owner's Personal enclave. The trade-offs: * **Sender must already know the recipient's pubkey.** The protocol does not advertise pubkeys; mass enumeration depends on the readability of registries and other public CTs (see Registry app spec). * **Spam mitigation is not in this manifest beyond the `notices` Gate.** OWNERs close the gate to stop new notices entirely. Selective filtering (per-sender rate limits, content-size caps, inbox-depth-scaled fees) is a node-implementation concern, not a manifest concern. * **Phishing defense is in the recipient's client**: the client MUST verify the claimed `enclave_id`'s originating action exists in the source CT before surfacing the notice as actionable (see "Reconciliation" above). A notice that points to a non-existent or unrelated source action is silently discarded. #### Metadata leak via dataview `dataview:P` on `notice` is required for the rail to be useful — the owner's client wakes on arrival rather than polling. As a consequence, dataview push subscribers learn: * That a notice arrived (timing). * The sender's pubkey (from the commit envelope's signature). They do NOT learn the notice's `kind`, `enclave_id`, `inviter` claim, or any handoff material — those are inside the ECDH-sealed payload. Owners who run their own dataview avoid the metadata leak; owners who delegate dataview to a third-party indexer accept it. This matches the DM design philosophy. #### Forward secrecy The notice envelope uses identity ECDH with no ratchet. A future compromise of the recipient's identity key reveals every historical notice — including the bootstrap path secret for any group the owner was invited to. This matches DM's documented trade-off and is acceptable for v2 because the path secret is rotated forward by the group's normal epoch ratchet immediately after the invitee joins. Higher-stakes future kinds MAY require a different envelope. ## Registry This document specifies the Registry application profile: a special enclave that maps enclave IDs to the node endpoints hosting them, resolves identity public keys to their published enclaves, and provides context lookup for what each enclave represents. The Registry is itself an ENC enclave plus a DataView surface (see [`node-api.md` §Registry DataView API](/spec/node/node-api)). *** *** ### Table of Contents 1. [Registry Purpose](#registry-purpose) 2. [Manifest](#manifest) 3. [State Transitions](#state-transitions) 4. [Event-Operator Matrix](#event-operator-matrix) 5. [Content Events](#content-events) * [reg\_node](#reg_node) * [reg\_enclave](#reg_enclave) * [reg\_identity](#reg_identity) 6. [Confidentiality](#confidentiality) 7. [Registry Operations](#registry-operations) * [Conflict Resolution](#conflict-resolution) * [DataView Semantics](#dataview-semantics) * [Registry Governance](#registry-governance) * [The Process of Registry](#the-process-of-registry) 8. [DataView API](#dataview-api) *** ### Registry Purpose The **Registry** is a special enclave that maps **Enclave IDs** to the **Node endpoints** that host them, and can optionally attach descriptive metadata. **Purpose:** * Service discovery: resolve `enclave_id → node(s)` * Identity resolution: resolve `id_pub → enclave(s)` * Context lookup: understand what an enclave represents **Registry Record:** A registry entry MAY include: * `enclave_id` — canonical enclave identifier * `nodes` — hosting node endpoints or identifiers * `app` *(optional)* — application that created or uses the enclave * `creator` *(optional)* — identity key that initialized the enclave * `desc` *(optional)* — human-readable description * `meta` *(optional)* — application-defined metadata **Trust Model:** * **Sequencer discovery:** When clients choose to use the Registry enclave for discovery, the Registry is authoritative for the sequencer mapping. Clients SHOULD re-check Registry periodically to catch migrations. Discovery via other mechanisms (DNS, well-known URLs, bootstrap configuration) is equally valid and out of scope for this document. * **Enclave metadata:** Registry is advisory. Clients SHOULD verify enclave proofs independently. ### Manifest The Registry uses a minimal RBAC v2 manifest. No States (stateless enclave), two traits: `owner` and `dataview`. ```json { "enc_v": 2, "RBAC": { "states": [], "traits": ["owner(0)", "dataview(1)"], "readers": [ { "type": "Public", "reads": "*" } ], "grants": [ { "event": "Grant", "operator": ["owner"], "scope": ["OUTSIDER"], "trait": ["dataview"] }, { "event": "Revoke", "operator": ["owner"], "scope": ["OUTSIDER"], "trait": ["dataview"] } ], "transfers": [ { "trait": "owner", "scope": ["OUTSIDER"] } ], "slots": [], "lifecycle": [ { "event": "Terminate", "operator": "owner", "ops": ["C"] } ], "customs": [ { "event": "reg_node", "operator": "Public", "ops": ["C"] }, { "event": "reg_node", "operator": "Sender", "ops": ["U", "D"] }, { "event": "reg_node", "operator": "dataview", "ops": ["P"] }, { "event": "reg_enclave", "operator": "Public", "ops": ["C"] }, { "event": "reg_enclave", "operator": "Sender", "ops": ["U", "D"] }, { "event": "reg_enclave", "operator": "dataview", "ops": ["P"] }, { "event": "reg_identity", "operator": "Public", "ops": ["C"] }, { "event": "reg_identity", "operator": "Sender", "ops": ["U", "D"] }, { "event": "reg_identity", "operator": "dataview", "ops": ["P"] } ], "init": [ { "identity": "", "state": "OUTSIDER", "traits": ["owner"] } ] } ``` **Authorization rules:** * Public can read all events (`*` wildcard R via readers) * dataview trait receives all events via Push (P) to serve the REST API * Public can submit reg\_node, reg\_enclave, reg\_identity (signed by submitter) * Only the original sender can Update or Delete their own entries (Sender context) * `Sender` for reg\_node is evaluated against `content.seq_pub`. * `Sender` for reg\_enclave is evaluated against `content.manifest_event.from`. * `Sender` for reg\_identity is evaluated against `content.id_pub`. * owner trait can Grant/Revoke the dataview trait (manage push endpoints) * owner trait can Transfer ownership and Terminate **Strict Self-Authorization:** For reg\_node: the commit's `from` field MUST equal `content.seq_pub`. One identity cannot register another node. For reg\_enclave: the commit's `from` field MUST equal `content.manifest_event.from`. One identity cannot register another's enclave. For reg\_identity: the commit's `from` field MUST equal `content.id_pub`. One identity cannot register enclaves for another. The verifier MUST accept either of two valid signature paths: 1. **Direct.** `commit.sig` is the parent identity's own signature over `commit.hash`, dispatched per `commit.alg` (Schnorr or ECDSA — see [§Signature Schemes](#signature-schemes)). 2. **Sub-key via cosign cert.** `commit.cert` carries a valid [Delegated Sub-keys cert](#delegated-sub-keys), `commit.cert.parentId == commit.from == content.id_pub`, and `commit.sig` is the sub-key's Schnorr signature over `commit.hash`. Cert-authorized reg\_identity is the path used by MetaMask-style ECDSA-only wallets that publish their `sub_pub` through their own reg\_identity entry. Both paths satisfy the Sender check on reg\_identity: `Sender` is evaluated against `content.id_pub` (= `commit.from`), not against `commit.cert.subkey`. RBAC and discovery remain keyed to the parent identity regardless of which signature scheme produced the commit. **reg\_enclave and Ownership Transfer:** The Registry tracks the **original creator** (`manifest_event.from`) as the default authorized identity. However, after `Transfer`, the new Owner can update the Registry by providing an `owner_proof`. **Update Scenarios:** | Scenario | `from` field | `owner_proof` | | ----------------------------------- | --------------------- | ------------------------------------- | | Original creator updates | `manifest_event.from` | Not required | | New Owner updates (no migration) | New Owner's key | Required (SMT proof of owner trait) | | New Owner updates (after migration) | New Owner's key | Required (SMT proof + migrate\_event) | See the Authorization section in reg\_enclave for verification details. ### State Transitions Registry is **stateless**: the manifest declares an empty `states` array. No state-transition (`Move`) events are defined or accepted. Authorization is trait-based (`owner`, `dataview`) and per-event `Sender` self-checks, not state-based. ### Event-Operator Matrix Derived from the manifest above: | Event | Operator | Ops | Notes | | ------------- | --------------------- | ---- | ------------------------------------------------------------------- | | Grant | owner | C | Issues `dataview` trait to a node serving the REST API | | Revoke | owner | C | Revokes `dataview` trait | | Transfer | owner | C | Transfers `owner` trait to a new identity | | Terminate | owner | C | Lifecycle: closes the Registry enclave | | reg\_node | Public | C | Anyone may submit; signed by `seq_pub` | | reg\_node | Sender | U, D | Only the original submitter (`from == content.seq_pub`) | | reg\_node | dataview | P | Pushed to the DataView server | | reg\_enclave | Public | C | Anyone may submit; signed by enclave creator or current owner | | reg\_enclave | Sender | U, D | Only the original submitter (`from == content.manifest_event.from`) | | reg\_enclave | dataview | P | Pushed to the DataView server | | reg\_identity | Public | C | Anyone may submit; signed by `id_pub` (parent or sub-key path) | | reg\_identity | Sender | U, D | Only the original submitter (`from == content.id_pub`) | | reg\_identity | dataview | P | Pushed to the DataView server | | Read | Public (`reads: "*"`) | R | All events are public; no encryption | ### Content Events reg\_node, reg\_enclave, and reg\_identity are **Content Events** within the Registry enclave. They can be Updated (to change metadata) or Deleted (to deregister) per the Registry's RBAC schema. Each follows the same lifecycle (`Public:C`, `Sender:U/D`, `dataview:P`) but carries different content. #### reg\_node **Type:** `reg_node` **Purpose:** Register a node in the Registry for discovery. ##### Content Structure ```jsonc { "seq_pub": "", "endpoints": [ { "uri": "https://node.example.com:443", "priority": 1 }, { "uri": "https://1.2.3.4:443", "priority": 2 } ], "protocols": ["https", "wss"], "enc_v": 2 } ``` ##### Fields | Field | Required | Description | | --------------------- | -------- | ----------------------------------------------------------- | | seq\_pub | Yes | Node's sequencer public key | | endpoints | Yes | Array of endpoints, ordered by priority (lower = preferred) | | endpoints\[].uri | Yes | Full URI including protocol and port | | endpoints\[].priority | No | Resolution order (default: array index) | | protocols | No | Supported transport protocols | | enc\_v | Yes | ENC protocol version | ##### Validation Limits | Field | Max | | ---------------- | --------------- | | endpoints array | 10 entries | | endpoints\[].uri | 2048 characters | | protocols array | 10 entries | Nodes MUST reject reg\_node commits exceeding these limits. ##### Authorization * The commit MUST be signed by `seq_pub` (proves ownership). * `from` field MUST equal `seq_pub`. ##### Update/Deregister * To update: submit new reg\_node (replaces previous) * To deregister: submit Delete event referencing the reg\_node event #### reg\_enclave **Type:** `reg_enclave` **Purpose:** Register an enclave in the Registry for discovery. ##### Content Structure ```json { "manifest_event": { "id": "", "hash": "", "enclave": "", "from": "", "type": "Manifest", "content": "...", "exp": 1706000000000, "tags": [], "timestamp": 1706000000000, "sequencer": "", "seq": 0, "sig": "", "seq_sig": "" }, "owner_proof": { "sth": { "t": 1706000000000, "ts": 500, "r": "", "sig": "" }, "ct_proof": { "ts": 500, "li": 499, "p": ["", ...] }, "state_hash": "", "events_root": "", "smt_proof": { "k": "", "v": "", "b": "", "s": ["", ...] } }, "app": "my-chat-app", "desc": "A group chat for project X", "meta": {} } ``` ##### Fields | Field | Required | Description | | --------------- | -------- | -------------------------------------------------------------------------- | | manifest\_event | Yes | The finalized Manifest event (full event structure) | | owner\_proof | No | Proof of current Owner status (required if `from` ≠ `manifest_event.from`) | | app | No | Application identifier | | desc | No | Human-readable description | | meta | No | Application-defined metadata | ##### Owner Proof Structure The `owner_proof` field allows the current Owner to submit `reg_enclave` even if they are not the original creator. This is required after `Transfer` when the new Owner needs to update the Registry. | Field | Required | Description | | -------------- | -------- | -------------------------------------------------------------------------- | | sth | Yes | Signed Tree Head from the enclave's sequencer | | ct\_proof | Yes | CT inclusion proof binding `state_hash` to the signed root | | state\_hash | Yes | SMT root hash at the proven tree position | | events\_root | Yes | Merkle root of event IDs in the bundle | | smt\_proof | Yes | SMT membership proof showing `from` has owner trait | | migrate\_event | No | Required if sequencer changed since Manifest (proves sequencer transition) | ##### Authorization The `reg_enclave` commit can be authorized in two ways: **Path 1: Original Creator (no owner\_proof)** * `from` field MUST equal `manifest_event.from`. * Registry verifies `manifest_event.sig` is valid. **Path 2: Current Owner (with owner\_proof)** * `from` field MAY differ from `manifest_event.from`. * `owner_proof` field MUST be present and valid. * Registry verifies the submitter currently holds the owner trait. **Manifest Signature Verification (both paths):** The `manifest_event.sig` signs the Manifest commit hash: ``` _content_hash = sha256(utf8_bytes(manifest_event.content)) commit_hash = H(0x10, enclave_id, from, "Manifest", _content_hash, exp, tags) verify per manifest_event.alg (see §Signature Schemes): - "schnorr" (or absent) : schnorr_verify(commit_hash, sig, from) - "ecdsa" : ecdsa_verify(commit_hash, sig, 0x02 || from) ``` **Owner Proof Verification (Path 2 only):** 1. **Verify STH signature:** ``` message = "enc:sth:" || be64(sth.t) || be64(sth.ts) || hex_decode(sth.r) verify: schnorr_verify(sha256(message), sth.sig, manifest_event.sequencer) ``` 2. **Verify CT inclusion:** * Compute leaf hash: `H(0x00, events_root, state_hash)` * Verify CT inclusion proof against `sth.r` using RFC 9162 algorithm * This binds `state_hash` to the signed tree 3. **Verify SMT proof:** * Compute expected key: `0x00 || sha256(from)[0:160 bits]` (RBAC namespace, 21 bytes total) * Verify `smt_proof.k` equals expected key * Verify SMT proof against `state_hash` * Verify `smt_proof.v` has owner trait (bit 8) set 4. **Verify enclave binding:** * The `manifest_event.enclave` MUST match the enclave ID in Registry lookup. * This prevents using an owner proof from a different enclave **Post-Migration Updates:** After migration, the sequencer changes. To update Registry after migration: 1. Include the `migrate_event` field in `owner_proof`: ```json { "owner_proof": { "migrate_event": { "id": "", "type": "Migrate", "content": { "new_sequencer": "", ... }, "sequencer": "", "seq_sig": "", ... }, "sth": { ... }, "ct_proof": { ... }, ... } } ``` 2. Registry verifies the Migrate event: * `migrate_event.content.new_sequencer` = new sequencer public key * `migrate_event.seq_sig` is valid signature by new sequencer * `migrate_event.enclave` matches `manifest_event.enclave` 3. STH signature is verified against `migrate_event.sequencer` (new sequencer) **Chained Migrations:** If multiple migrations occurred, only the most recent `migrate_event` is needed. The new sequencer's STH authenticates the current state, which includes the full history. **Security Notes:** * The `manifest_event` provides enclave identity and original creator * The `migrate_event` (if present) proves sequencer transition * The SMT proof proves current Owner status * All three are cryptographically bound via signatures ##### Derived Fields Registry extracts: * `enclave_id` = `manifest_event.enclave` * `sequencer` = `owner_proof.migrate_event.content.new_sequencer` if `migrate_event` is present, otherwise `manifest_event.sequencer` * `creator` = `manifest_event.from` ##### Update/Deregister * To update metadata: submit new reg\_enclave (replaces previous) * To deregister: submit Delete event referencing the reg\_enclave event #### reg\_identity **Type:** `reg_identity` **Purpose:** Register an identity's enclaves in the Registry for discovery. ##### Content Structure ```json { "id_pub": "a1b2c3...", "id_pub_full": "02a1b2c3...", "sub_pub": "d4e5f6...", "enclaves": { "personal": "", "dm": "" } } ``` ##### Fields | Field | Required | Description | | | | | | ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | - | ------- | - | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | id\_pub | Yes | Identity public key (32-byte x-only) | | | | | | id\_pub\_full | No | Full **compressed** secp256k1 public key (33 bytes, \`02 | | x`or`03 | | x`), publishing the y-parity (see [Public Key Parity](#public-key-parity)). The "full" suffix distinguishes the compressed (parity-aware) form from the x-only `id\_pub\` above. | | sub\_pub | No | 32-byte x-only **operating public key** for ECDH-incapable wallets (notably MetaMask EIP-191/ECDSA, whose private key cannot be exposed for ECDH). Derived deterministically by the wallet from `id_pub` + a stable salt; published here so peers can target this identity for ECDH-derived schemes (ratchet-pair OR-wrap, mls-lazy `epoch_or_wraps`, ecdh-envelope). For ECDH-capable wallets (dev / passkey / Nostr / NFC), `sub_pub` is identical to `id_pub` and MAY be omitted; absent `sub_pub`, peers MUST treat `id_pub` as the operating key. See [Delegated Sub-keys](#delegated-sub-keys). | | | | | | enclaves | No | Map of label → enclave\_id (named enclave references) | | | | | ##### Public Key Parity The identity `id_pub` is a 32-byte **x-only** key, which omits the y-coordinate parity. For Schnorr verification this is sufficient (BIP-340 keys are even-y by definition). But operations that need the **full curve point** — deriving an EVM-style address `keccak256(uncompressed_pubkey)[12:]`, or computing an ECDH shared secret from the compressed point — are **ambiguous** from `id_pub` alone, because two points (`02||x` and `03||x`) share the same x. Two ways to resolve the ambiguity: 1. **Even-y convention (default).** Treat every `id_pub` as the **even-y** point (`02||id_pub`). A signer whose natural key is odd-y uses the BIP-340-adjusted (negated) private key, so the key it controls matches the even-y point others derive. This is consistent with the ECDSA verification rule (`0x02 || id_pub`) in [Signature Schemes](#signature-schemes). 2. **Published `id_pub_full`.** Registering the full compressed `id_pub_full` (33 bytes, `02||x` or `03||x`) records the **actual** parity, so peers derive the correct address / ECDH point without assuming even-y. For an identity registering via an **ECDSA** commit (`alg: "ecdsa"`), the registration signature's recovered public key MUST equal the registered `id_pub_full`, binding the parity to the key. When present, `id_pub_full` MUST satisfy `x_only(id_pub_full) == id_pub`. `id_pub_full` is OPTIONAL; absent it, the even-y convention applies. ##### Enclaves Map The `enclaves` field is a free-form map of string keys to enclave IDs. Applications use this to associate named enclaves with an identity. Common keys: * `personal` — the identity's personal enclave (see Appendix A) The map is application-defined; the Registry stores but does not interpret the keys. ##### Validation Limits | Field | Max | | ------------ | ------------- | | enclaves map | 256 entries | | enclaves key | 64 characters | Nodes MUST reject reg\_identity commits exceeding these limits. ##### Authorization * `commit.from` MUST equal `content.id_pub` (the parent identity). * `commit.sig` MUST verify via either of the two paths defined in [§Strict Self-Authorization](#strict-self-authorization) above for reg\_identity: direct (parent's own Schnorr or ECDSA signature, dispatched by `commit.alg`) or sub-key (cosign cert with `cert.parentId == id_pub` + sub-key Schnorr over `commit.hash`). RBAC and the Sender check remain keyed to the parent regardless of which path produced the signature. ##### Update/Deregister reg\_identity uses the **multi-event merge** model defined in [§DataView Semantics](#dataview-semantics) below. An identity MAY publish multiple reg\_identity events for the same `id_pub`; DataView folds the active set with last-seq-wins per top-level field. To **add or change** a key (e.g. register a new `dm` enclave, rotate `sub_pub`): * Submit a fresh reg\_identity event with only the keys you're changing in the `enclaves` map. DataView merges it into the prior view; un-mentioned keys persist. To **remove a key** from the `enclaves` map: * Submit `Delete(event_id)` referencing the event that contributed that key. Its contribution drops out of the merge. If the key was set by multiple active events, the latest surviving contributor wins. To **replace one specific past contribution** (e.g. fix a typo in the value the old event published): * Submit `Update(event_id, new_content)`. The old event's contribution is replaced wholesale by the Update's content; keys the old event set but the Update doesn't disappear from the merge. To **fully reset** all registrations for an identity: * Submit `Delete(event_id)` for each active reg\_identity event for that `id_pub`. After the last DELETE the merged view is empty. There is no single "replace whole map" verb. The merge model treats each event as a contribution; replace-all is achieved by deleting prior contributions. *** *** ### Confidentiality The Registry is **public by design**. The manifest declares `readers: [{ "type": "Public", "reads": "*" }]`, so every event is readable by any client; content is not encrypted. reg\_node, reg\_enclave, and reg\_identity records are intended for discovery and MUST NOT carry secrets. No confidentiality plugin is attached to this enclave. ### Registry Operations The rules below define Registry-specific semantics not covered by the generic enclave model. #### Conflict Resolution When two parties attempt to register the same `enclave_id` via `reg_enclave`: * First valid reg\_enclave wins (earliest `seq` in Registry). * Subsequent registrations for the same `enclave_id` are rejected (not superseded). * To transfer an enclave to a different node, use Migrate (not Registry re-registration). This differs from normal `reg_enclave` superseding behavior — enclave ID conflicts are errors, not updates. #### DataView Semantics Registry has three resource types with three different DataView reduce rules: | Resource | Key | Reduce | Rationale | | ----------------- | ------------ | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | reg\_node | `seq_pub` | latest-wins (last active event for the key) | A node has one current endpoint set; replacement is the natural verb | | reg\_enclave | `enclave_id` | first-wins (initial registration; conflicts rejected) | Enclave IDs are unforgeable; whoever registers first owns the slot. Transfer of hosting goes through Migrate, not Registry re-registration. See [§Conflict Resolution](#conflict-resolution). | | **reg\_identity** | **`id_pub`** | **multi-event merge** (fold all active events, LWW per top-level field) | An identity accumulates enclaves over time; partial updates should not erase prior keys | The merge rule below applies only to reg\_identity. reg\_node and reg\_enclave keep their existing single-active-entry semantics. ##### reg\_identity merge For a given `id_pub` X, the DataView projection is computed by: ``` events_X = [e ∈ Registry events | e.type == "reg_identity" , e.content.id_pub == X , status(e) == Active ] -- not Deleted, not Updated ordered = sort events_X by e.seq ascending view(X) = fold ordered with mergeMaps (shallow per top-level key, last-write-wins) ``` Where `mergeMaps` is **shallow** — top-level fields (`enclaves`, `id_pub_full`, `sub_pub`) merge per-field LWW, and the `enclaves` map itself merges per-key LWW. Nested objects deeper than the `enclaves` map are NOT merged structurally. **Example:** | seq | event | content | merged view after event | | --- | ------------------------------------------------------ | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | 100 | reg\_identity (CREATE) | `{id_pub:X, enclaves:{personal:"p1", dm:"d1"}}` | `{enclaves:{personal:"p1", dm:"d1"}}` | | 150 | reg\_identity (CREATE) | `{id_pub:X, enclaves:{group:"g1"}, sub_pub:"sp"}` | `{enclaves:{personal:"p1", dm:"d1", group:"g1"}, sub_pub:"sp"}` | | 200 | reg\_identity (CREATE) | `{id_pub:X, enclaves:{dm:"d2"}}` | `{enclaves:{personal:"p1", dm:"d2", group:"g1"}, sub_pub:"sp"}` | | 250 | Delete(event\_at\_seq\_100) | — | `{enclaves:{dm:"d2", group:"g1"}, sub_pub:"sp"}` (personal vanishes — only seq 100 set it) | | 300 | Update(event\_at\_seq\_150, `{enclaves:{group:"g2"}}`) | replaces seq 150's contribution | `{enclaves:{dm:"d2", group:"g2"}}` (sub\_pub vanishes — only seq 150 set it, now superseded by Update content that omits it) | **Status interpretation:** active = not in the SMT's deleted/updated lifecycle for that event id. See [`spec.md` §U (Update) and D (Delete)](/spec/kernel/spec#u-update-and-d-delete) for the canonical event status model. **Ordering tiebreak:** `seq` is strictly monotonic per enclave (sequencer-linearized), so per-key LWW has no ambiguity. ##### Implementation: re-fold on read vs. cache Two equivalent implementations: **Option A — re-fold on read.** DataView keeps no cache. On `GET /identity/:id_pub`, walk the active reg\_identity events for that id\_pub and fold. O(n) per read where n = active events for that id\_pub. No invalidation logic; impossible to have stale state. Recommended for the reference impl. **Option B — cached merged view.** DataView keeps `merged_view: Map`. On any reg\_identity event (CREATE / UPDATE / DELETE) for id\_pub X, recompute X's entry from active events. O(1) read, O(n) write but only on events affecting X. Recommended for production nodes that prioritize read latency. Both produce the same projection. Conformance is defined against the re-fold semantics; caches MUST agree with the re-fold result event-by-event. ##### SMT entry for reg\_identity The SMT records per-event status (`Active` / `Deleted` / `Updated`) as for any other event — see [`spec.md` §U (Update) and D (Delete)](/spec/kernel/spec#u-update-and-d-delete). The Registry node MAY additionally publish a per-identity merged-state hash under `SMT[registry:identity:] = sha256(canonical(view(id_pub)))` for clients that want a verifiable summary of an identity's current registration without replaying the event chain. This is OPTIONAL and additive — clients can always recompute by reading the event log. ##### Querying Clients query Registry by key: * reg\_node: `GET /nodes/:seq_pub` → latest active reg\_node event for that seq\_pub * reg\_enclave: `GET /enclaves/:enclave_id` → the unique active reg\_enclave event for that enclave\_id (first-wins) * reg\_identity: `GET /identity/:id_pub` → the merged view of all active reg\_identity events for that id\_pub #### Registry Governance **Registry Owner:** The `registry_owner_id` is the identity operating the Registry service. In the current centralized design: * The Registry Owner is a fixed, well-known identity (e.g., protocol operator) * The Registry Owner has standard Owner powers: can `Transfer`, `Pause`, `Resume`, `Terminate` * The Registry Owner does NOT have special powers over individual reg\_node/reg\_enclave/reg\_identity entries — those are controlled by their respective `Self` identities **Notes:** * Current design is **centralized**; future versions can be decentralized. #### The Process of Registry 1. **Node Register**: The node normally registers seq\_pub and domain / IP on the registry with reg\_node. If both domain and IP is set, first resolve IP then the domain. 2. **Create Enclave**: The client send manifest to the node, and get receipt with sequencer, then the client knows who is hosting the enclave. 3. **Enclave Register**: Then the client can send the finalized event as content of the register commit (event type predefined as reg\_enclave). And the registry will check the sig = event.sig in content. If it comes from the same signer, registry will accept this register. 4. **Identity Register**: The client registers its enclaves via reg\_identity, mapping `id_pub → enclaves`. The commit's `from` MUST equal `content.id_pub` (the parent identity), and `commit.sig` is either the parent's direct signature OR a sub-key Schnorr signature accompanied by a [cosign cert](#delegated-sub-keys) whose `parentId` equals `id_pub`. This step is optional but enables discovery of an identity's enclaves; it is the only step that publishes `sub_pub` and `id_pub_full`, both of which peers need for ECDH-derived encryption (ratchet-pair, mls-lazy OR-wrap, ecdh-envelope) and for parity-aware key operations. ### DataView API *Originally specified in `node-api.md`; moved here because these endpoints are Registry-specific, not generic node API. The Registry enclave's DataView exposes the endpoints below.* The Registry is an enclave with a DataView server that provides discovery endpoints. These endpoints are served by the Registry's DataView, not by the generic Enclave API. **Base URL:** Registry node endpoint (discovered via bootstrap) ##### GET /nodes/:seq_pub Resolve node by sequencer public key. **Path Parameters:** | Param | Type | Description | | -------- | ----- | -------------------- | | seq\_pub | hex64 | Sequencer public key | **Request:** ``` GET /nodes/a1b2c3... ``` **Response (200 OK):** ```jsonc { "seq_pub": "", "endpoints": [ { "uri": "https://node.example.com", "priority": 1 }, { "uri": "https://backup.example.com", "priority": 2 } ], "protocols": ["https", "wss"], "enc_v": 2 } ``` | Field | Type | Description | | --------------------- | ------ | ------------------------------------------ | | seq\_pub | hex64 | Sequencer public key | | endpoints | array | Endpoints sorted by priority (1 = highest) | | endpoints\[].uri | string | Endpoint URI | | endpoints\[].priority | uint | Priority (lower = preferred) | | protocols | array | Supported protocols | | enc\_v | uint | ENC protocol version | **Errors:** | Code | HTTP | Description | | ---------------- | ---- | ------------------- | | `NODE_NOT_FOUND` | 404 | Node not registered | ##### GET /enclaves/:enclave_id Resolve an enclave: returns the enclave record **and** its current hosting node, so a client can connect in a single round-trip (no separate `/nodes` follow-up needed). **Path Parameters:** | Param | Type | Description | | ----------- | ----- | ------------------ | | enclave\_id | hex64 | Enclave identifier | **Request:** ``` GET /enclaves/d4e5f6... ``` **Response (200 OK):** ```jsonc { "enclave": { "enclave_id": "", "sequencer": "", "creator": "", "created_at": 1706000000000, "app": "chat", "desc": "Team chat", "meta": {} }, "node": { "seq_pub": "", "endpoints": [ { "uri": "https://node.example.com", "priority": 1 } ], "protocols": ["https", "wss"], "enc_v": 2 } } ``` | Field | Type | Required | Description | | ------------------- | ------ | -------- | ---------------------------------------------------- | | enclave.enclave\_id | hex64 | Yes | Enclave identifier | | enclave.sequencer | hex64 | Yes | Current sequencer public key (equals `node.seq_pub`) | | enclave.creator | hex64 | No | Creator's identity key | | enclave.created\_at | uint | No | Creation timestamp (Unix milliseconds) | | enclave.app | string | No | Application identifier | | enclave.desc | string | No | Human-readable description | | enclave.meta | object | No | Application-defined metadata | | node.seq\_pub | hex64 | Yes | Sequencer public key hosting the enclave | | node.endpoints | array | Yes | Hosting node endpoints (`uri` + `priority`) | | node.protocols | array | No | Supported transports | | node.enc\_v | uint | No | Protocol version | **Errors:** | Code | HTTP | Description | | ------------------- | ---- | ----------------------------- | | `ENCLAVE_NOT_FOUND` | 404 | Enclave not registered | | `NODE_NOT_FOUND` | 404 | Sequencer node not registered | ##### GET /identity/:id_pub Resolve identity by public key. Returns the identity's registered enclaves. **Path Parameters:** | Param | Type | Description | | ------- | ----- | ------------------- | | id\_pub | hex64 | Identity public key | **Request:** ``` GET /identity/a1b2c3... ``` **Response (200 OK):** ```json { "id_pub": "", "enclaves": { "personal": "", "dm": "" } } ``` | Field | Type | Description | | -------- | ------ | -------------------------- | | id\_pub | hex64 | Identity public key | | enclaves | object | Map of label → enclave\_id | **Errors:** | Code | HTTP | Description | | -------------------- | ---- | ----------------------- | | `IDENTITY_NOT_FOUND` | 404 | Identity not registered | ## @enc-protocol/cli-sdk-base — base SDK classes The base classes that every `@enc-protocol/-cli` package extends. ### Install ```bash npm config set @enc-protocol:registry https://npm-registry.ocrybit.workers.dev/ npm install @enc-protocol/cli-sdk-base ``` You usually consume this transitively via a [per-app SDK](/sdk/apps) like `@enc-protocol/group-cli`. Direct use is for advanced cases — building a new app SDK, or embedding the engine in custom tooling. ### Exports ```js import { AppSdk } from '@enc-protocol/cli-sdk-base/app-sdk' import { AppClient } from '@enc-protocol/cli-sdk-base/app-client' import { DataView, loadDataView } from '@enc-protocol/cli-sdk-base/dataview' ``` Or the barrel: ```js import { AppSdk } from '@enc-protocol/cli-sdk-base' // = app-sdk ``` ### AppSdk App-driven SDK class. Reads `apps//{app.json, schema.json}` at runtime, composes an `AppClient`, and adds app-level submit/query that resolve via `tableMap` to enclave events. ```js const sdk = new AppSdk({ appId: 'super', mode: 'mem', repoRoot: '/path/to/app/repo', }) await sdk.init() await sdk.submit('moments', { body: 'wow' }) // → Personal.public const events = await sdk.query('moments') ``` #### Constructor ```ts new AppSdk(opts: { appId: string // matches apps// mode: 'mem' | 'cf' repoRoot: string // path to find apps/ + enclaves/ identity?: Identity // required for cf nodeUrl?: string // cf mode encHome?: string // state dir; defaults to ~/.enc forceReadable?: boolean // test-only Public-R escape (cf) }) ``` #### Methods | Method | Description | | -------------------------- | ----------------------------------------------------------------------------------------------- | | `async init()` | Load app definition, register all enclaves, wire the dataview. | | `async submit(name, args)` | Submit. `name` is a data\_type (resolved via tableMap) OR an enclave event directly. | | `async query(name)` | Query. `cross_enclave: true` reads go to the in-memory `DataView`; others resolve via tableMap. | | `raw()` | Returns the underlying `AppClient` for low-level access. | | `whoami()` | Identity + registered enclaves + dataview info. | #### Resolution `_resolve(name)` returns `{ enclave, event }`: 1. If `name` is a key in `schema.tableMap` → the enclave is found by the event name in the map's value. 2. Else if `name` matches an enclave event directly (fallback) → that enclave + event. 3. Otherwise throws. ### AppClient Multi-enclave coordinator. Holds one identity and N enclave adapters. ```js const client = new AppClient({ appId: 'super', mode: 'mem', repoRoot }) await client.init() // registers all 3 enclaves: DM, Group, Personal await client.submit('Personal', 'public', { body: 'wow' }) ``` #### Methods | Method | Description | | ---------------------------------------- | ------------------------------------------------------- | | `async init()` | Read `apps//app.json`, register each enclave. | | `async addEnclave(name, opts?)` | Add an enclave manually (init does this automatically). | | `async submit(enclaveName, event, args)` | Write to one enclave's adapter. | | `async query(enclaveName, event)` | Read from one enclave. | | `whoami()` | Identity, mode, list of registered enclaves with ids. | In mem mode, an identity is provisioned per enclave. In cf mode, an externally-supplied identity is used for all enclaves; each is minted on the node with that identity as owner. ### DataView In-memory dataview for `cross_enclave: true` reads. Mirrors the production Cloudflare Durable Object dataview semantics without SQLite. #### Loading ```js const dataview = loadDataView('super', '/path/to/app/repo') // Reads apps/super/infra.json's manifest.cross_enclave_reads // e.g. { profiles: { from: 'Personal.Shared(profile)', via: 'dataview', key: 'id_pub' } } ``` #### Methods | Method | Description | | ------------------------- | ---------------------------------------------------------------------------------- | | `has(viewName)` | Whether this view is configured. | | `ingest(enclaveName, ev)` | Called by `AppClient.submit` for every successful event; routes to matching views. | | `query(viewName)` | Returns rows. | | `watchedEnclaves()` | Set of enclave names this dataview cares about. | #### Storage shape * **Append-only** by default (most events) — stored as an array. * **UPSERT keyed by `from`** for `Shared()` slot events — a keyed Map. * **UPSERT keyed by id field** for registry snapshots (`reg_identity` → `id_pub`, `reg_enclave` → `enclave_id`, `reg_node` → `seq_pub`). ### Per-app subclassing A per-app SDK (`@enc-protocol/group-cli`, etc.) extends `AppSdk`: ```js import { AppSdk } from '@enc-protocol/cli-sdk-base' export class GroupSdk extends AppSdk { constructor(opts) { super({ ...opts, appId: 'group' }) } async _encrypt(dataType, args) { return args } // default pass-through async submitMessages(args) { args = await this._encrypt('messages', args) return this.submit('messages', args) } async queryMessages() { return this.query('messages') } } ``` The `_encrypt(dataType, args)` hook is per-app: pass-through by default; subclass to add MLS / AEAD / your-protocol encryption. ### See also * [Per-app SDKs](/sdk/apps) — the generated subclasses for each reference app * [`@enc-protocol/cli`](/sdk/cli) — the `enc` binary that uses these classes internally * [`@enc-protocol/client`](/sdk/client) — the per-enclave adapter the `AppClient` wraps ## @enc-protocol/cli — the app CLI The global `enc` binary that drives ENC Protocol **apps** from the command line — installing app SDKs, minting enclaves, and submitting/querying app data. > This is the npm app-driver CLI. It's distinct from the Rust [`enc` enclave CLI](/guide/cli), > which is the lower-level git-style tool for raw enclave operations (commits, bundles, proofs). ### Install ```bash npm install -g @enc-protocol/cli --registry https://npm-registry.ocrybit.workers.dev/ enc help ``` Published to the ENC registry at `https://npm-registry.ocrybit.workers.dev/`. ### Top-level commands | Command | What it does | | -------------------- | ------------------------------------------------ | | `enc help` | Show top-level help. | | `enc apps` | List installed/available apps. | | `enc add ` | Install a per-app SDK package locally. | | `enc remove ` | Uninstall an app. | | `enc list` | List installed apps. | | `enc keygen` | Generate a keypair (saved to `~/.enc/key.json`). | | `enc keygen --force` | Regenerate keypair. | | `enc node` | Start a local node (development). | ### Skill commands Install Claude Code skills into the current project. | Command | What it does | | ------------------------- | ------------------------------------------------------------------------ | | `enc skill add ` | Install skill → symlinks `SKILL.md` into `./.claude/commands/.md`. | | `enc skill remove ` | Remove the symlink + uninstall the skill package. | | `enc skill list` | List installed skills. | | `enc skill search` | Search available skills on the registry. | Resolution: `enc skill add ` looks for `@enc-protocol/skill-` first, falls back to `@enc-protocol/`. ### Per-app commands For any app with an `apps//` definition: ```bash enc help # actions/reads + examples enc submit [] # write (data_type or enclave event) enc query [] # read events; no name = all events enc whoami # identity / enclaves / mode enc create # mint enclaves (cf only) ``` **Flags:** * `--mem` — use the in-process backend (default is cf via `NODE_URL`) * `--node=` — override `NODE_URL` * `--json` — output structured JSON (useful for piping or agents) ### Resolution: data\_types vs enclave events `enc submit ` accepts either: 1. **App-level data\_type** (preferred) — resolved via `schema.tableMap` to the underlying enclave event. 2. **Enclave-level event** (backward compat) — passed through unchanged. ```bash # App-level (super has tableMap.messages → DM.message) enc super submit messages '{"message_draft":"hi"}' # Enclave-level (writes to DM.message directly) enc super submit message '{"message_draft":"hi"}' ``` For multi-enclave apps, app-level routing is the canonical surface. ### TUI / REPL | Command | What it does | | --------------------- | -------------------------------------- | | `enc app ` | Launch TUI (cf mode) — Ink terminal UI | | `enc app --mem` | Launch TUI (mem mode) | | `enc :repl` | Interactive REPL (mem) | | `enc :repl:cf` | Interactive REPL (cf) | | `enc :tui:mem` | TUI explicitly in mem mode | | `enc :tui:cf` | TUI explicitly in cf mode | ### Environment variables | Variable | Default | Description | | ---------------- | ------------------ | ------------------------------------------------------------------ | | `ENC_HOME` | `~/.enc` | Identity + per-app state directory. | | `NODE_URL` | — | cf-mode default backend (a wrangler dev URL or a production node). | | `ENC_SKILLS_DIR` | `~/.claude/skills` | Local skill install directory. | ### State layout ``` ~/.enc/ key.json identity keypair apps//state/ cf-state.json enclaveId + nodeUrl (single-enclave apps) cf-.json per-enclave for multi-enclave apps mem-state.json replayed event log (mem mode) ``` ### See also * [`@enc-protocol/cli-sdk-base`](/sdk/cli-sdk-base) — the base SDK classes the CLI uses internally * [Per-app SDKs](/sdk/apps) — the programmatic surface behind `enc ` ## SDK Reference The ENC SDKs are JavaScript/TypeScript packages **generated from the Lean 4 specification** — the same formally-verified source that defines the protocol. Because they're codegen output, the client-side crypto, RBAC, and proof-checking you run is byte-identical to what the [node](/guide/node) and the [spec](/spec) compute. ### Packages | Package | What it is | | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | [`@enc-protocol/core`](/sdk/core) | Protocol primitives — crypto, events, RBAC, SMT, CT, manifest validation, snapshots, and the confidentiality plugins | | [`@enc-protocol/client`](/sdk/client) | Network client — HTTP + WebSocket, sessions, wallet utilities, and a high-level SDK | | [`@enc-protocol/memory`](/sdk/memory) | In-process node emulation — the full protocol in the JS heap, no network, for tests and local development | | [`@enc-protocol/cli`](/sdk/cli) | The `enc` app CLI — install apps, mint enclaves, submit/query from the terminal | | [`@enc-protocol/cli-sdk-base`](/sdk/cli-sdk-base) | Base classes (`AppSdk`, `AppClient`, `DataView`) that every app SDK extends | | [Plugin SDKs](/sdk/plugins) | The four protocol encryption plugins — ratchet-pair, mls-lazy, identity-aead, ecdh-envelope | | [App SDKs](/sdk/apps) | Typed `@enc-protocol/-cli` SDKs for the reference apps — personal, dm, group, super, registry, node | ### Installation ```bash # point the @enc-protocol scope at the ENC registry once: npm config set @enc-protocol:registry https://npm-registry.ocrybit.workers.dev/ npm install @enc-protocol/core @enc-protocol/client ``` All `@enc-protocol/*` packages are published to the ENC registry at `https://npm-registry.ocrybit.workers.dev/`. Setting the scope registry (above) lets you `npm install` them without a per-command `--registry` flag. `@enc-protocol/client` takes `@enc-protocol/core` as a peer dependency. ### Generated, not hand-written Every exported function is emitted from the Lean DSL and proven equivalent to the protocol's canonical semantics — so signature checks, proof verification, and RBAC evaluation done in the SDK match exactly what the node and the spec do. The published source is regenerated from the spec on every build; don't hand-edit it. ## @enc-protocol/memory — API Reference `@enc-protocol/memory` runs the entire ENC node protocol **in the JavaScript heap** — no network, no subprocess, no `fetch`. It exposes the same surface an app uses against a real [node](/guide/node) (`createEnclave` / `submit` / `query` / `subscribe` / `as` / `toEnclave`) but executes the node state machine directly in-process: same RBAC, same signature validation, same event log, just no wire. It's the fast path for tests, local development, and deterministic workflows. Like the other SDKs, it's generated from the Lean specification. ### Installation ```bash npm install @enc-protocol/memory --registry https://npm-registry.ocrybit.workers.dev/ ``` ### `MemoryAdapter` A single class. Construct one bound to an identity, then drive enclaves through it. Multiple adapters in a process share one in-memory registry of enclaves keyed by enclave id, so `as()` / `toEnclave()` and cross-user reads see consistent state — mirroring how one node hosts many enclaves. #### `MemoryAdapter.devMode(nodeUrl, enclaveId, privKeyHex, opts?)` Create an adapter with a signing key (the common entry point for tests). ```javascript import { MemoryAdapter } from '@enc-protocol/memory' const alice = MemoryAdapter.devMode(null, null, alicePrivHex) ``` Derives the public key from `privKeyHex` and returns an adapter that can sign commits. `nodeUrl` is ignored (there is no network) and `enclaveId` may be `null` until you create or attach to one. #### `.createEnclave(manifest)` ```javascript await adapter.createEnclave(manifest: string | Object) → { enclave_id: string, ok: true } | { error: string } ``` Signs a `Manifest` commit, instantiates a fresh in-heap node, and returns the derived enclave id (the manifest hash). The adapter's `enclaveId` is set to the new enclave. #### `.submit(type, content)` ```javascript await adapter.submit(type: string, content: string | Object) → { ok: true, ...receipt } | { error: string, type: 'Error' } ``` Signs and applies a commit of the given event type. Runs the same RBAC authorization and signature checks as a real node; on success returns the receipt and notifies subscribers. #### `.query(type, opts?)` ```javascript await adapter.query(type: string, opts?: { limit?: number, from?: string, reverse?: boolean }) → event[] ``` Returns events from the current enclave, filtered by type (`'*'` for all) and optionally by author, newest-first by default. #### `.subscribe(callback)` ```javascript const unsubscribe = adapter.subscribe((event) => { /* … */ }) ``` Registers a listener for new events on the current enclave and returns an unsubscribe function. `submit` delivers each accepted event to all subscribers synchronously. #### RBAC helpers ```javascript await adapter.grant(target, role) // Grant() await adapter.revoke(target, role) // Revoke() await adapter.move(target, fromState, toState) // Move(,) await adapter.transfer(target, trait) // Transfer() ``` Convenience wrappers over `submit` that build the corresponding access-control event. #### `.as(pubHex)` / `.toEnclave(enclaveId)` ```javascript adapter.as(pubHex: string) → MemoryAdapter // same enclave + signer, different identity view adapter.toEnclave(enclaveId: string) → MemoryAdapter // same signer, different enclave ``` Return new adapters that share the signing key and the in-memory registry, so you can act as another identity or talk to another enclave without re-creating state. #### `.queryEndpoint(path, opts?)` ```javascript await adapter.queryEndpoint(path: string, opts?: { limit?: number, reverse?: boolean }) → row[] ``` Emulates a public dataview: scans **every** enclave in the registry for events matching the path's event name and returns flattened rows (`{ ...content, from, _enclave, _event_id, _timestamp }`), newest-first. This is the in-memory stand-in for a node's cross-enclave indexing, so SDK code that reads from a dataview at runtime works unchanged against memory. #### `MemoryAdapter.reset()` ```javascript MemoryAdapter.reset() ``` Wipe the shared in-memory registry (enclaves, subscriptions, any persisted state) between test runs. ### Example ```javascript import { MemoryAdapter } from '@enc-protocol/memory' MemoryAdapter.reset() // Alice creates an enclave and posts const alice = MemoryAdapter.devMode(null, null, alicePrivHex) const { enclave_id } = await alice.createEnclave({ enc_v: 2, nonce: 1, RBAC: { use_temp: 'none', schema: [ { event: 'post', role: 'owner', ops: ['C', 'U', 'D'] }, { event: '*', role: 'Public', ops: ['R'] }, ], states: [], traits: ['owner(0)'], initial_state: { owner: [alice.pubHex] }, }, }) await alice.submit('post', { body: 'hello from the heap' }) // Bob reads it — same enclave, different identity, no network const bob = alice.as(bobPubHex) const posts = await bob.query('post') console.log(posts[0].content) // '{"body":"hello from the heap"}' ``` ## @enc-protocol/client — API Reference Complete API reference for `@enc-protocol/client`, the network client package for the ENC Protocol. Provides HTTP, WebSocket, session management, wallet utilities, and a high-level SDK interface. ### Installation ```bash npm install @enc-protocol/client --registry https://npm-registry.ocrybit.workers.dev/ ``` Peer dependency: ```bash npm install @enc-protocol/core --registry https://npm-registry.ocrybit.workers.dev/ ``` ### Package Structure ``` @enc-protocol/client index.js Re-exports everything from all modules http.js NodeClient — HTTP client for node communication ws.js NodeWebSocket — WebSocket client for real-time streaming session.js SessionManager — auto-refreshing session tokens sdk.js High-level SDK functions, factory exports, state management registry-client.js RegistryClient — registry node lookups wallet.js ENC pubkey to EVM address utilities ``` Each module is importable individually: ```javascript import { NodeClient } from '@enc-protocol/client/http.js' import { NodeWebSocket } from '@enc-protocol/client/ws.js' ``` Or import everything from the barrel: ```javascript import { NodeClient, createIdentity, SessionManager } from '@enc-protocol/client' ``` *** ### http.js — `NodeClient` HTTP client for communicating with an ENC node. All requests go through a single multiplexed `POST /` endpoint (except STH and liveness). Supports both ECDH-encrypted and plaintext modes. #### Constructor ```javascript import { NodeClient } from '@enc-protocol/client/http.js' const client = new NodeClient(baseUrl, opts?) ``` **Parameters:** | Parameter | Type | Description | | --------------------- | ---------------- | ----------------------------------------------------- | | `baseUrl` | `string` | Node base URL (trailing slash stripped automatically) | | `opts.enclaveId` | `string` | Enclave ID (64 hex) for ECDH requests | | `opts.identityPubHex` | `string` | Identity public key (64 hex) | | `opts.seqPubHex` | `string` | Sequencer public key (64 hex) | | `opts.sessionManager` | `SessionManager` | Session manager instance for ECDH | **Plaintext mode** (no opts or partial opts): ```javascript const client = new NodeClient('https://your-node.example.com') ``` **Encrypted mode** (all four opts provided): ```javascript import { SessionManager } from '@enc-protocol/client/session.js' const sm = new SessionManager(myPrivateKey) const client = new NodeClient('https://your-node.example.com', { enclaveId: '...', identityPubHex: '...', seqPubHex: '...', sessionManager: sm, }) ``` #### `.encrypted` (getter) Returns `true` when all four ECDH credentials are configured. ```javascript client.encrypted → boolean ``` #### `.submitCommit(commit)` Submit a signed commit to the node. ```javascript client.submitCommit(commit: Object) → Promise ``` Sends `POST /` with the commit JSON. The node detects commits by the presence of the `exp` field. **Returns** a receipt object on success: ```javascript { type: 'Receipt', seq: 0, id: '...', // 64 hex event ID hash: '...', // 64 hex commit hash timestamp: 1234567, // ms sig: '...', // 128 hex author signature seq_sig: '...', // 128 hex sequencer signature sequencer: '...', // 64 hex sequencer pubkey } ``` Or an error: ```javascript { type: 'Error', code: 'UNAUTHORIZED' | 'EXPIRED' | 'DUPLICATE' | ..., message: '...', } ``` ```javascript import { mkCommit, signCommit } from '@enc-protocol/core/event.js' const commit = mkCommit(enclaveId, pubHex, 'post', '{"body":"hi"}', Date.now() + 300000, []) const signed = signCommit(commit, privateKey) const receipt = await client.submitCommit(signed) console.log(receipt.id) // event ID ``` #### `.query(filter, auth?)` Query events. Uses ECDH encryption when credentials are configured, plaintext otherwise. ```javascript client.query(filter: Object, auth?: Object) → Promise ``` **Encrypted mode:** The filter is encrypted with ECDH (label: `'enc:query'`). The request sends: ```json { "type": "Query", "enclave": "...", "from": "...", "content": "session.ciphertext" } ``` The response `content` is decrypted with label `'enc:response'`. **Plaintext mode:** ```javascript const result = await client.query( { enclave: enclaveId, type: 'post', limit: 50 }, { identity: pubHex, session: sessionToken } // optional auth ) ``` **Filter fields:** | Field | Type | Description | | ----------- | --------- | --------------------------------------- | | `enclave` | `string` | Enclave ID (required in plaintext mode) | | `type` | `string` | Event type filter | | `from` | `string` | Author pubkey filter | | `id` | `string` | Specific event ID | | `seq` | `number` | Specific sequence number | | `timestamp` | `number` | Timestamp filter | | `limit` | `number` | Max events to return | | `reverse` | `boolean` | Reverse order | #### `.pull(afterSeq, opts?)` Pull events after a sequence number. Uses ECDH encryption when configured. ```javascript client.pull(afterSeq: number, opts?: Object) → Promise ``` **Parameters:** | Parameter | Type | Description | | --------------- | -------- | --------------------------------------- | | `afterSeq` | `number` | Pull events after this seq (-1 for all) | | `opts.identity` | `string` | Identity pubkey (plaintext mode) | | `opts.session` | `string` | Session token (plaintext mode) | | `opts.limit` | `number` | Max events | **Returns:** ```javascript { type: 'Events', events: [...], // array of event objects } ``` ```javascript // Pull all events from the beginning const result = await client.pull(-1, { limit: 100 }) for (const event of result.events) { console.log(event.seq, event.type, event.content) } ``` #### `.info()` Get enclave info. Requires ECDH credentials. ```javascript client.info() → Promise ``` Returns `{ type: 'Error', code: 'NO_CREDENTIALS', message: '...' }` without ECDH credentials. #### `.getSTH(enclaveId?)` Get the Signed Tree Head for an enclave. ```javascript client.getSTH(enclaveId?: string) → Promise<{ t: number, ts: number, r: string, sig: string }> ``` Sends `GET /:enclaveId/sth`. Uses the configured `enclaveId`, the provided parameter, or `'_'` as fallback. **Response fields:** | Field | Type | Description | | ----- | -------- | ---------------------------- | | `t` | `number` | Tree size (number of leaves) | | `ts` | `number` | Timestamp | | `r` | `string` | Root hash (64 hex) | | `sig` | `string` | Schnorr signature (128 hex) | ```javascript import { verifySTH, hexToBytes } from '@enc-protocol/core/crypto.js' const sth = await client.getSTH() const valid = verifySTH(sth.t, sth.ts, hexToBytes(sth.r), sth.sig, hexToBytes(seqPubHex)) ``` #### `.getInclusion(leafIndex)` Get an inclusion proof for a leaf (event). Uses ECDH when configured. ```javascript client.getInclusion(leafIndex: number) → Promise ``` **Encrypted mode:** sends `POST /inclusion` with encrypted content containing `{ leaf_index }`. **Plaintext mode:** sends `POST /inclusion` with `{ seq: leafIndex }`. #### `.getState(key)` Get a state proof for a key. Uses ECDH when configured. ```javascript client.getState(key: string) → Promise ``` **Encrypted mode:** sends `POST /state` with encrypted content containing `{ key }`. **Plaintext mode:** sends `POST /state` with `{ key }`. ```javascript // Get RBAC state for an identity const result = await client.getState(`rbac:${pubHex}`) ``` #### `.pingLiveness()` Check if the node is alive via CORS preflight. ```javascript client.pingLiveness() → Promise ``` Sends `OPTIONS /`. Returns `true` if the node responds with HTTP 204. *** ### ws.js — `NodeWebSocket` WebSocket client for real-time event streaming. Follows a Nostr-like protocol with typed JSON messages. #### Constructor ```javascript import { NodeWebSocket } from '@enc-protocol/client/ws.js' const ws = new NodeWebSocket(url) ``` | Parameter | Type | Description | | --------- | -------- | ----------------------------------- | | `url` | `string` | WebSocket URL (`ws://` or `wss://`) | #### `.on(event, handler)` Register an event handler. Returns `this` for chaining. ```javascript ws.on(event: string, handler: Function) → NodeWebSocket ``` **Events:** | Event | Handler Signature | Description | | ----------- | --------------------- | ----------------------------------------- | | `'event'` | `(event, subId)` | New event received | | `'eose'` | `(subId)` | End of stored events for subscription | | `'receipt'` | `(receipt)` | Commit receipt (after submitting via WS) | | `'error'` | `({ code, message })` | Error from server | | `'closed'` | `(subId, reason)` | Subscription closed by server | | `'notice'` | `(msg)` | Server notice (unrecognized message type) | | `'open'` | `()` | WebSocket connection opened | | `'close'` | `(code, reason)` | WebSocket connection closed | #### `.connect()` Open the WebSocket connection. Returns `this` for chaining. ```javascript ws.connect() → NodeWebSocket ``` #### `.close()` Close the WebSocket connection. ```javascript ws.close() ``` #### `.connected` (getter) Check if the WebSocket is open. ```javascript ws.connected → boolean ``` #### `.subscribe(enclave, from, session, filter?)` Subscribe to events on an enclave. ```javascript ws.subscribe( enclave: string, // enclave ID (64 hex) from: string | null, // identity pubkey for auth (or null) session: string | null, // session token (or null) filter?: Object // { type?, from?, id?, seq?, timestamp?, limit? } ) → null ``` Sends a `Query` message over the WebSocket. The subscription ID is assigned by the server and delivered via `'event'` and `'eose'` callbacks. Returns `null` (not the sub ID). #### `.unsubscribe(subId)` Close a subscription. ```javascript ws.unsubscribe(subId: string) ``` Sends a `Close` message with the subscription ID. #### `.commit(commit)` Submit a signed commit via WebSocket. ```javascript ws.commit(commit: Object) ``` Sends the raw signed commit JSON. The server detects it by the `exp` field. The receipt is delivered via the `'receipt'` event handler. Throws `Error('WebSocket not connected')` if not connected. #### Full WebSocket Example ```javascript import { NodeWebSocket } from '@enc-protocol/client/ws.js' import { generateKeypair, bytesToHex, generateSession } from '@enc-protocol/core/crypto.js' import { mkCommit, signCommit } from '@enc-protocol/core/event.js' const kp = generateKeypair() const pub = bytesToHex(kp.publicKey) const { session } = generateSession(kp.privateKey) const ws = new NodeWebSocket('wss://your-node.example.com/ws') ws.on('open', () => { console.log('Connected') // Subscribe to all events ws.subscribe(enclaveId, pub, session, {}) }) ws.on('event', (event, subId) => { console.log(`[${subId}] Event ${event.seq}: ${event.type}`) console.log('Content:', event.content) }) ws.on('eose', (subId) => { console.log(`[${subId}] End of stored events — now streaming live`) }) ws.on('receipt', (receipt) => { console.log('Commit accepted, event ID:', receipt.id) }) ws.on('error', (err) => { console.error('Error:', err.code, err.message) }) ws.on('close', (code, reason) => { console.log('Disconnected:', code, reason) }) ws.connect() // Submit a commit after connection setTimeout(() => { const commit = mkCommit(enclaveId, pub, 'post', '{"body":"via ws"}', Date.now() + 300000, []) const signed = signCommit(commit, kp.privateKey) ws.commit(signed) }, 1000) ``` *** ### session.js — `SessionManager` Auto-refreshing session token manager. Generates cryptographic session tokens and automatically refreshes them before expiry. #### Constructor ```javascript import { SessionManager } from '@enc-protocol/client/session.js' const sm = new SessionManager(identityPriv, opts?) ``` | Parameter | Type | Default | Description | | -------------------- | ------------ | -------- | -------------------------------------------- | | `identityPriv` | `Uint8Array` | required | 32-byte identity private key | | `opts.duration` | `number` | `3600` | Session duration in seconds (capped at 7200) | | `opts.refreshBefore` | `number` | `300` | Refresh this many seconds before expiry | #### `.getSession()` Get a valid session, auto-refreshing if needed. ```javascript sm.getSession() → { session: string, // 136 hex char session token sessionPriv: Uint8Array, // 32-byte session private key expires: number // Unix timestamp (seconds) } ``` Auto-refreshes when `now >= expires - refreshBefore`. #### `.token` (getter) Get just the session token string (calls `getSession()` internally). ```javascript sm.token → string // 136 hex chars ``` #### `.valid` (getter) Check if the current session is still valid (not expired). ```javascript sm.valid → boolean ``` Returns `false` if no session has been generated yet. #### `.refresh()` Force immediate session refresh. ```javascript sm.refresh() ``` #### Example ```javascript import { SessionManager } from '@enc-protocol/client/session.js' import { generateKeypair } from '@enc-protocol/core/crypto.js' const kp = generateKeypair() const sm = new SessionManager(kp.privateKey, { duration: 3600, // 1 hour sessions refreshBefore: 300, // refresh 5 min before expiry }) // First call generates a session const { session, sessionPriv, expires } = sm.getSession() console.log(sm.valid) // true // Subsequent calls return cached session (until refresh needed) const same = sm.getSession() console.log(same.session === session) // true (same token) // Force refresh sm.refresh() const fresh = sm.getSession() console.log(fresh.session === session) // false (new token) ``` *** ### sdk.js — High-Level SDK High-level functions for identity management, connection state, commit creation, and factory constructors. Re-exports key crypto primitives for convenience. #### Identity Management ##### `createIdentity(seed?)` Create a new identity, optionally from a seed. ```javascript createIdentity(seed?: Uint8Array) → { privateKey: Uint8Array, publicKey: Uint8Array, publicKeyHex: string } ``` * Without seed: generates a random keypair * With seed: derives the private key as `sha256(seed)` ```javascript import { createIdentity } from '@enc-protocol/client/sdk.js' // Random identity const alice = createIdentity() // Deterministic identity from seed const bob = createIdentity(new TextEncoder().encode('bob-seed-phrase')) console.log(bob.publicKeyHex) // always the same for same seed ``` ##### `loadIdentity(privateKey)` Load an identity from an existing private key. ```javascript loadIdentity(privateKey: Uint8Array) → { privateKey: Uint8Array, publicKey: Uint8Array, publicKeyHex: string } ``` ##### `signWithIdentity(identity, data)` Sign data with an identity's private key (Schnorr). ```javascript signWithIdentity(identity: Object, data: Uint8Array) → Uint8Array(64) ``` #### Connection State ##### `ConnectionStatus` Frozen enum of connection states. ```javascript import { ConnectionStatus } from '@enc-protocol/client/sdk.js' ConnectionStatus.disconnected // 'disconnected' ConnectionStatus.connecting // 'connecting' ConnectionStatus.connected // 'connected' ConnectionStatus.error // 'error' ``` ##### `createConnection(host, port, nodePubHex)` Create a connection config object. ```javascript createConnection(host: string, port: number, nodePubHex: string) → { host: string, port: number, nodePubHex: string, status: 'disconnected' } ``` ##### `connect(conn)` / `disconnect(conn)` Update connection status (pure functions, return new objects). ```javascript connect(conn: Object) → Object // { ...conn, status: 'connected' } disconnect(conn: Object) → Object // { ...conn, status: 'disconnected' } ``` ##### `isConnected(conn)` ```javascript isConnected(conn: Object) → boolean ``` #### Commit Creation ##### `createCommit(identity, enclave, type, content, exp?, tags?)` Create and sign a commit in one call. ```javascript createCommit( identity: Object, // { privateKey, publicKey, publicKeyHex } enclave: string, // enclave ID (64 hex) type: string, // event type content: string, // JSON string content exp?: number, // expiration ms (default: now + 5 min) tags?: string[][] // tags (default: []) ) → Object // signed commit (has sig field) ``` ```javascript import { createIdentity, createCommit } from '@enc-protocol/client/sdk.js' const id = createIdentity() const signed = createCommit(id, enclaveId, 'post', '{"body":"hi"}') // signed is ready to submit to a node ``` ##### `createManifestCommit(identity, manifestContent, exp?, tags?)` Create and sign a manifest commit with a derived enclave ID. ```javascript createManifestCommit( identity: Object, // { privateKey, publicKey, publicKeyHex } manifestContent: string, // manifest JSON string exp?: number, // expiration (default: now + 5 min) tags?: string[][] // tags (default: []) ) → Object // signed commit with derived enclave ``` ```javascript const manifest = JSON.stringify({ enc_v: 2, nonce: Date.now(), RBAC: { use_temp: 'none', schema: [{ event: '*', role: 'Public', ops: ['R', 'C'] }], states: [], traits: [], initial_state: {}, }, }) const signed = createManifestCommit(id, manifest) console.log(signed.enclave) // derived enclave ID ``` ##### `buildRegEnclaveContent(manifestEvent, opts?)` Build content for a registry `Reg_Enclave` commit. ```javascript buildRegEnclaveContent( manifestEvent: Object, // full finalized manifest event opts?: { app?: string, // application name desc?: string, // description meta?: Object, // arbitrary metadata owner_proof?: string, // ownership proof } ) → string // JSON string for Reg_Enclave content ``` #### Client State Management Functional state management for SDK client lifecycle. ##### `SubmitResult` ```javascript SubmitResult.success // 'success' SubmitResult.notConnected // 'notConnected' SubmitResult.error // 'error' ``` ##### `initClient(identity)` Initialize client state. ```javascript initClient(identity: Object) → { identity: Object, connection: null, cachedSTH: null, pendingReceipts: [] } ``` ##### `connectClient(state, host, port, nodePubHex)` Connect client to a node (updates state). ```javascript connectClient(state, host, port, nodePubHex) → Object // new state with connected connection ``` ##### `disconnectClient(state)` Disconnect client. ```javascript disconnectClient(state) → Object // new state with disconnected connection ``` ##### `clientIsConnected(state)` ```javascript clientIsConnected(state) → boolean ``` ##### `getCachedSTH(state)` / `cacheSTH(state, sth, timestamp)` Manage cached Signed Tree Head. ```javascript getCachedSTH(state) → Object | null cacheSTH(state, sth, timestamp) → Object // new state with cached STH ``` #### Receipt Verification ##### `verifyNodeReceipt(receipt, nodePubHex)` Verify a receipt from the node by checking the sequencer's co-signature. ```javascript verifyNodeReceipt(receipt: Object, nodePubHex: string) → boolean ``` ```javascript import { verifyNodeReceipt } from '@enc-protocol/client/sdk.js' const receipt = await client.submitCommit(signed) const valid = verifyNodeReceipt(receipt, seqPubHex) ``` #### Health Check ##### `healthCheck(state)` ```javascript healthCheck(state) → { connected: true, latency: 0, version: '1.0.0' } | null ``` Returns `null` if not connected. #### SDK Interface Metadata ##### `SDKInterface` / `SDKOperation` Enums describing the SDK's operation categories. ```javascript SDKInterface.node_api // 'node_api' SDKInterface.registry_api // 'registry_api' SDKInterface.local_api // 'local_api' SDKOperation.createIdentity // 'createIdentity' SDKOperation.loadIdentity // 'loadIdentity' SDKOperation.derivePublicKey // 'derivePublicKey' SDKOperation.generatePrivateKey // 'generatePrivateKey' SDKOperation.signWithIdentity // 'signWithIdentity' SDKOperation.createCommit // 'createCommit' SDKOperation.submitCommit // 'submitCommit' SDKOperation.queryEvents // 'queryEvents' SDKOperation.verifyNodeReceipt // 'verifyNodeReceipt' SDKOperation.verifyLogInclusion // 'verifyLogInclusion' SDKOperation.verifyLogConsistency // 'verifyLogConsistency' SDKOperation.cacheSTH // 'cacheSTH' SDKOperation.healthCheck // 'healthCheck' SDKOperation.lookupNode // 'lookupNode' SDKOperation.lookupEnclave // 'lookupEnclave' SDKOperation.resolveEnclave // 'resolveEnclave' ``` ##### `sdkOperationInterface(op)` Map an operation to its interface category. ```javascript sdkOperationInterface(op: string) → string | null // 'createIdentity' → 'local_api' // 'submitCommit' → 'node_api' // 'lookupNode' → 'registry_api' ``` ##### `allSDKOperations` Frozen array of all operation names. ```javascript allSDKOperations → string[] // all 16 operation names ``` #### Factory Functions Convenience constructors for all client classes. ##### `createNodeClient(url)` ```javascript createNodeClient(url: string) → NodeClient ``` ##### `createRegistryClient(url)` ```javascript createRegistryClient(url: string) → RegistryClient ``` ##### `createWebSocket(url, opts?)` ```javascript createWebSocket(url: string, opts?: Object) → NodeWebSocket ``` ##### `createSessionManager(identityPriv, opts?)` ```javascript createSessionManager(identityPriv: Uint8Array, opts?: Object) → SessionManager ``` #### Re-exports `sdk.js` re-exports the following from `@enc-protocol/core/crypto.js`: ```javascript export { hexToBytes, bytesToHex, derivePublicKey, schnorrSign, generateSession, sha256Hash, taggedHash, computeContentHash, computeCommitHash, generateKeypair, ecdh, deriveKey, encrypt, decrypt, } from '@enc-protocol/core/crypto.js' ``` And re-exports all client classes: ```javascript export { NodeClient, NodeWebSocket, RegistryClient, SessionManager } ``` *** ### registry-client.js — `RegistryClient` Client for the ENC registry node. Used for enclave discovery and node lookup. #### Constructor ```javascript import { RegistryClient } from '@enc-protocol/client/registry-client.js' const registry = new RegistryClient(registryUrl) ``` #### `.lookupNode(seqPub)` Look up a node by its sequencer public key. ```javascript registry.lookupNode(seqPub: string) → Promise ``` Sends `GET /nodes/:seqPub`. Returns `null` on 404. #### `.lookupEnclave(enclaveId)` Look up an enclave by ID. ```javascript registry.lookupEnclave(enclaveId: string) → Promise ``` Sends `GET /enclaves/:enclaveId`. Returns `null` on 404. #### `.resolveEnclave(enclaveId)` Resolve an enclave to its hosting node and endpoints. ```javascript registry.resolveEnclave(enclaveId: string) → Promise<{ enclave: Object, node: Object } | null> ``` Sends `GET /resolve/:enclaveId`. Returns `null` on 404. #### `.listNodes()` List all active nodes. ```javascript registry.listNodes() → Promise ``` #### `.lookupIdentity(pubkey)` Look up an identity. ```javascript registry.lookupIdentity(pubkey: string) → Promise ``` #### `.listIdentities()` List all active identities. ```javascript registry.listIdentities() → Promise ``` #### `.connectToEnclave(enclaveId)` Resolve an enclave and create a `NodeClient` pointing to its hosting node. ```javascript registry.connectToEnclave(enclaveId: string) → Promise ``` Returns `null` if the enclave is not found or has no endpoints. ```javascript const client = await registry.connectToEnclave(enclaveId) if (client) { const sth = await client.getSTH() console.log('Tree size:', sth.t) } ``` #### `.submitCommit(commit)` Submit a commit to the registry node. ```javascript registry.submitCommit(commit: Object) → Promise ``` #### `.query(filter)` Query events on the registry. ```javascript registry.query(filter: Object) → Promise ``` #### `.pingLiveness()` ```javascript registry.pingLiveness() → Promise ``` *** ### wallet.js Utilities for converting ENC secp256k1 x-only public keys to EVM (Ethereum) addresses. #### The Y-Parity Problem ENC uses x-only public keys (32 bytes). An x-coordinate corresponds to two possible full public keys (even-y and odd-y), which produce two different EVM addresses. These functions help resolve the ambiguity. #### `encPubToEvmAddress(encPubHex)` Convert an ENC x-only public key to both possible EVM addresses. ```javascript encPubToEvmAddress(encPubHex: string) → [string, string] // Returns [evenYAddress, oddYAddress] ``` Each address is a checksumless `0x`-prefixed 40-hex-char Ethereum address, derived via `keccak256(uncompressedPubkey)`. ```javascript import { encPubToEvmAddress } from '@enc-protocol/client/wallet.js' const [even, odd] = encPubToEvmAddress('abcd1234...') console.log(even) // '0x...' — address assuming even y-coordinate console.log(odd) // '0x...' — address assuming odd y-coordinate ``` #### `resolveEvmAddress(encPubHex, rpcUrl)` Resolve the correct EVM address by checking on-chain balances. ```javascript resolveEvmAddress(encPubHex: string, rpcUrl: string) → Promise ``` Calls `eth_getBalance` on both possible addresses. Returns the one with the higher balance. Falls back to the even-y address on error or equal balances. ```javascript import { resolveEvmAddress } from '@enc-protocol/client/wallet.js' const addr = await resolveEvmAddress(pubHex, 'https://eth.llamarpc.com') console.log(addr) // '0x...' — the address with higher balance ``` *** ### Complete Example: Identity to Proof Verification End-to-end flow using the high-level SDK: create identity, create enclave, submit events, verify proofs. ```javascript import { createIdentity, createManifestCommit, createCommit, createNodeClient, createSessionManager, verifyNodeReceipt, } from '@enc-protocol/client' import { verifySTH, hexToBytes, verifySession } from '@enc-protocol/core/crypto.js' import { verifyEvent } from '@enc-protocol/core/event.js' import { verify, wireToProof, buildRBACKey, decodeRoleBitmask } from '@enc-protocol/core/smt.js' import { verifyInclusionProof } from '@enc-protocol/core/ct.js' import { isOwner } from '@enc-protocol/core/rbac.js' const NODE = 'https://your-node.example.com' // ── 1. Create identity ── const id = createIdentity() console.log('Public key:', id.publicKeyHex) // ── 2. Create enclave ── const manifest = JSON.stringify({ enc_v: 2, nonce: Date.now(), RBAC: { use_temp: 'none', schema: [ { event: 'post', role: 'owner', ops: ['C', 'U', 'D'] }, { event: '*', role: 'Public', ops: ['R'] }, ], states: [], traits: ['owner(0)'], initial_state: { owner: [id.publicKeyHex] }, }, }) const manifestCommit = createManifestCommit(id, manifest) const client = createNodeClient(NODE) const createResult = await client.submitCommit(manifestCommit) const enclaveId = manifestCommit.enclave const seqPubHex = createResult.sequencer console.log('Enclave:', enclaveId) // ── 3. Submit a post ── const postCommit = createCommit(id, enclaveId, 'post', JSON.stringify({ body: 'hello world' })) const receipt = await client.submitCommit(postCommit) // Verify the receipt const receiptValid = verifyNodeReceipt(receipt, seqPubHex) console.log('Receipt valid:', receiptValid) // ── 4. Set up encrypted client ── const sm = createSessionManager(id.privateKey, { duration: 3600 }) const encClient = new (await import('@enc-protocol/client/http.js')).NodeClient(NODE, { enclaveId, identityPubHex: id.publicKeyHex, seqPubHex, sessionManager: sm, }) console.log('Encrypted mode:', encClient.encrypted) // true // ── 5. Verify Signed Tree Head ── const sth = await client.getSTH(enclaveId) const sthValid = verifySTH(sth.t, sth.ts, hexToBytes(sth.r), sth.sig, hexToBytes(seqPubHex)) console.log('STH valid:', sthValid, '| Tree size:', sth.t) // ── 6. Pull events and verify each ── const pullResult = await client.pull(-1, { enclave: enclaveId, limit: 100 }) for (const event of pullResult.events) { const ok = verifyEvent(event) console.log(` seq=${event.seq} type=${event.type} verified=${ok}`) } // ── 7. Verify inclusion proof ── const inclResult = await encClient.getInclusion(0) if (inclResult.ct_proof) { const path = inclResult.ct_proof.p.map(h => hexToBytes(h)) const leafHash = hexToBytes(inclResult.leaf_hash) const inclValid = verifyInclusionProof(leafHash, 0, sth.t, path, hexToBytes(sth.r)) console.log('Inclusion proof valid:', inclValid) } // ── 8. Verify RBAC state proof ── const stateResult = await encClient.getState(`rbac:${id.publicKeyHex}`) if (stateResult.proof) { const proof = wireToProof(stateResult.proof) const smtRoot = hexToBytes(stateResult.smt_root) const proofValid = verify(proof, smtRoot) console.log('State proof valid:', proofValid) if (proof.value) { const bitmask = decodeRoleBitmask(proof.value) console.log('Is owner:', isOwner(bitmask)) } } ``` ### Encrypted vs Plaintext Summary | Feature | Plaintext | Encrypted (ECDH) | | -------------- | -------------------- | ----------------------------------- | | `submitCommit` | Always plaintext | Always plaintext | | `query` | Direct JSON filter | Session-encrypted filter + response | | `pull` | Direct JSON | Session-encrypted | | `info` | Not available | Session-encrypted | | `getSTH` | Always plaintext GET | Always plaintext GET | | `getInclusion` | Plaintext POST | Session-encrypted | | `getState` | Plaintext POST | Session-encrypted | | `pingLiveness` | Always OPTIONS | Always OPTIONS | ECDH encryption uses: * **Request label:** `'enc:query'` (HKDF info) * **Response label:** `'enc:response'` (HKDF info) * **Wire format:** `"session_hex.ciphertext_base64"` in the `content` field * **Key derivation:** `deriveSignerPriv(sessionPriv, sessionPub, seqPub, enclaveId)` then `ecdh(signerPriv, seqPub)` then `deriveKey(shared, label)` ## @enc-protocol/core — API Reference Complete API reference for `@enc-protocol/core`, the protocol primitives package for the ENC Protocol. All code is generated from the Lean 4 DSL and formally verified. Do not edit the source files directly. ### Installation ```bash npm install @enc-protocol/core --registry https://npm-registry.ocrybit.workers.dev/ ``` > Published to the ENC registry at `https://npm-registry.ocrybit.workers.dev/`. To skip the `--registry` flag, set the scope once: `npm config set @enc-protocol:registry https://npm-registry.ocrybit.workers.dev/`. ### Package Structure ``` @enc-protocol/core index.js Re-exports everything from all modules types.js Protocol constants and enumerations crypto.js Cryptographic operations (secp256k1, SHA-256, XChaCha20, ECDH) event.js Commit construction, signing, verification rbac.js Role-based access control bitmask operations smt.js Sparse Merkle Tree (168-bit depth) ct.js Certificate Transparency tree (RFC 9162) manifest-validator.js Manifest RBAC structure validation snapshot.js .enc portable snapshot container format dm-ratchet.js Confidentiality plugin — DM ratchet (+ NIP-44) group-ratchet.js Confidentiality plugin — group ratchet mls-lazy.js Confidentiality plugin — lazy-MLS group tree ecdh-envelope.js Confidentiality plugin — notice / invite handoff identity-aead.js Confidentiality plugin — owner-only private content aggregator.js WebSocket hub routing core *-wasm.js WASM-kernel-backed twins (byte-identical output) ``` Each module is importable individually: ```javascript import { generateKeypair } from '@enc-protocol/core/crypto.js' import { mkCommit } from '@enc-protocol/core/event.js' ``` Or import everything from the barrel: ```javascript import { generateKeypair, mkCommit, Context, SparseMerkleTree } from '@enc-protocol/core' ``` *** ### types.js Protocol constants and enumerations. All exports are `Object.freeze`-d. #### `Context` RBAC context roles used in schema permission evaluation. ```javascript import { Context } from '@enc-protocol/core/types.js' Context.Self // 'Self' — the event author is the identity being checked Context.Sender // 'Sender' — the identity that submitted the commit Context.Public // 'Public' — any identity, including unauthenticated ``` #### `Op` RBAC operations. Prefix `_` denotes explicit denial (overrides grant). ```javascript import { Op } from '@enc-protocol/core/types.js' // Grant operations Op.C // 'C' — Create Op.R // 'R' — Read Op.U // 'U' — Update Op.D // 'D' — Delete Op.P // 'P' — Push (append to collection) Op.N // 'N' — Notify (receive real-time events) // Deny operations (override grants) Op._C // '_C' — Deny Create Op._R // '_R' — Deny Read Op._U // '_U' — Deny Update Op._D // '_D' — Deny Delete Op._P // '_P' — Deny Push Op._N // '_N' — Deny Notify ``` #### `ACEventType` Access control event types. Frozen array of 13 strings. ```javascript import { ACEventType } from '@enc-protocol/core/types.js' ACEventType // ['Manifest', 'Grant', 'Revoke', 'Move', 'Transfer', // 'Gate', 'Shared', 'Own', 'AC_Bundle', // 'Pause', 'Resume', 'Terminate', 'Migrate'] ``` #### `EventStatus` Possible statuses for events in the state tree. ```javascript import { EventStatus } from '@enc-protocol/core/types.js' EventStatus.Active // 'Active' EventStatus.Deleted // 'Deleted' EventStatus.Updated // 'Updated' ``` #### `SMTNamespace` Namespace prefixes for Sparse Merkle Tree keys. ```javascript import { SMTNamespace } from '@enc-protocol/core/types.js' SMTNamespace.RBAC // 'RBAC' — identity role bitmasks SMTNamespace.EventStatus // 'EventStatus' — event deletion/update status SMTNamespace.KVState // 'KVState' — key-value state entries ``` #### `LifecycleState` Enclave lifecycle states. ```javascript import { LifecycleState } from '@enc-protocol/core/types.js' LifecycleState.Active // 'Active' LifecycleState.Paused // 'Paused' LifecycleState.Terminated // 'Terminated' LifecycleState.Migrating // 'Migrating' ``` *** ### crypto.js Cryptographic operations built on `@noble/curves` (secp256k1), `@noble/hashes` (SHA-256), and `@noble/ciphers` (XChaCha20-Poly1305). All functions are pure and deterministic except `generateKeypair()`, `generateSession()`, and `encrypt()` which use `randomBytes`. #### Domain Separation Constants Used as single-byte prefixes in domain-separated hashes. ```javascript import { DOMAIN_CT_LEAF, // 0 — Certificate Transparency leaf hash prefix DOMAIN_CT_NODE, // 1 — Certificate Transparency node hash prefix DOMAIN_COMMIT, // 16 — Commit hash prefix DOMAIN_EVENT, // 17 — Event hash prefix DOMAIN_ENCLAVE, // 18 — Enclave ID hash prefix DOMAIN_SMT_LEAF, // 32 — SMT leaf hash prefix DOMAIN_SMT_NODE, // 33 — SMT node hash prefix } from '@enc-protocol/core/crypto.js' ``` #### `SMT_EMPTY_HASH` The SHA-256 hash of empty input. Used as the default hash for empty SMT nodes and empty events roots. ```javascript import { SMT_EMPTY_HASH } from '@enc-protocol/core/crypto.js' // Uint8Array(32) — equals sha256(new Uint8Array(0)) // Hex: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ``` *** #### Key Generation & Derivation ##### `generateKeypair()` Generate a random secp256k1 keypair. ```javascript generateKeypair() → { privateKey: Uint8Array(32), publicKey: Uint8Array(32) } ``` * `privateKey` — 32 random bytes (valid secp256k1 scalar) * `publicKey` — x-only public key (32 bytes, no parity prefix) ```javascript import { generateKeypair, bytesToHex } from '@enc-protocol/core/crypto.js' const kp = generateKeypair() console.log(bytesToHex(kp.publicKey)) // 64 hex chars ``` ##### `derivePublicKey(privateKey)` Derive the x-only public key from a private key. ```javascript derivePublicKey(privateKey: Uint8Array) → Uint8Array(32) ``` Internally computes the compressed public key (33 bytes) and strips the parity prefix byte, returning the 32-byte x coordinate. ```javascript import { derivePublicKey, hexToBytes } from '@enc-protocol/core/crypto.js' const priv = hexToBytes('deadbeef...') // 32 bytes const pub = derivePublicKey(priv) // 32 bytes, x-only ``` *** #### Encoding Utilities ##### `bytesToHex(bytes)` Convert a `Uint8Array` to a lowercase hex string. ```javascript bytesToHex(bytes: Uint8Array) → string ``` ```javascript bytesToHex(new Uint8Array([0xca, 0xfe])) // 'cafe' ``` ##### `hexToBytes(hex)` Convert a hex string to a `Uint8Array`. Accepts optional `0x` prefix. ```javascript hexToBytes(hex: string) → Uint8Array ``` ```javascript hexToBytes('cafe') // Uint8Array [0xca, 0xfe] hexToBytes('0xcafe') // Uint8Array [0xca, 0xfe] ``` *** #### Hash Functions ##### `sha256Hash(data)` Raw SHA-256 hash. ```javascript sha256Hash(data: Uint8Array) → Uint8Array(32) ``` ##### `sha256Str(str)` SHA-256 of a UTF-8 encoded string. ```javascript sha256Str(str: string) → Uint8Array(32) ``` ```javascript const hash = sha256Str('hello world') // Uint8Array(32) ``` ##### `domainHash(prefix, data)` Domain-separated hash: `sha256(prefix_byte || data)`. ```javascript domainHash(prefix: number, data: Uint8Array) → Uint8Array(32) ``` Used internally by all tree hash functions. The single-byte prefix provides collision resistance between different hash domains. ##### `taggedHash(tag, data)` BIP-340 tagged hash: `sha256(sha256(tag) || sha256(tag) || data)`. ```javascript taggedHash(tag: string, data: Uint8Array) → Uint8Array(32) ``` Used in session token generation for challenge computation. *** #### Tree Hash Functions ##### `ctLeafHash(eventsRoot, stateHash)` Certificate Transparency leaf hash. ```javascript ctLeafHash(eventsRoot: Uint8Array, stateHash: Uint8Array) → Uint8Array(32) ``` Computes `sha256(0x00 || eventsRoot || stateHash)`. ##### `ctNodeHash(left, right)` Certificate Transparency internal node hash. ```javascript ctNodeHash(left: Uint8Array, right: Uint8Array) → Uint8Array(32) ``` Computes `sha256(0x01 || left || right)`. ##### `smtLeafHash(key, value)` Sparse Merkle Tree leaf hash. ```javascript smtLeafHash(key: Uint8Array, value: Uint8Array) → Uint8Array(32) ``` Computes `sha256(0x20 || key || value)`. ##### `smtNodeHash(left, right)` Sparse Merkle Tree internal node hash. ```javascript smtNodeHash(left: Uint8Array, right: Uint8Array) → Uint8Array(32) ``` Computes `sha256(0x21 || left || right)`. *** #### Protocol Hash Functions ##### `computeContentHash(content)` Hash event content (UTF-8 string). ```javascript computeContentHash(content: string) → Uint8Array(32) ``` Equivalent to `sha256Hash(new TextEncoder().encode(content))`. ##### `computeCommitHash(contentHash, enclave, from, type, exp, tags)` Compute the commit hash that gets signed. ```javascript computeCommitHash( contentHash: string, // hex string of content hash enclave: string, // enclave ID (64 hex) from: string, // author pubkey (64 hex) type: string, // event type exp: number, // expiration timestamp (ms) tags: string[][] // tag array ) → Uint8Array(32) ``` Internally computes `sha256(JSON.stringify([16, enclave, from, type, contentHash, exp, encodedTags]))`. ##### `computeEventHash(seq, sequencer, sig1, timestamp)` Compute the event hash (signed by the sequencer). ```javascript computeEventHash( seq: number, // sequence number sequencer: string, // sequencer pubkey (64 hex) sig1: string, // author signature (128 hex) timestamp: number // event timestamp (ms) ) → Uint8Array(32) ``` Internally computes `sha256(JSON.stringify([1, seq, sequencer, sig1, timestamp]))`. Note: The domain prefix `1` in the JSON array serves the same role as `DOMAIN_EVENT` but is embedded in the serialization format rather than prepended as a byte. ##### `computeEnclaveId(from, type, contentHash, tags)` Derive a deterministic enclave ID from a manifest commit. ```javascript computeEnclaveId( from: string, // creator pubkey (64 hex) type: string, // 'Manifest' contentHash: string, // hex content hash tags: string[][] // tags ) → string // 64 hex chars ``` Returns a hex string (not bytes). The enclave ID is deterministic given the same inputs. ##### `computeEventId(sig2Hex)` Compute event ID from the sequencer's signature. ```javascript computeEventId(sig2Hex: string) → string // 64 hex chars ``` Returns `sha256(hexToBytes(sig2Hex))` as a hex string. The event ID is the hash of the sequencer signature, making it globally unique. ##### `computeEventsRoot(eventIds)` Compute Merkle root from an array of event IDs. ```javascript computeEventsRoot(eventIds: string[]) → Uint8Array(32) ``` * Empty array returns `SMT_EMPTY_HASH` * Single event returns the event ID bytes * Multiple events are combined into a binary Merkle tree using `ctNodeHash` *** #### Schnorr Signatures (BIP-340) ##### `schnorrSign(msgHash, privateKey)` Create a BIP-340 Schnorr signature with deterministic nonce (zero auxiliary bytes). ```javascript schnorrSign(msgHash: Uint8Array, privateKey: Uint8Array) → Uint8Array(64) ``` The zero aux bytes (`new Uint8Array(32)`) ensure cross-implementation reproducibility. This is a deliberate deviation from standard BIP-340 which uses random aux bytes. ```javascript const sig = schnorrSign(sha256Hash(message), privateKey) // sig is 64 bytes: 32-byte R x-coordinate || 32-byte s scalar ``` ##### `schnorrVerify(msgHash, signature, publicKey)` Verify a BIP-340 Schnorr signature. ```javascript schnorrVerify( msgHash: Uint8Array, // 32 bytes signature: Uint8Array, // 64 bytes publicKey: Uint8Array // 32 bytes (x-only) ) → boolean ``` Returns `false` on any error (never throws). *** #### Session Management ##### `generateSession(idPriv, duration?)` Generate a session token and derived session keypair. ```javascript generateSession( idPriv: Uint8Array, // 32-byte identity private key duration?: number // session duration in seconds (default: 7200, max: 7200) ) → { session: string, // 136 hex chars: r(64) + sessionPub(64) + expires(8) sessionPriv: Uint8Array, // 32-byte session private key expires: number // Unix timestamp (seconds) when session expires } ``` The session token encodes: * Bytes 0-31 (hex 0-63): `r` — random point x-coordinate * Bytes 32-63 (hex 64-127): `sessionPub` — derived session public key * Bytes 64-67 (hex 128-135): `expires` — big-endian uint32 expiration timestamp The session private key is derived via EC point arithmetic: `sessionPriv = k + e * d` where `k` is random, `e` is a BIP-340 tagged challenge hash, and `d` is the identity private key. ```javascript import { generateKeypair, generateSession, bytesToHex } from '@enc-protocol/core/crypto.js' const kp = generateKeypair() const { session, sessionPriv, expires } = generateSession(kp.privateKey, 3600) console.log(session.length) // 136 console.log(expires) // Unix seconds, ~1h from now ``` ##### `verifySession(session, fromPubHex)` Verify a session token was created by the given identity. ```javascript verifySession( session: string, // 136 hex char session token fromPubHex: string // 64 hex char identity public key ) → string | null ``` Returns `null` on success. Returns an error string on failure: * `'INVALID_SESSION: token must be 136 hex chars'` * `'SESSION_EXPIRED: token expired'` * `'INVALID_SESSION: expires too far in future (max 2h)'` * `'INVALID_SESSION: session_pub verification failed'` Clock skew tolerance: 60 seconds. ```javascript import { generateKeypair, generateSession, verifySession, bytesToHex } from '@enc-protocol/core/crypto.js' const kp = generateKeypair() const { session } = generateSession(kp.privateKey) const pubHex = bytesToHex(kp.publicKey) const err = verifySession(session, pubHex) console.log(err) // null (valid) ``` *** #### ECDH Encryption ##### `ecdh(privKey, pubKey)` Compute an ECDH shared secret. ```javascript ecdh(privKey: Uint8Array, pubKey: Uint8Array) → Uint8Array(32) ``` Uses secp256k1 point multiplication. The shared secret is the x-coordinate of the resulting point (32 bytes). ##### `deriveKey(shared, label)` Derive an encryption key from a shared secret using HKDF-SHA256. ```javascript deriveKey(shared: Uint8Array, label: string) → Uint8Array(32) ``` * `shared` — the ECDH shared secret * `label` — HKDF info string (e.g. `'enc:query'`, `'enc:response'`) No salt is used (`undefined`). Output is 32 bytes. ##### `encrypt(key, plaintext)` Encrypt a string with XChaCha20-Poly1305. ```javascript encrypt(key: Uint8Array, plaintext: string) → string // base64 ``` Returns base64-encoded `nonce(24) || ciphertext || tag(16)`. The 24-byte nonce is randomly generated. ##### `decrypt(key, ciphertextB64)` Decrypt a base64-encoded XChaCha20-Poly1305 ciphertext. ```javascript decrypt(key: Uint8Array, ciphertextB64: string) → string ``` Splits the decoded bytes into 24-byte nonce and remaining ciphertext, decrypts, and returns the UTF-8 string. ```javascript import { ecdh, deriveKey, encrypt, decrypt, generateKeypair } from '@enc-protocol/core/crypto.js' const alice = generateKeypair() const bob = generateKeypair() const sharedA = ecdh(alice.privateKey, bob.publicKey) const sharedB = ecdh(bob.privateKey, alice.publicKey) // sharedA === sharedB (same shared secret) const key = deriveKey(sharedA, 'my-app:messages') const ct = encrypt(key, 'hello bob') const pt = decrypt(key, ct) console.log(pt) // 'hello bob' ``` *** #### Signer Derivation Used for ECDH-encrypted communication with the node. Derives per-session, per-enclave signer keys. ##### `deriveSignerPriv(sessionPriv, sessionPub, seqPub, enclaveId)` Derive a signer private key from session credentials. ```javascript deriveSignerPriv( sessionPriv: Uint8Array, // 32-byte session private key sessionPub: Uint8Array, // 32-byte session public key (x-only) seqPub: Uint8Array, // 32-byte sequencer public key (x-only) enclaveId: string // 64 hex char enclave ID ) → Uint8Array(32) ``` Computes `t = sha256(sessionPub || seqPub || enclaveBytes) mod n`, then `signerPriv = adjustedSessionPriv + t mod n`. The y-parity of the session public key point determines whether `sessionPriv` is negated. ##### `deriveSignerPub(sessionPub, seqPub, enclaveId)` Derive the corresponding signer public key (without needing the private key). ```javascript deriveSignerPub( sessionPub: Uint8Array, // 32-byte session public key (x-only) seqPub: Uint8Array, // 32-byte sequencer public key (x-only) enclaveId: string // 64 hex char enclave ID ) → Uint8Array(32) ``` Computes the same `t` value and returns `sessionPubPoint + t*G` as an x-only public key. *** #### Signed Tree Head (STH) ##### `signSTH(t, ts, rootHash, seqPriv)` Sign a tree head. ```javascript signSTH( t: number, // tree size (number of leaves) ts: number, // timestamp rootHash: Uint8Array, // 32-byte Merkle root seqPriv: Uint8Array // 32-byte sequencer private key ) → string // 128 hex char Schnorr signature ``` The signed message is: `"enc:sth:" || bigEndian64(t) || bigEndian64(ts) || rootHash`. ##### `verifySTH(t, ts, rootHash, sigHex, seqPub)` Verify a signed tree head. ```javascript verifySTH( t: number, // tree size ts: number, // timestamp rootHash: Uint8Array, // 32-byte Merkle root sigHex: string, // 128 hex char signature seqPub: Uint8Array // 32-byte sequencer public key ) → boolean ``` Returns `false` on any error (never throws). ```javascript import { verifySTH, hexToBytes } from '@enc-protocol/core/crypto.js' const sth = await (await fetch(`https://your-node.example.com/${enclaveId}/sth`)).json() const valid = verifySTH(sth.t, sth.ts, hexToBytes(sth.r), sth.sig, hexToBytes(seqPubHex)) ``` *** ### event.js Commit construction, signing, and verification. Commits are the unit of write in the ENC Protocol. A commit becomes an event after the sequencer co-signs it. #### Constants ```javascript import { MAX_EXP_WINDOW, // 3600000 (1 hour in ms) — maximum expiration window CLOCK_SKEW_TOLERANCE, // 60000 (1 minute in ms) — clock skew tolerance acEventTypes, // same as ACEventType from types.js lifecycleEventTypes, // ['Pause', 'Resume', 'Terminate', 'Migrate'] } from '@enc-protocol/core/event.js' ``` #### `mkCommit(enclave, from, type, content, exp, tags)` Create an unsigned commit object. ```javascript mkCommit( enclave: string, // enclave ID (64 hex) from: string, // author public key (64 hex) type: string, // event type (e.g. 'post', 'Grant', 'Manifest') content: string, // event content (JSON string) exp: number, // expiration timestamp in ms (epoch) tags: string[][] // tag array (e.g. [['t', 'post'], ['p', '']]) ) → { enclave: string, from: string, type: string, content: string, content_hash: string, // hex SHA-256 of content hash: string, // hex commit hash (to be signed) exp: number, tags: string[][] } ``` The `hash` field is the value that gets signed by the author. It is computed as: `sha256(JSON.stringify([16, enclave, from, type, contentHash, exp, encodedTags]))`. ```javascript import { mkCommit, signCommit } from '@enc-protocol/core/event.js' import { generateKeypair, bytesToHex } from '@enc-protocol/core/crypto.js' const kp = generateKeypair() const pub = bytesToHex(kp.publicKey) const commit = mkCommit( enclaveId, pub, 'post', JSON.stringify({ body: 'hello world' }), Date.now() + 300000, // expires in 5 minutes [] ) ``` #### `signCommit(commit, privateKey)` Sign a commit, adding the `sig` field. ```javascript signCommit(commit: Object, privateKey: Uint8Array) → Object ``` Returns a new object with all commit fields plus `sig` (128 hex char Schnorr signature over the commit `hash`). ```javascript const signed = signCommit(commit, kp.privateKey) // signed.sig is a 128 hex char string ``` #### `verifyCommit(commit)` Verify that a commit's signature matches its hash and `from` pubkey. ```javascript verifyCommit(commit: Object) → boolean ``` Returns `false` on any error (never throws). Requires `commit.hash`, `commit.sig`, and `commit.from`. #### `verifyEvent(event)` Verify both the author signature (sig) and sequencer co-signature (seq\_sig) on a finalized event. ```javascript verifyEvent(event: Object) → boolean ``` First verifies the commit signature, then verifies the sequencer signature over the event hash. #### `mkManifestCommit(from, manifestContent, exp, tags)` Create a manifest commit with a deterministically derived enclave ID. ```javascript mkManifestCommit( from: string, // creator pubkey (64 hex) manifestContent: string, // manifest JSON string exp: number, // expiration timestamp (ms) tags: string[][] // tags ) → Object // commit with derived enclave field ``` The `enclave` field is set to `computeEnclaveId(from, 'Manifest', contentHash, tags)`. This means the enclave ID is deterministic: the same creator, manifest content, and tags always produce the same enclave ID. ```javascript import { mkManifestCommit, signCommit } from '@enc-protocol/core/event.js' const manifest = JSON.stringify({ enc_v: 2, nonce: Date.now(), RBAC: { use_temp: 'none', schema: [ { event: 'post', role: 'owner', ops: ['C', 'U', 'D'] }, { event: '*', role: 'Public', ops: ['R'] }, ], states: [], traits: ['owner(0)'], initial_state: { owner: [myPubHex] }, }, }) const commit = mkManifestCommit(myPubHex, manifest, Date.now() + 300000, []) const signed = signCommit(commit, myPrivateKey) // signed.enclave is the derived enclave ID ``` #### `finalizeCommit(commit, timestamp, seq, sequencerPubHex, sequencerKey)` Finalize a commit into a full event (sequencer-side operation). ```javascript finalizeCommit( commit: Object, // signed commit timestamp: number, // event timestamp (ms) seq: number, // sequence number sequencerPubHex: string, // sequencer public key (64 hex) sequencerKey: Uint8Array // sequencer private key ) → Object // event with seq, timestamp, sequencer, seq_sig, id ``` Adds the sequencer co-signature (`seq_sig`) and computes the event ID from it. #### `mkReceipt(event)` Extract a receipt from a finalized event. ```javascript mkReceipt(event: Object) → { seq: number, id: string, hash: string, timestamp: number, sig: string, seq_sig: string, sequencer: string } ``` #### `validateCommitStructure(commit)` Validate a commit's hash matches its fields. ```javascript validateCommitStructure(commit: Object) → string | undefined ``` Returns an error string (`'missing required fields'` or `'hash mismatch'`) or `undefined` on success. #### Type Checking Functions ##### `isACEvent(type)` Check if an event type is an access control event. ```javascript isACEvent(type: string) → boolean ``` Uses `startsWith` matching, so `'Grant'` and `'Grant(admin)'` both return `true`. ##### `isLifecycleEvent(type)` Check if an event type is a lifecycle event. ```javascript isLifecycleEvent(type: string) → boolean // true for: 'Pause', 'Resume', 'Terminate', 'Migrate' ``` ##### `canUpdateDelete(type)` Check if the type is `'Update'` or `'Delete'`. ```javascript canUpdateDelete(type: string) → boolean ``` *** ### rbac.js Role-based access control using bitmask operations. Each identity has a single `bigint` bitmask encoding both a state (low 8 bits) and trait flags (bits 8+). #### Bitmask Layout ``` Bit: 255 ... 10 9 8 7 6 5 4 3 2 1 0 [--- traits ---] [------ state (0-255) ------] ^ | OWNER_BIT (bit 8, = FIRST_TRAIT_BIT) ``` * **Bits 0-7 (STATE\_MASK = 0xFF)**: State value (0 = outsider, 1-255 = named states from schema) * **Bit 8 (OWNER\_BIT)**: Owner trait (always the first trait) * **Bits 9+**: Custom traits defined in the manifest schema #### Constants ```javascript import { STATE_MASK, // 0xFFn — masks low 8 bits FIRST_TRAIT_BIT, // 8 — first trait bit position OUTSIDER_STATE, // 0n — outsider state value EMPTY_ROLES, // 0n — no roles assigned OWNER_BIT, // 8 — same as FIRST_TRAIT_BIT } from '@enc-protocol/core/rbac.js' ``` Additional internal constants: ```javascript acEventTypesWithState // same 13 AC event types lifecycleOnlyACEvents // ['Pause', 'Resume', 'Terminate', 'Migrate'] updateDeleteTypes // ['Update', 'Delete'] kvEventTypes // ['Shared', 'Own', 'Gate'] ``` #### State Functions ##### `getState(bitmask)` Extract the state value from the low 8 bits. ```javascript getState(bitmask: bigint) → number // 0-255 ``` ```javascript getState(0x100n) // 0 (outsider, but has trait bit 8 set) getState(0x103n) // 3 ``` ##### `setState(bitmask, stateValue)` Set the state value, preserving trait bits. ```javascript setState(bitmask: bigint, stateValue: number) → bigint ``` ```javascript setState(0x100n, 5) // 0x105n — keeps owner bit, sets state to 5 ``` ##### `isOutsider(bitmask)` Check if the identity has state 0 (outsider/no membership). ```javascript isOutsider(bitmask: bigint) → boolean ``` #### Trait Functions ##### `hasTrait(bitmask, traitBit)` Check if a trait bit is set. ```javascript hasTrait(bitmask: bigint, traitBit: number) → boolean ``` ```javascript hasTrait(0x100n, 8) // true (OWNER_BIT) hasTrait(0x100n, 9) // false ``` ##### `setTrait(bitmask, traitBit)` Set a trait bit. ```javascript setTrait(bitmask: bigint, traitBit: number) → bigint ``` ##### `clearTrait(bitmask, traitBit)` Clear a trait bit. ```javascript clearTrait(bitmask: bigint, traitBit: number) → bigint ``` ##### `isOwner(mask)` Check if the owner trait (bit 8) is set. ```javascript isOwner(mask: bigint) → boolean ``` ##### `bestRank(mask, traitRanks)` Find the lowest (best) rank among all traits the identity holds. ```javascript bestRank(mask: bigint, traitRanks: [number, number][]) → number | 'Infinity' ``` Each entry in `traitRanks` is `[traitBit, rank]`. Returns the minimum rank for traits that are set, or `'Infinity'` if none match. ##### `clearAllTraits(bitmask)` Clear all trait bits, keeping only the state. ```javascript clearAllTraits(bitmask: bigint) → bigint ``` ```javascript clearAllTraits(0x1FFn) // 0xFFn (all traits cleared, state preserved) ``` #### Alias Functions These are aliases for the trait functions, using `bit` parameter name: ```javascript setRoleBit(bitmask, bit) // alias for setTrait clearRoleBit(bitmask, bit) // alias for clearTrait hasBit(bitmask, bit) // alias for hasTrait ``` **Note:** Prefer `setTrait` / `clearTrait` / `hasTrait` directly; the `*Bit` aliases are retained only for backwards compatibility. #### Type Checking Functions ##### `isContext(role)` Check if a role name is a context role (Self, Sender, or Public). ```javascript isContext(role: string) → boolean ``` ##### `isACEventType(type)` Check if a type starts with any AC event type string. ```javascript isACEventType(type: string) → boolean ``` ##### `isUpdateDeleteType(type)` Check if the type is `'Update'` or `'Delete'`. ```javascript isUpdateDeleteType(type: string) → boolean ``` ##### `isKVEventType(type)` Check if the type starts with `'Shared('`, `'Own('`, or `'Gate('`. ```javascript isKVEventType(type: string) → boolean ``` #### Schema Functions ##### `schemaPermits(schema, roleName, eventType, op)` Check if a schema grants an operation to a role for an event type. ```javascript schemaPermits( schema: { event: string, role: string, ops: string[] }[], roleName: string, eventType: string, op: string ) → boolean ``` Matches `event === eventType` or `event === '*'` (wildcard). ##### `isAuthorized(schema, bitmask, eventType, op, isSelf?, isSender?, stateNames?, traitNames?)` The main authorization function. Evaluates all applicable roles and returns whether the operation is permitted. ```javascript isAuthorized( schema: { event: string, role: string, ops: string[] }[], bitmask: bigint, // identity's role bitmask eventType: string, // event type being checked op: string, // operation (e.g. 'C', 'R') isSelf?: boolean, // is the identity the event author? (default: false) isSender?: boolean, // is the identity the commit sender? (default: false) stateNames?: string[], // ordered state names from manifest (default: []) traitNames?: string[] // ordered trait names from manifest (default: []) ) → boolean ``` Evaluation order: 1. Resolve state name from bitmask low 8 bits (index into `stateNames`, 0 = OUTSIDER) 2. Collect ops from the state role 3. Collect ops from each held trait 4. If `isSelf`, collect ops from `'Self'` role 5. If `isSender`, collect ops from `'Sender'` role 6. Always collect ops from `'Public'` and `'Any'` roles 7. Deny operations override grants (any `_X` removes `X`) ```javascript import { isAuthorized, OWNER_BIT, setTrait, setState } from '@enc-protocol/core/rbac.js' const schema = [ { event: 'post', role: 'owner', ops: ['C', 'U', 'D'] }, { event: '*', role: 'Public', ops: ['R'] }, ] // Owner with state 1 ('member') const ownerMask = setTrait(setState(0n, 1), OWNER_BIT) isAuthorized(schema, ownerMask, 'post', 'C', false, false, ['member'], ['owner']) // true — owner role grants C on 'post' isAuthorized(schema, 0n, 'post', 'R', false, false, [], []) // true — Public grants R on '*' isAuthorized(schema, 0n, 'post', 'C', false, false, [], []) // false — outsider has no C grant ``` ##### `getCustomRoleNames(schema)` Extract custom (non-context) role names from a schema. ```javascript getCustomRoleNames(schema: Object[]) → string[] ``` Filters out `'Self'`, `'Sender'`, `'Public'`, and `'Any'`. ##### `roleBitFromName(name, customRoles)` Get the trait bit position for a custom role name. ```javascript roleBitFromName(name: string, customRoles: string[]) → number | null ``` Returns `FIRST_TRAIT_BIT + indexOf(name)` or `null` if not found. #### `RBACState` Class Manages per-identity role bitmasks and event status. ```javascript import { RBACState } from '@enc-protocol/core/rbac.js' const state = new RBACState() ``` ##### `getRoles(identity)` Get the role bitmask for an identity. ```javascript state.getRoles(identity: string) → bigint // defaults to 0n ``` ##### `setRoles(identity, mask)` Set the entire role bitmask for an identity. ```javascript state.setRoles(identity: string, mask: bigint) ``` ##### `grantRole(identity, bit)` Set a single trait bit for an identity. ```javascript state.grantRole(identity: string, bit: number) ``` ##### `revokeRole(identity, bit)` Clear a single trait bit for an identity. ```javascript state.revokeRole(identity: string, bit: number) ``` ##### `revokeAll(identity)` Remove all roles for an identity (delete from map). ```javascript state.revokeAll(identity: string) ``` ##### `markDeleted(eventId)` Mark an event as deleted in event status tracking. ```javascript state.markDeleted(eventId: string) ``` ##### `markUpdated(targetId, updateId)` Mark an event as updated, storing the update event ID. ```javascript state.markUpdated(targetId: string, updateId: string) ``` ##### `getEventStatus(eventId)` Get an event's status. ```javascript state.getEventStatus(eventId: string) → 'Active' | 'Deleted' | string // Returns 'Active' if not tracked, 'Deleted' if deleted, or the update event ID ``` ##### `applyInitialState(initialState, schema, stateNames, traitNames)` Apply initial state from a manifest's `initial_state` field. ```javascript state.applyInitialState( initialState: { [roleName: string]: (string | { identity: string })[] }, schema: Object[], stateNames: string[], traitNames: string[] ) ``` *** ### smt.js Sparse Merkle Tree with 168-bit depth (21-byte keys). Provides authenticated state proofs for RBAC roles, event status, and key-value state. #### Constants ```javascript import { DEPTH, // 168 — tree depth in bits KEY_BYTES, // 21 — key size in bytes EVENT_STATUS_DELETED, // Uint8Array([0]) — sentinel value for deleted events } from '@enc-protocol/core/smt.js' ``` #### Key Building Functions All keys are 21 bytes: 1 namespace byte + 20 bytes from `sha256(rawKey)`. ##### `buildSMTKey(namespace, rawKey)` Build a generic SMT key. ```javascript buildSMTKey(namespace: number, rawKey: Uint8Array) → Uint8Array(21) ``` ##### `buildRBACKey(identityHex)` Build an RBAC state key for an identity. ```javascript buildRBACKey(identityHex: string) → Uint8Array(21) // namespace = SMTNamespace.RBAC ``` ##### `buildEventStatusKey(eventIdHex)` Build an event status key. ```javascript buildEventStatusKey(eventIdHex: string) → Uint8Array(21) // namespace = SMTNamespace.EventStatus ``` ##### `buildKVKey(kvKey, identity)` Build a key-value state key. ```javascript buildKVKey(kvKey: string, identity?: string) → Uint8Array(21) // namespace = SMTNamespace.KV // If identity provided: sha256(kvKey + identity); otherwise sha256(kvKey) ``` #### Wire Format Conversion ##### `proofToWire(proof)` Convert a proof object to wire format (hex strings). ```javascript proofToWire(proof: { key, value, bitmap, siblings }) → { k: string, // hex key v: string | null, // hex value (null for non-membership) b: string, // hex bitmap s: string[] // hex siblings } ``` ##### `wireToProof(wire)` Convert wire format back to a proof object (Uint8Arrays). ```javascript wireToProof(wire: { k, v, b, s }) → { key: Uint8Array, value: Uint8Array | null, bitmap: Uint8Array, siblings: Uint8Array[] } ``` #### Encoding Functions ##### `encodeRoleBitmask(bitmask)` Encode a bigint bitmask as 32 big-endian bytes for SMT storage. ```javascript encodeRoleBitmask(bitmask: bigint) → Uint8Array(32) ``` ##### `decodeRoleBitmask(bytes)` Decode 32 big-endian bytes back to a bigint bitmask. ```javascript decodeRoleBitmask(bytes: Uint8Array) → bigint ``` ##### `encodeEventStatus(status)` Encode an event status for SMT storage. ```javascript encodeEventStatus(status: string) → Uint8Array // 'Deleted' → Uint8Array([0]) // Otherwise → hexToBytes(status) (the update event ID) ``` #### `verify(proof, expectedRoot)` Verify an SMT membership or non-membership proof against a root hash. ```javascript verify( proof: { key: Uint8Array, value: Uint8Array | null, bitmap: Uint8Array, siblings: Uint8Array[] }, expectedRoot: Uint8Array ) → boolean ``` For membership proofs, `value` is non-null and the proof demonstrates the key-value pair exists in the tree. For non-membership proofs, `value` is null and the proof demonstrates the key is absent. The bitmap is a 21-byte array where each bit indicates whether a sibling exists at that depth. The siblings array contains only the non-empty siblings, in order from leaf to root. ```javascript import { verify, wireToProof } from '@enc-protocol/core/smt.js' import { hexToBytes } from '@enc-protocol/core/crypto.js' // From a node API response const proof = wireToProof(apiResponse.proof) const root = hexToBytes(apiResponse.smt_root) const valid = verify(proof, root) ``` #### `SparseMerkleTree` Class Full in-memory SMT implementation. ```javascript import { SparseMerkleTree } from '@enc-protocol/core/smt.js' const smt = new SparseMerkleTree() ``` ##### `getRoot()` Get the current root hash. ```javascript smt.getRoot() → Uint8Array(32) // Returns SMT_EMPTY_HASH when tree is empty ``` ##### `getRootHex()` Get the root hash as a hex string. ```javascript smt.getRootHex() → string // 64 hex chars ``` ##### `get(key)` Get the value for a key. ```javascript smt.get(key: Uint8Array) → Uint8Array | null ``` ##### `insert(key, value)` Insert or update a key-value pair. Recomputes the root. ```javascript smt.insert(key: Uint8Array, value: Uint8Array) ``` ##### `remove(key)` Remove a key. Recomputes the root. ```javascript smt.remove(key: Uint8Array) ``` ##### `prove(key)` Generate a membership or non-membership proof. ```javascript smt.prove(key: Uint8Array) → { key: Uint8Array, value: Uint8Array | null, bitmap: Uint8Array(21), siblings: Uint8Array[] } ``` ##### `serialize()` / `deserialize(data)` Serialize the tree to/from a JSON-compatible format. ```javascript smt.serialize() → { leaves: [string, string][] } // [keyHex, valueHex] pairs smt.deserialize(data: { leaves: [string, string][] }) ``` Note: `deserialize` is an instance method (not static) that clears and repopulates the tree. ##### `SparseMerkleTree.verify` Static reference to the module-level `verify` function. ```javascript SparseMerkleTree.verify(proof, expectedRoot) → boolean ``` ```javascript import { SparseMerkleTree, buildRBACKey, encodeRoleBitmask } from '@enc-protocol/core/smt.js' import { OWNER_BIT, setTrait, setState } from '@enc-protocol/core/rbac.js' const smt = new SparseMerkleTree() // Insert an owner role const key = buildRBACKey('abcd1234...') // 64 hex pubkey const mask = setTrait(setState(0n, 1), OWNER_BIT) smt.insert(key, encodeRoleBitmask(mask)) // Generate and verify proof const proof = smt.prove(key) const valid = SparseMerkleTree.verify(proof, smt.getRoot()) console.log(valid) // true ``` *** ### ct.js Certificate Transparency tree following RFC 9162. Provides append-only event log verification with inclusion and consistency proofs. #### `verifyInclusionProof(leafHash, leafIndex, treeSize, path, expectedRoot)` Verify that a leaf is included in a tree of a given size. ```javascript verifyInclusionProof( leafHash: Uint8Array, // 32-byte leaf hash leafIndex: number, // 0-based leaf index treeSize: number, // total number of leaves path: Uint8Array[], // proof path (array of 32-byte hashes) expectedRoot: Uint8Array // 32-byte expected root ) → boolean ``` Implements the RFC 9162 inclusion proof verification algorithm. ```javascript import { verifyInclusionProof } from '@enc-protocol/core/ct.js' import { hexToBytes } from '@enc-protocol/core/crypto.js' const sth = await (await fetch(`${nodeUrl}/${enclaveId}/sth`)).json() // Get inclusion proof from node API const inclProof = await (await fetch(`${nodeUrl}/inclusion`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ seq: 0 }), })).json() const path = inclProof.ct_proof.p.map(h => hexToBytes(h)) const leafHash = hexToBytes(inclProof.leaf_hash) const valid = verifyInclusionProof(leafHash, 0, sth.t, path, hexToBytes(sth.r)) ``` #### `verifyConsistencyProof(size1, size2, path, firstRoot, secondRoot)` Verify that a smaller tree is a prefix of a larger tree (append-only property). ```javascript verifyConsistencyProof( size1: number, // earlier tree size size2: number, // later tree size path: Uint8Array[], // consistency proof path firstRoot: Uint8Array, // 32-byte root at size1 secondRoot: Uint8Array // 32-byte root at size2 ) → boolean ``` Edge cases: * `size1 > size2` returns `false` * `size1 === 0` returns `true` (empty tree is prefix of everything) * `size1 === size2` requires `path.length === 1` with matching roots #### `verifyBundleMembership(eventIdHex, proof, expectedRootHex)` Verify that an event ID is part of a bundle's events root. ```javascript verifyBundleMembership( eventIdHex: string, // 64 hex char event ID proof: { ei: number, s: string[] }, // bundle membership proof expectedRootHex: string // 64 hex char expected events root ) → boolean ``` The proof contains: * `ei` — event index within the bundle * `s` — array of sibling hashes (hex strings) #### `bundleMembershipProof(eventIds, eventIndex)` Generate a bundle membership proof. ```javascript bundleMembershipProof( eventIds: string[], // array of event ID hex strings eventIndex: number // index of the event to prove ) → { ei: number, s: string[] } ``` Throws if `eventIndex >= eventIds.length`. #### `CTTree` Class Full in-memory Certificate Transparency tree. ```javascript import { CTTree } from '@enc-protocol/core/ct.js' const ct = new CTTree() ``` ##### `size` (getter) Number of leaves in the tree. ```javascript ct.size → number ``` ##### `getRoot()` / `getRootHex()` Get the current Merkle root. ```javascript ct.getRoot() → Uint8Array(32) ct.getRootHex() → string // 64 hex chars // Empty tree returns 64 zero bytes ``` ##### `append(eventsRoot, stateHash)` Append a new leaf (computed as `ctLeafHash(eventsRoot, stateHash)`). ```javascript ct.append(eventsRoot: Uint8Array, stateHash: Uint8Array) → number // leaf index ``` ##### `inclusionProof(leafIndex)` Generate an inclusion proof. ```javascript ct.inclusionProof(leafIndex: number) → { ts: number, // tree size at time of proof li: number, // leaf index p: string[] // proof path as hex strings } ``` Throws if `leafIndex >= tree size`. ##### `consistencyProof(size1, size2?)` Generate a consistency proof between two tree sizes. ```javascript ct.consistencyProof(size1: number, size2?: number) → { ts1: number, // first tree size ts2: number, // second tree size p: string[] // proof path as hex strings } ``` `size2` defaults to current tree size. Throws if sizes are out of range. ##### `serialize()` / `deserialize(data)` ```javascript ct.serialize() → string[] // array of hex leaf hashes ct.deserialize(data: string[]) // restore from serialized form ``` *** ### manifest-validator.js Static validation of an enclave manifest's RBAC structure against the protocol's naming and completeness rules. Generated from the Lean DSL and proven equivalent to the in-kernel validator. #### `validateManifest(manifestContent)` ```javascript validateManifest(manifestContent: Object) → string | null ``` Returns `null` if the manifest is valid, or a human-readable error string identifying the first violated rule. Accepts either a full manifest object or its `RBAC` sub-object, normalizing the wire forms (string-or-object states, `name(rank)` traits, v1 `schema` event entries) before checking. Rules enforced: * **Rule 5 (Reserved Keys)** — slot keys may not use the reserved `gate:` prefix or the key `lifecycle`. * **Rule 8 (Complete States)** — every state referenced by a move / grant / transfer scope must be declared (plus the implicit `OUTSIDER`). * **Rule 9 (Naming)** — states are `UPPER_CASE`, traits and slot keys are `lower_case`, and custom event names are lowercase or a known protocol event. ```javascript import { validateManifest } from '@enc-protocol/core/manifest-validator.js' validateManifest({ RBAC: { states: ['member'], traits: ['owner(0)'], schema: [] } }) // 'Rule 9 (Naming): State "member" must be UPPER_CASE' ``` *** ### snapshot.js The `.enc` portable snapshot container format — a header, payload, and SHA-256 footer — with kernel-version compatibility checks. A snapshot round-trips an enclave's full state byte-identically across hosts. #### Constants ```javascript SNAPSHOT_MAGIC // Uint8Array — 'ENC\x01' (0x454e4301) SNAPSHOT_HEADER_SIZE // 32 SNAPSHOT_FOOTER_SIZE // 32 SNAPSHOT_LAYOUT_VERSION // 1 ``` #### `pack(kernelVer, payload)` / `unpack(bytes)` ```javascript pack(kernelVer: number, payload: Uint8Array) → Uint8Array unpack(bytes: Uint8Array) → { ok: true, header, payload } | { ok: false, code } ``` `pack` frames a payload into a `.enc` container: a 32-byte header (magic, layout version, packed kernel version, flags, payload size), the payload, then a 32-byte SHA-256 footer over `header || payload`. `unpack` validates magic, layout version, flags, size, and footer, returning the parsed header and payload — or an error `code` (`TOO_SHORT`, `BAD_MAGIC`, `UNKNOWN_LAYOUT_VERSION`, `UNKNOWN_FLAGS`, `SIZE_MISMATCH`, `FOOTER_MISMATCH`). #### Kernel version helpers ```javascript packSemver(major, minor, patch) → number majorOf(v) / minorOf(v) / patchOf(v) → number kernelVerCompatible(producer, restorer) → 'same' | 'patchDiff' | 'minorDiff' | 'majorDiff' | 'preOneAnyDiff' isAcceptable(compat) → boolean // true for same / patchDiff / minorDiff ``` `kernelVerCompatible` classifies whether a snapshot produced by one kernel version can be restored by another; `isAcceptable` is the restore gate. Lower-level header readers (`checkMagic`, `readLayoutVer`, `readKernelVer`, `readFlags`, `readPayloadSize`) operate directly on the raw bytes. ```javascript import { pack, unpack, packSemver } from '@enc-protocol/core/snapshot.js' const kernelVer = packSemver(0, 11, 0) const blob = pack(kernelVer, payloadBytes) // .enc container bytes const r = unpack(blob) // r.ok === true, r.header.kernelVer === kernelVer, r.payload matches payloadBytes ``` *** ### Confidentiality plugins The core package ships the JS implementations of the protocol's confidentiality plugins — the same algorithms specified in the [plugin profiles](/spec/app/plugins). Each composes **only** the verified primitives in `crypto.js` (no raw crypto), so the wire bytes match the spec's known-answer vectors. Each plugin imports from its own module. Full per-plugin reference: [Plugin SDKs](/sdk/plugins). #### ratchet-pair — `dm-ratchet.js` Per-message symmetric ratchet for 1:1 (DM) confidentiality ([`ratchet-pair`](/spec/app/plugins/ratchet-pair)), plus a NIP-44 v2-compatible code path. ```javascript ratchetMessageKey(epochSecret: Uint8Array, senderSeq: number) → Uint8Array(32) dmRatchetEncrypt(epochSecret, plaintext: string, senderSeq: number, epochN: number) → { epoch, sender_seq, ciphertext } dmRatchetDecrypt(epochSecret, ciphertextB64: string, senderSeq: number) → string ``` The ratchet derives a fresh message key per `senderSeq` by chaining HKDF advances from the epoch secret, so every message uses a unique key. NIP-44 helpers (`nip44Encrypt`, `nip44Decrypt`, and the lower-level `nip44ConversationKey` / `nip44MessageKeys` / pad / HMAC functions) provide interop with the Nostr NIP-44 v2 envelope. #### group-ratchet — `group-ratchet.js` Per-sender symmetric ratchet for group messages. ```javascript groupRatchetMessageKey(epochSecret, senderPub: string, senderSeq: number) → Uint8Array(32) groupEncrypt(groupSecret, senderPub, seq, plaintext) → { ciphertext, nonce } groupDecrypt(groupSecret, senderPub, seq, ciphertextHex, nonceHex) → string ``` Each sender gets an independent ratchet chain seeded from the group's epoch secret and the sender's public key, so message keys never collide across senders. #### mls-lazy — `mls-lazy.js` Lazy-MLS group key agreement ([`mls-lazy`](/spec/app/plugins/mls-lazy)): a binary ratchet tree giving `O(log N)` epoch-key distribution for large groups. ```javascript prepareCommit(opts) → { newEpochSecret, newTreeState, envelope } consumeCommit(opts) → { newEpochSecret, newTreeState } wireEnvelope(prepareResult) / parseWireEnvelope(content) encryptMessage(opts) → { epoch_n, sender_pub, sender_seq, ciphertext, nonce } decryptMessage(opts) → string | Uint8Array deriveSenderMessageKey(epochSecret, senderPubHex, senderSeq) → Uint8Array(32) ``` `prepareCommit` (run by the committer) generates a new epoch root and wraps it to each member along the tree copath; `consumeCommit` (run by each member) unwraps the one path entry decryptable with that member's identity key. Once a member holds the epoch secret, `encryptMessage` / `decryptMessage` run the per-sender ratchet. The module also exports the pure tree-math helpers (`paddedLeafCount`, `directPath`, `copath`, `lca`, `subtreeLeafIndices`, …). #### ecdh-envelope — `ecdh-envelope.js` One-shot sender→recipient confidentiality for Personal notices and group-invite handoff ([`ecdh-envelope`](/spec/app/plugins/ecdh-envelope)). ```javascript ecdhEnvelopeSeal(senderOpPriv, recipientOpPubHex, payload) → { ciphertext, nonce, sender_pub, scheme, encrypted } ecdhEnvelopeOpen(myOpPriv, envelope) → payload ecdhEnvelopeWrapHandoff(committerPriv, recipientOpPubHex, rootSecret) → handoff ecdhEnvelopeUnwrapHandoff(myOpPriv, myOpPubHex, handoff) → Uint8Array(32) | null ``` `Seal` / `Open` carry an invite payload between operator keys via ECDH + HKDF (`enc:personal:notice`); the handoff pair distributes a 32-byte group root secret to a new member. The `*WithNonce` variants are deterministic, for the spec's KAT vectors. #### identity-aead — `identity-aead.js` Single-owner deterministic confidentiality for Personal `private` content ([`identity-aead`](/spec/app/plugins/identity-aead)) — no ECDH, no rotation. ```javascript identityAeadEncrypt(identityPriv, enclaveIdHex, plaintext) → { ciphertext, nonce } identityAeadDecrypt(identityPriv, enclaveIdHex, envelope) → string identityAeadContentKey(identityPriv, enclaveIdHex) → Uint8Array(32) ``` The content key is `HKDF(identity_priv, "enc-personal-private:" + lowercase(enclaveId))` — derived from the owner's identity alone, so only the owner can read their own private content, with no key exchange. *** ### aggregator.js — `Hub` In-process routing core for the [WebSocket aggregation hub](/guide/wshub) — the same `sub_id` multiplexing logic the hub Worker runs, exposed as a pure state machine. ```javascript import { Hub } from '@enc-protocol/core/aggregator.js' const hub = new Hub() hub.processClientQuery(client, clientSubId, enclave, queryBody) → outFrames hub.processClientClose(client, clientSubId) → outFrames hub.processUpstreamFrame(frame) → outFrames hub.processUpstreamDeath(enclave) → notifications hub.upstreamRefcount(enclave) → number hub.hasRoute(client, clientSubId) → boolean ``` Each method folds an event into the hub's route table and returns the frames to emit. The underlying transitions are also exported individually from `aggregator-core.js` (`hubInit`, `processClientQuery`, …) as a pure `(state, input) → { state, out }` reducer. *** ### WASM-accelerated modules Every core module has a WASM-backed twin — `crypto-wasm.js`, `event-wasm.js`, `rbac-wasm.js`, `smt-wasm.js`, `ct-wasm.js`, `aggregator-wasm.js`, `snapshot-wasm.js` — exposing the same functions backed by the `enc-core.wasm` kernel instead of pure JS. They produce **byte-identical** results to their JS counterparts (this is the cross-implementation parity property) and are used where the WASM kernel is already loaded. The pure-JS modules above are the portable default and need no WASM to run. *** ### Complete Example: Create Enclave and Submit Events ```javascript import { generateKeypair, derivePublicKey, bytesToHex, hexToBytes, verifySTH, computeEventsRoot, } from '@enc-protocol/core/crypto.js' import { mkCommit, signCommit, mkManifestCommit, verifyEvent, } from '@enc-protocol/core/event.js' import { verifyInclusionProof } from '@enc-protocol/core/ct.js' import { verify, wireToProof, buildRBACKey, decodeRoleBitmask } from '@enc-protocol/core/smt.js' import { isOwner } from '@enc-protocol/core/rbac.js' const NODE = 'https://your-node.example.com' // 1. Generate identity const kp = generateKeypair() const pub = bytesToHex(kp.publicKey) // 2. Create manifest const manifest = JSON.stringify({ enc_v: 2, nonce: Date.now(), RBAC: { use_temp: 'none', schema: [ { event: 'post', role: 'owner', ops: ['C', 'U', 'D'] }, { event: '*', role: 'Public', ops: ['R'] }, ], states: [], traits: ['owner(0)'], initial_state: { owner: [pub] }, }, }) // 3. Submit manifest commit const manifestCommit = signCommit( mkManifestCommit(pub, manifest, Date.now() + 300000, []), kp.privateKey ) const createRes = await (await fetch(NODE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(manifestCommit), })).json() const enclaveId = manifestCommit.enclave const seqPub = createRes.sequencer // 4. Submit a post const postCommit = signCommit( mkCommit(enclaveId, pub, 'post', JSON.stringify({ body: 'hello' }), Date.now() + 300000, []), kp.privateKey ) const receipt = await (await fetch(NODE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(postCommit), })).json() console.log('Event ID:', receipt.id) // 5. Verify signed tree head const sth = await (await fetch(`${NODE}/${enclaveId}/sth`)).json() const sthValid = verifySTH(sth.t, sth.ts, hexToBytes(sth.r), sth.sig, hexToBytes(seqPub)) console.log('STH valid:', sthValid) // 6. Pull and verify events const pullRes = await (await fetch(NODE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'Pull', enclave: enclaveId, after_seq: -1, limit: 100 }), })).json() for (const event of pullRes.events) { console.log(`Event ${event.seq}: ${event.type} — verified: ${verifyEvent(event)}`) } ``` *** ### Dependencies All cryptographic operations use audited `@noble` libraries: | Package | Version | Purpose | | ---------------- | ------- | ------------------------- | | `@noble/curves` | ^1.8.0 | secp256k1 (Schnorr, ECDH) | | `@noble/hashes` | ^1.7.0 | SHA-256, HKDF | | `@noble/ciphers` | ^0.6.0 | XChaCha20-Poly1305 | ## ecdh-envelope — `ecdh-envelope.js` The **one-shot directed** confidentiality plugin: a sender seals a payload to a single recipient with `ECDH(sender, recipient) + AEAD`, with an optional inner sub-encryption for group-invite handoff. Suite: `enc-xchacha-v1` (XChaCha20-Poly1305, 24-byte nonce). Used for Personal notices and group invitations. Normative spec: [`ecdh-envelope`](/spec/app/plugins/ecdh-envelope). ```js import { ecdhEnvelopeSeal, ecdhEnvelopeOpen, ecdhEnvelopeWrapHandoff, ecdhEnvelopeUnwrapHandoff, } from '@enc-protocol/core/ecdh-envelope.js' ``` ### Notice sealing #### `ecdhEnvelopeSeal(senderOpPriv, recipientOpPubHex, payload)` ```js ecdhEnvelopeSeal(senderOpPriv: Uint8Array, recipientOpPubHex: string, payload) → { ciphertext, nonce, sender_pub, scheme: 'personal:notice', encrypted: true } ``` `ECDH(sender, recipient)` → `HKDF('enc:personal:notice')` → AEAD over `JSON.stringify(payload)`. `ecdhEnvelopeSealWithNonce(...)` is the deterministic variant. #### `ecdhEnvelopeOpen(myOpPriv, envelope)` ```js ecdhEnvelopeOpen(myOpPriv: Uint8Array, envelope) → payload ``` Recovers the shared key from `envelope.sender_pub`, decrypts, and validates the payload shape. ### Group-invite handoff #### `ecdhEnvelopeWrapHandoff(committerPriv, recipientOpPubHex, rootSecret)` ```js ecdhEnvelopeWrapHandoff(committerPriv: Uint8Array, recipientOpPubHex: string, rootSecret: Uint8Array) → handoff ``` Seals a 32-byte group root secret to a new member under a separate `enc:personal:notice:epoch` key — the bridge that lets a one-shot invite hand off an ongoing group epoch. #### `ecdhEnvelopeUnwrapHandoff(myOpPriv, myOpPubHex, handoff)` ```js ecdhEnvelopeUnwrapHandoff(myOpPriv: Uint8Array, myOpPubHex: string, handoff) → Uint8Array(32) | null ``` Returns the 32-byte secret, or `null` if the handoff isn't addressed to this key. The guards `ecdhEnvelopeIsEnvelope(content)` and `ecdhEnvelopeValidatePayload(payload)` are also exported. ### Example ```js import { ecdhEnvelopeSeal, ecdhEnvelopeOpen, ecdhEnvelopeWrapHandoff, ecdhEnvelopeUnwrapHandoff, } from '@enc-protocol/core/ecdh-envelope.js' // sender seals a group invite to the recipient's operator key const envelope = ecdhEnvelopeSeal(senderOpPriv, recipientOpPub, { kind: 'group_invite', enclave_id, enclave_kind: 'Group', inviter, epoch_n: 0, }) const payload = ecdhEnvelopeOpen(recipientOpPriv, envelope) console.log(payload.kind) // 'group_invite' // hand the live group root secret off to the new member const handoff = ecdhEnvelopeWrapHandoff(committerPriv, recipientOpPub, groupRootSecret) const secret = ecdhEnvelopeUnwrapHandoff(recipientOpPriv, recipientOpPub, handoff) // Uint8Array(32) | null ``` ### See also * Normative: [ecdh-envelope](/spec/app/plugins/ecdh-envelope) * [Plugin SDKs](/sdk/plugins) · [`@enc-protocol/core`](/sdk/core#confidentiality-plugins) ## identity-aead — `identity-aead.js` The **single-owner** confidentiality plugin: deterministic AEAD for content only its owner reads — no ECDH, no key exchange, no rotation. The content key is `HKDF(identity_priv, "enc-personal-private:" + lowercase(enclaveId))`. Suite: `enc-xchacha-v1` (XChaCha20-Poly1305, 24-byte nonce). Used by [personal](/sdk/apps/personal) for `private` content. Normative spec: [`identity-aead`](/spec/app/plugins/identity-aead). ```js import { identityAeadEncrypt, identityAeadDecrypt, identityAeadContentKey, identityAeadIsEnvelope, } from '@enc-protocol/core/identity-aead.js' ``` ### API #### `identityAeadEncrypt(identityPriv, enclaveIdHex, plaintext)` ```js identityAeadEncrypt(identityPriv: Uint8Array, enclaveIdHex: string, plaintext: string) → { ciphertext: string, nonce: string } ``` Derives the owner content key and seals `plaintext` with a random 24-byte nonce. `identityAeadEncryptWithNonce(identityPriv, enclaveIdHex, plaintext, nonce)` is the deterministic variant used for the spec's known-answer vectors. #### `identityAeadDecrypt(identityPriv, enclaveIdHex, envelope)` ```js identityAeadDecrypt(identityPriv: Uint8Array, enclaveIdHex: string, envelope) → string ``` Validates the envelope shape, then derives the same content key and decrypts. Throws on a malformed envelope. #### `identityAeadContentKey(identityPriv, enclaveIdHex)` / `identityAeadIsEnvelope(content)` ```js identityAeadContentKey(identityPriv: Uint8Array, enclaveIdHex: string) → Uint8Array(32) identityAeadIsEnvelope(content) → boolean ``` The key-derivation primitive and an envelope type guard. Because the key depends only on the owner's identity and the enclave id, there is nothing to exchange or rotate — the owner can always re-derive it. ### Example ```js import { identityAeadEncrypt, identityAeadDecrypt } from '@enc-protocol/core/identity-aead.js' // seal owner-only content — only this identity can re-derive the key const env = identityAeadEncrypt(myIdentityPriv, enclaveId, 'private note') // → { ciphertext, nonce } const plain = identityAeadDecrypt(myIdentityPriv, enclaveId, env) console.log(plain) // 'private note' ``` ### See also * Normative: [identity-aead](/spec/app/plugins/identity-aead) * [Plugin SDKs](/sdk/plugins) · [`@enc-protocol/core`](/sdk/core#confidentiality-plugins) ## Plugin SDKs ENC's confidentiality is an application-layer concern: an app seals event content with one of the protocol's **four encryption plugins**, picked per data\_type. The node only ever sees opaque ciphertext. Each plugin is a `(role, suite)` pair with a byte-exact wire-and-key contract — two implementations produce identical ciphertext — and each is **generated from the Lean specification** into [`@enc-protocol/core`](/sdk/core#confidentiality-plugins). | Plugin | Role (trust shape) | Suite | Typical use | | ------------------------------------------- | ----------------------- | ---------------- | -------------------------- | | [ratchet-pair](/sdk/plugins/ratchet-pair) | pairwise (1:1) | `enc-xchacha-v1` | DMs, 1:1 threads | | [mls-lazy](/sdk/plugins/mls-lazy) | shared-secret (N-party) | `mls-chacha-v1` | group chat, channels | | [identity-aead](/sdk/plugins/identity-aead) | single-owner | `enc-xchacha-v1` | owner-only private content | | [ecdh-envelope](/sdk/plugins/ecdh-envelope) | one-shot directed | `enc-xchacha-v1` | invites, addressed notices | The normative wire-and-key specs live in the [encryption plugin catalog](/spec/app/plugins). ### How a plugin is bound At runtime an app dispatches encryption through the `ClientPluginRegistry`'s `EnvelopeEncryptFn` / `EnvelopeDecryptFn` slots. An app's generated `submit*` method calls `_encrypt(dataType, args)` with that data\_type's plugin; you can override the slot to supply a custom envelope implementation: ```js import { defaultClientPluginRegistry } from '@enc-protocol/cli-sdk-base' const plugins = defaultClientPluginRegistry() plugins.register('EnvelopeEncryptFn', myEncrypt) plugins.register('EnvelopeDecryptFn', myDecrypt) ``` ### Where they're generated Each plugin's JS is emitted by a Lean DSL module — `spec-lean/Enc/DSL/Modules/{DmRatchet,GroupRatchet,MlsLazy,IdentityAead,EcdhEnvelope}.lean` — proven equivalent to its core spec (`Enc/Core/Plugins/*.lean`) and emitted into `sdk/core/*.js`. They compose only the verified `crypto.js` primitives (no raw `@noble`), which is what makes the wire output byte-identical across implementations. ### See also * [Encryption plugin catalog](/spec/app/plugins) — the normative `(role, suite)` specs * [`@enc-protocol/core`](/sdk/core#confidentiality-plugins) — the package that ships them ## mls-lazy — `mls-lazy.js` The **shared-secret (N-party)** confidentiality plugin: a binary ratchet tree keyed to the sorted member set, giving `O(log N)` epoch-key distribution per commit, plus a per-sender HKDF message ratchet inside each epoch. Suite: `mls-chacha-v1` (ChaCha20-Poly1305, 12-byte nonce, RFC 9420). Used by [group](/sdk/apps/group). Normative spec: [`mls-lazy`](/spec/app/plugins/mls-lazy). ```js import { prepareCommit, consumeCommit, wireEnvelope, parseWireEnvelope, encryptMessage, decryptMessage, deriveSenderMessageKey, } from '@enc-protocol/core/mls-lazy.js' ``` ### Epoch key agreement #### `prepareCommit(opts)` ```js prepareCommit({ sortedMembers, committerPubHex, prevEpochN?, prevTreeState?, newMembers? }) → { newEpochSecret: Uint8Array, newTreeState, envelope } ``` Run by the committer. Generates a fresh epoch root and wraps it to each member along the tree copath (reusing unchanged subtree keys from `prevTreeState` when present). `envelope` is the commit to broadcast; `newTreeState` is cached for the next commit. #### `consumeCommit(opts)` ```js consumeCommit({ sortedMembers, myPubHex, identityPriv, prevTreeState?, envelope, prevHighestN?, expectedCommitter? }) → { newEpochSecret: Uint8Array, newTreeState } ``` Run by each member. Unwraps the one path entry decryptable with the member's identity key and recovers the new epoch secret. Validates that `envelope.n` is strictly increasing and (optionally) that the committer matches. #### `wireEnvelope(result)` / `parseWireEnvelope(content)` Serialize a `prepareCommit` result to the wire `{ epoch: {...} }` shape, and parse it back. ### Per-epoch messaging #### `deriveSenderMessageKey(epochSecret, senderPubHex, senderSeq)` ```js deriveSenderMessageKey(epochSecret: Uint8Array, senderPubHex: string, senderSeq: number) → Uint8Array(32) ``` The per-sender HKDF ratchet inside an epoch — a fresh key per `(sender, seq)`. #### `encryptMessage(opts)` / `decryptMessage(opts)` ```js encryptMessage({ epochSecret, epochN, senderPubHex, senderSeq, plaintext }) → { epoch_n, sender_pub, sender_seq, ciphertext, nonce } decryptMessage({ epochSecret, envelope }) → string | Uint8Array ``` The standalone per-sender ratchet is also exposed as `@enc-protocol/core/group-ratchet.js` (`groupRatchetMessageKey` / `groupEncrypt` / `groupDecrypt`). ### Tree helpers Pure tree math is exported for tooling: `paddedLeafCount`, `totalNodeCount`, `treeDepth`, `leafNodeId`, `nodeIdToLeafIdx`, `parentNode`, `siblingNode`, `directPath`, `copath`, `subtreeLeafIndices`, `lca`, `sortMembers`, `memberLeafIdx`. ### Example ```js import { sortMembers, prepareCommit, consumeCommit, wireEnvelope, parseWireEnvelope, encryptMessage, decryptMessage, } from '@enc-protocol/core/mls-lazy.js' const members = sortMembers([alicePubHex, bobPubHex, carolPubHex]) // alice commits a fresh epoch and broadcasts the envelope const { newEpochSecret, envelope } = prepareCommit({ sortedMembers: members, committerPubHex: alicePubHex }) const wire = wireEnvelope({ envelope }) // bob consumes it and recovers the same epoch secret const bob = consumeCommit({ sortedMembers: members, myPubHex: bobPubHex, identityPriv: bobPriv, envelope: parseWireEnvelope(wire), }) // alice sends a message in the epoch; bob decrypts it const m = encryptMessage({ epochSecret: newEpochSecret, epochN: envelope.n, senderPubHex: alicePubHex, senderSeq: 0, plaintext: 'gm all', }) console.log(decryptMessage({ epochSecret: bob.newEpochSecret, envelope: m })) // 'gm all' ``` ### See also * Normative: [mls-lazy](/spec/app/plugins/mls-lazy) * [Plugin SDKs](/sdk/plugins) · [`@enc-protocol/core`](/sdk/core#confidentiality-plugins) ## ratchet-pair — `dm-ratchet.js` The **pairwise** confidentiality plugin: a per-message symmetric ratchet for 1:1 threads. An ECDH-derived per-pair epoch secret seeds a per-sender HKDF ratchet, so every message uses a fresh key. Suite: `enc-xchacha-v1` (XChaCha20-Poly1305, 24-byte nonce). Used by [dm](/sdk/apps/dm) and [super](/sdk/apps/super). Normative spec: [`ratchet-pair`](/spec/app/plugins/ratchet-pair). ```js import { ratchetMessageKey, dmRatchetEncrypt, dmRatchetDecrypt, } from '@enc-protocol/core/dm-ratchet.js' ``` ### Ratchet API #### `ratchetMessageKey(epochSecret, senderSeq)` ```js ratchetMessageKey(epochSecret: Uint8Array, senderSeq: number) → Uint8Array(32) ``` Derives the message key for a sequence number by chaining HKDF advances from the epoch secret. Deterministic — the same `(epochSecret, senderSeq)` always yields the same key. #### `dmRatchetEncrypt(epochSecret, plaintext, senderSeq, epochN)` ```js dmRatchetEncrypt(epochSecret, plaintext: string, senderSeq: number, epochN: number) → { epoch: number, sender_seq: number, ciphertext: string } ``` Encrypts `plaintext` under the message key for `senderSeq`, tagging the result with the epoch number. `ciphertext` is the base64 XChaCha20-Poly1305 output. #### `dmRatchetDecrypt(epochSecret, ciphertextB64, senderSeq)` ```js dmRatchetDecrypt(epochSecret, ciphertextB64: string, senderSeq: number) → string ``` Re-derives the message key for `senderSeq` and decrypts. ### NIP-44 v2 interop `dm-ratchet.js` also ships a Nostr NIP-44 v2 code path for bridging to Nostr DMs: ```js nip44Encrypt(privHex, peerPubHex, plaintext) → string // base64 NIP-44 payload nip44Decrypt(privHex, peerPubHex, payload) → string ``` These are built from the lower-level primitives `nip44ConversationKey(privHex, pubHex)`, `nip44MessageKeys(ck, nonce)`, `nip44Pad` / `nip44Unpad`, `nip44HmacAad`, and `nip44EncryptWithKey` / `nip44DecryptWithKey`. ### Example ```js import { dmRatchetEncrypt, dmRatchetDecrypt } from '@enc-protocol/core/dm-ratchet.js' // both sides hold the same 32-byte per-pair epoch secret (ECDH-derived) const env = dmRatchetEncrypt(epochSecret, 'hello', 0, 0) // → { epoch: 0, sender_seq: 0, ciphertext: '…base64…' } const plain = dmRatchetDecrypt(epochSecret, env.ciphertext, env.sender_seq) console.log(plain) // 'hello' ``` Or the NIP-44 v2 path, for Nostr interop: ```js import { nip44Encrypt, nip44Decrypt } from '@enc-protocol/core/dm-ratchet.js' const payload = nip44Encrypt(alicePrivHex, bobPubHex, 'hi bob') const msg = nip44Decrypt(bobPrivHex, alicePubHex, payload) // 'hi bob' ``` ### See also * Normative: [ratchet-pair](/spec/app/plugins/ratchet-pair) * [Plugin SDKs](/sdk/plugins) · [`@enc-protocol/core`](/sdk/core#confidentiality-plugins) ## @enc-protocol/dm-cli — `DmSdk` Direct messages: end-to-end encrypted 1:1 conversations with an invite handshake, where each message is written to the recipient's own DM enclave (resolved through the registry). Extends [`AppSdk`](/sdk/cli-sdk-base#appsdk). Enclaves: [`DM`](/spec/app/enclaves/dm), [`Personal`](/spec/app/enclaves/personal), [`Registry`](/spec/app/enclaves/registry). Encrypted data\_types: `messages` ([ratchet-pair](/spec/app/plugins/ratchet-pair)), `invites` ([ecdh-envelope](/spec/app/plugins/ecdh-envelope)). ### Install ```bash npm config set @enc-protocol:registry https://npm-registry.ocrybit.workers.dev/ npm install @enc-protocol/dm-cli ``` ### Construct ```js import { DmSdk } from '@enc-protocol/dm-cli' const sdk = new DmSdk({ mode: 'cf', nodeUrl: process.env.NODE_URL, identity }) await sdk.init() ``` Options are the standard [`AppSdk` constructor](/sdk/cli-sdk-base#constructor). ### Data types | data\_type | shape | encryption | routes to | | ---------- | --------------------------- | ------------- | --------------------------------- | | `messages` | `{ message_draft: string }` | ratchet-pair | `DM.message` | | `invites` | `{}` | ecdh-envelope | `DM.invite` | | `contacts` | `{}` | — | *declared; no generated accessor* | ### Methods #### `submitMessages(args)` ```js await sdk.submitMessages({ message_draft: 'hi' }) // → DM.message (encrypted) ``` Encrypts the draft with **ratchet-pair** (a per-message symmetric ratchet) and writes it to the conversation's DM enclave. #### `deleteInvites(args)` ```js await sdk.deleteInvites({}) // → DM.invite (delete) ``` Deletes an invite from the DM enclave. #### `queryMessages()` Read decrypted messages. Row fields: `body`, `outgoing`. #### `queryInvites()` Read pending invites. Row fields: `greeting`, `pub`, `accept`, `reject`. #### `queryProfiles()` Cross-enclave dataview read of `Personal.Shared(profile)` — the latest profile per identity. ### Recipient routing DM resolves the recipient's DM enclave through `Registry.reg_identity` (the `_enclave_routing` cross-enclave route) so a message lands in the right person's enclave. ### Example ```js import { DmSdk } from '@enc-protocol/dm-cli' const sdk = new DmSdk({ mode: 'cf', nodeUrl: process.env.NODE_URL, identity }) await sdk.init() await sdk.submitMessages({ message_draft: 'hi' }) // encrypted (ratchet-pair) const thread = await sdk.queryMessages() // decrypted const invites = await sdk.queryInvites() const me = await sdk.queryProfiles() // cross-enclave profiles ``` ### See also * Enclave profiles: [DM](/spec/app/enclaves/dm), [Personal](/spec/app/enclaves/personal), [Registry](/spec/app/enclaves/registry) * Confidentiality plugins: [ratchet-pair](/spec/app/plugins/ratchet-pair), [ecdh-envelope](/spec/app/plugins/ecdh-envelope) * [App SDK pattern](/sdk/apps) · [`cli-sdk-base`](/sdk/cli-sdk-base) ## @enc-protocol/group-cli — `GroupSdk` Group chat: MLS-encrypted group messages with admin moderation, muting, and member-state management (PENDING / MEMBER / BLOCKED). Extends [`AppSdk`](/sdk/cli-sdk-base#appsdk). Enclaves: [`Group`](/spec/app/enclaves/group), [`Personal`](/spec/app/enclaves/personal). Encrypted data\_type: `messages` ([mls-lazy](/spec/app/plugins/mls-lazy)). ### Install ```bash npm config set @enc-protocol:registry https://npm-registry.ocrybit.workers.dev/ npm install @enc-protocol/group-cli ``` ### Construct ```js import { GroupSdk } from '@enc-protocol/group-cli' const sdk = new GroupSdk({ mode: 'cf', nodeUrl: process.env.NODE_URL, identity }) await sdk.init() ``` Options are the standard [`AppSdk` constructor](/sdk/cli-sdk-base#constructor). ### Data types | data\_type | shape | encryption | routes to | | ---------- | --------------------------- | ---------- | --------------- | | `messages` | `{ message_draft: string }` | mls-lazy | `Group.message` | ### Methods #### `submitMessages(args)` ```js await sdk.submitMessages({ message_draft: 'hi everyone' }) // → Group.message (encrypted) ``` Encrypts the draft to the current group epoch with **mls-lazy** (a binary ratchet tree giving `O(log N)` key distribution) and writes it to the Group enclave. #### `queryMessages()` Read decrypted group messages. Row fields: `body`, `outgoing`. #### `queryProfiles()` Cross-enclave dataview read of `Personal.Shared(profile)` — the latest profile per member. ### Membership The Group enclave models membership with RBAC states (`PENDING`, `MEMBER`, `BLOCKED`) and traits (`owner`, `admin`, `muted`). Joins, promotions, mutes, and blocks are access-control events on the enclave — issue them via the lower-level adapter or the `enc group` CLI; see the [Group enclave profile](/spec/app/enclaves/group). ### Example ```js import { GroupSdk } from '@enc-protocol/group-cli' const sdk = new GroupSdk({ mode: 'cf', nodeUrl: process.env.NODE_URL, identity }) await sdk.init() await sdk.submitMessages({ message_draft: 'gm all' }) // MLS-encrypted (mls-lazy) const msgs = await sdk.queryMessages() const members = await sdk.queryProfiles() // cross-enclave profiles ``` ### See also * Enclave profiles: [Group](/spec/app/enclaves/group), [Personal](/spec/app/enclaves/personal) * Confidentiality plugin: [mls-lazy](/spec/app/plugins/mls-lazy) * [App SDK pattern](/sdk/apps) · [`cli-sdk-base`](/sdk/cli-sdk-base) ## App SDKs Each reference app ships as a typed SDK at `@enc-protocol/-cli` — a class extending [`AppSdk`](/sdk/cli-sdk-base#appsdk) with a method per action and a method per read. The class, its bundled manifests, and its routing are **generated** from the app's `app.json` / `schema.json` definition, so the SDK surface always matches the app's RBAC manifests. | App SDK | Class | Enclaves | Encrypted data\_types | | ------------------------------ | ------------- | ---------------------- | ---------------------------------------------------- | | [personal](/sdk/apps/personal) | `PersonalSdk` | Personal, Group | `private` (identity-aead) | | [dm](/sdk/apps/dm) | `DmSdk` | DM, Personal, Registry | `messages` (ratchet-pair), `invites` (ecdh-envelope) | | [group](/sdk/apps/group) | `GroupSdk` | Group, Personal | `messages` (mls-lazy) | | [super](/sdk/apps/super) | `SuperSdk` | DM, Group, Personal | `messages` (ratchet-pair) | | [registry](/sdk/apps/registry) | `RegistrySdk` | Registry | — | | [node](/sdk/apps/node) | `NodeSdk` | all six | — | ### Install ```bash npm config set @enc-protocol:registry https://npm-registry.ocrybit.workers.dev/ npm install @enc-protocol/-cli ``` ### Construct & init ```ts new Sdk(opts: { mode: 'mem' | 'cf' identity?: Identity // required for cf nodeUrl?: string // cf (or set NODE_URL env) repoRoot?: string // path to find apps// + enclaves/ encHome?: string // state dir (defaults to ~/.enc) forceReadable?: boolean // test-only Public-R escape (cf) }) ``` ```js const sdk = new PersonalSdk({ mode: 'cf', nodeUrl: process.env.NODE_URL, identity }) await sdk.init() // load app definition, register enclaves, wire the dataview ``` Constructor options are the standard [`AppSdk` options](/sdk/cli-sdk-base#constructor); each app SDK bundles its own manifests, so no `appId` is needed. ### How a method routes Each `submit` / `query` resolves its `data_type` through the app's `tableMap` to a concrete `enclave.event`: ``` super.submitMessages({...}) → tableMap.messages = "message" → DM declares "message" → DM.message ``` Both data\_types and raw enclave event names are accepted. ### Encryption Apps with encrypted data\_types call `_encrypt(dataType, args)` before submit, dispatching to the named [confidentiality plugin](/sdk/core#confidentiality-plugins) — `identity-aead`, `ratchet-pair`, `ecdh-envelope`, or `mls-lazy`. The generated `submit*` methods pass the right plugin automatically; override `_encrypt` only for custom envelope crypto. ### Cross-enclave reads `cross_enclave: true` reads (e.g. `profiles`, `notices`) are served by the [`DataView`](/sdk/cli-sdk-base#dataview) — an in-memory projection that captures matching events on a source enclave (per the app's `cross_enclave_reads`) and serves rows on query. In production the same projection runs as a Cloudflare Durable Object + SQLite. ### See also * [`@enc-protocol/cli-sdk-base`](/sdk/cli-sdk-base) — the `AppSdk` / `AppClient` / `DataView` base * [`@enc-protocol/cli`](/sdk/cli) — the `enc` binary that drives these SDKs ## @enc-protocol/node-cli — `NodeSdk` A **host app** that bundles the reference enclaves — DM, Group, Personal, Registry, and more — so a single node deployment can serve them all from one definition. Extends [`AppSdk`](/sdk/cli-sdk-base#appsdk). Enclaves: DM, Group, [`Personal`](/spec/app/enclaves/personal), [`Registry`](/spec/app/enclaves/registry). No encryption, no own write surface. ### Install ```bash npm config set @enc-protocol:registry https://npm-registry.ocrybit.workers.dev/ npm install @enc-protocol/node-cli ``` ### Construct ```js import { NodeSdk } from '@enc-protocol/node-cli' const sdk = new NodeSdk({ mode: 'cf', nodeUrl: process.env.NODE_URL, identity }) await sdk.init() // registers all six bundled enclaves ``` Options are the standard [`AppSdk` constructor](/sdk/cli-sdk-base#constructor). ### A host, not a feature app `NodeSdk` has **no generated `submit*` / `query*` methods of its own** — it exists to register and host the bundled enclaves so they're available under one deployment. To actually read or write, use the individual app SDKs ([personal](/sdk/apps/personal), [dm](/sdk/apps/dm), [group](/sdk/apps/group), …) or drop to the underlying `AppClient`: ```js const client = sdk.raw() await client.submit('Personal', 'public', { draft: 'gm' }) ``` This is the SDK counterpart to running an [ENC node](/guide/node): the node serves the enclaves, and `NodeSdk` bundles their manifests so a deployment can stand them all up at once. ### See also * [Run a Node](/guide/node) — hosting enclaves on a real node * [Enclave profiles](/spec/app/enclaves) — the catalog of bundled enclaves * [App SDK pattern](/sdk/apps) · [`cli-sdk-base`](/sdk/cli-sdk-base) ## @enc-protocol/personal-cli — `PersonalSdk` The Personal identity-anchor app: public posts, owner-only encrypted private documents, a KV profile, and a notice inbox for cross-enclave addressed messages (e.g. group invitations). Extends [`AppSdk`](/sdk/cli-sdk-base#appsdk). Enclaves: [`Personal`](/spec/app/enclaves/personal), [`Group`](/spec/app/enclaves/group). Encrypted data\_type: `private` ([identity-aead](/spec/app/plugins/identity-aead)). ### Install ```bash npm config set @enc-protocol:registry https://npm-registry.ocrybit.workers.dev/ npm install @enc-protocol/personal-cli ``` ### Construct ```js import { PersonalSdk } from '@enc-protocol/personal-cli' const sdk = new PersonalSdk({ mode: 'cf', nodeUrl: process.env.NODE_URL, identity }) await sdk.init() ``` Options are the standard [`AppSdk` constructor](/sdk/cli-sdk-base#constructor). ### Data types | data\_type | shape | encryption | routes to | | ---------- | ------------------- | ------------- | ------------------ | | `public` | `{ draft: string }` | — | `Personal.public` | | `private` | `{ draft: string }` | identity-aead | `Personal.private` | ### Methods #### `submitPublic(args)` ```js await sdk.submitPublic({ draft: 'gm' }) // → Personal.public ``` Writes a public post to the owner's Personal enclave. #### `submitPrivate(args)` ```js await sdk.submitPrivate({ draft: 'note to self' }) // → Personal.private ``` Encrypts `args` with **identity-aead** (owner-only, derived from the identity key — no key exchange) before writing. #### `queryPublic()` / `queryPrivate()` Read the owner's public posts / private documents. Row fields: `body`, `from`, `trailing`. #### `queryProfiles()` Cross-enclave dataview read of `Personal.Shared(profile)` — the latest profile per identity. #### `queryNotices()` Cross-enclave dataview read of `Group.notice` — addressed notices (e.g. group invitations) delivered to this identity's inbox. ### Example ```js import { PersonalSdk } from '@enc-protocol/personal-cli' const sdk = new PersonalSdk({ mode: 'cf', nodeUrl: process.env.NODE_URL, identity }) await sdk.init() await sdk.submitPublic({ draft: 'gm' }) await sdk.submitPrivate({ draft: 'remember the milk' }) // encrypted (identity-aead) const posts = await sdk.queryPublic() // public feed const docs = await sdk.queryPrivate() // owner-only, decrypted const me = await sdk.queryProfiles() // cross-enclave profile rows ``` ### See also * Enclave profiles: [Personal](/spec/app/enclaves/personal), [Group](/spec/app/enclaves/group) * Confidentiality plugin: [identity-aead](/spec/app/plugins/identity-aead) * [App SDK pattern](/sdk/apps) · [`cli-sdk-base`](/sdk/cli-sdk-base) ## @enc-protocol/registry-cli — `RegistrySdk` The discovery app: a public index of identities, enclaves, and nodes that lets clients resolve who/where without a central server. Extends [`AppSdk`](/sdk/cli-sdk-base#appsdk). Enclave: [`Registry`](/spec/app/enclaves/registry). No encryption — the registry is a public index. ### Install ```bash npm config set @enc-protocol:registry https://npm-registry.ocrybit.workers.dev/ npm install @enc-protocol/registry-cli ``` ### Construct ```js import { RegistrySdk } from '@enc-protocol/registry-cli' const sdk = new RegistrySdk({ mode: 'cf', nodeUrl: process.env.NODE_URL, identity }) await sdk.init() ``` Options are the standard [`AppSdk` constructor](/sdk/cli-sdk-base#constructor). ### Read-only at the app level `RegistrySdk` exposes **no generated `submit*` methods** — registration is written at the enclave level through the Registry enclave's customs (`reg_identity`, `reg_enclave`, `reg_node`), and read back through the registry's dataview snapshots. The dataview keeps one UPSERT-keyed row per entity: | view (data\_type) | source event | keyed by | | ----------------- | -------------- | ------------ | | `reg_identities` | `reg_identity` | `id_pub` | | `reg_enclaves` | `reg_enclave` | `enclave_id` | | `reg_nodes` | `reg_node` | `seq_pub` | Read them with the generic `AppSdk` surface, or drop to the lower-level adapter: ```js const identities = await sdk.query('reg_identities') const client = sdk.raw() // underlying AppClient for enclave-level access ``` For typed lookups (resolve an enclave to its hosting node, list nodes/identities), the [`RegistryClient`](/sdk/client) in `@enc-protocol/client` is usually the better surface. ### See also * Enclave profile: [Registry](/spec/app/enclaves/registry) * [`RegistryClient`](/sdk/client) — typed registry lookups * [App SDK pattern](/sdk/apps) · [`cli-sdk-base`](/sdk/cli-sdk-base) ## @enc-protocol/super-cli — `SuperSdk` A super-app composing three enclaves — DM, Group, and Personal — into one SDK: direct messages, a public moments feed, and cross-enclave profiles. Extends [`AppSdk`](/sdk/cli-sdk-base#appsdk). Enclaves: [`DM`](/spec/app/enclaves/dm), [`Group`](/spec/app/enclaves/group), [`Personal`](/spec/app/enclaves/personal). Encrypted data\_type: `messages` ([ratchet-pair](/spec/app/plugins/ratchet-pair)). ### Install ```bash npm config set @enc-protocol:registry https://npm-registry.ocrybit.workers.dev/ npm install @enc-protocol/super-cli ``` ### Construct ```js import { SuperSdk } from '@enc-protocol/super-cli' const sdk = new SuperSdk({ mode: 'cf', nodeUrl: process.env.NODE_URL, identity }) await sdk.init() ``` Options are the standard [`AppSdk` constructor](/sdk/cli-sdk-base#constructor). ### Data types | data\_type | shape | encryption | routes to | | ---------- | --------------------------- | ------------ | ----------------- | | `messages` | `{ message_draft: string }` | ratchet-pair | `DM.message` | | `moments` | `{ moment_draft: string }` | — | `Personal.public` | ### Methods #### `submitMessages(args)` ```js await sdk.submitMessages({ message_draft: 'hi' }) // → DM.message (encrypted) ``` Encrypts with **ratchet-pair** and routes to the DM enclave. #### `submitMoments(args)` ```js await sdk.submitMoments({ moment_draft: 'wow' }) // → Personal.public ``` Writes a public moment to the Personal enclave's public feed. #### `queryMessages()` Read decrypted DM messages. Row fields: `body`, `outgoing`. #### `queryMoments()` Read the public moments feed. Row fields: `body`, `from`, `trailing`, `likes`, `replies`. #### `queryProfiles()` Cross-enclave dataview read of `Personal.Shared(profile)` — the latest profile per identity. ### Multi-enclave routing `submit` resolves through the app's `tableMap` to the owning enclave — `messages → DM.message`, `moments → Personal.public` — so one SDK drives all three enclaves transparently. See [how a method routes](/sdk/apps#how-a-method-routes). ### Example ```js import { SuperSdk } from '@enc-protocol/super-cli' const sdk = new SuperSdk({ mode: 'cf', nodeUrl: process.env.NODE_URL, identity }) await sdk.init() await sdk.submitMessages({ message_draft: 'hi' }) // → DM.message (encrypted) await sdk.submitMoments({ moment_draft: 'wow' }) // → Personal.public const feed = await sdk.queryMoments() const thread = await sdk.queryMessages() const profiles = await sdk.queryProfiles() ``` ### See also * Enclave profiles: [DM](/spec/app/enclaves/dm), [Group](/spec/app/enclaves/group), [Personal](/spec/app/enclaves/personal) * Confidentiality plugin: [ratchet-pair](/spec/app/plugins/ratchet-pair) * [App SDK pattern](/sdk/apps) · [`cli-sdk-base`](/sdk/cli-sdk-base) ## AppGen AppGen turns **app intent into a formally-specified app**. It abstracts \~1,500 reference apps into a generative model and produces validated enclave graphs, SDK code, and deployment artifacts for **11 platforms** — but the AI never writes arbitrary code. It only selects from and composes a closed vocabulary of verified building blocks, so every generated app inherits the protocol's formal guarantees. ### How it works 1. **Classify.** A classifier maps the natural-language intent to an archetype — a known substrate app, team-chat, social-feed, or custom — as a closed-schema decision, not free text. 2. **Resolve parameters.** For non-substrate archetypes, a resolver extracts typed parameters (workspace name, encryption mode, …) against the archetype's schema. 3. **Constraint-solve.** A deterministic solver applies defaults, type-checks the parameters, and enforces constraint rules (e.g. "AI summary requires full search"), returning solved params or a structured correction list. 4. **Generate the manifest.** The archetype's generator produces an app manifest — the RBAC matrix, event definitions, plugin-slot bindings, and encryption topology. 5. **Validate + translate.** Structural checks and algebra tests (via the [UI Kit](/pipeline/ui-kit)) validate it; a translator converts the app spec into deterministic Lean that must compile. 6. **Platform codegen.** The validated spec feeds [CodeGen](/pipeline/codegen)'s fan-out to 11 platform targets — web, native, desktop, extension, CLI, TUI, Cloudflare Workers, embedded, and more. Each target gets the same logical app and semantics; only the bindings differ. ### What makes it sound The lever is the **closed vocabulary**: \~23 primitives arranged into \~31 canonical shapes (PublicFeed, Channel, ContentMarket, OwnerProfile, …), each instantiated as Lean theorems. The AI touches only classification and parameter binding; generation, validation, and translation are deterministic. So however the LLM arrived at the spec, the emitted Lean must compile and carry its proven properties — **25 in total** (12 from the core guarantee matrix, 13 from substrate theorems, all `sorry`-free). When the model errs, it errs *within* the schema, where the solver and validator catch it; it cannot produce arbitrary code. ### Figures * \~1,500 reference apps abstracted; \~31 shapes · \~23 primitives · 40+ archetypes * 25 proven properties (sorry-free Lean), across 11 platform targets * a deterministic (named-app) path resolves in \~90 ms; the AI-assisted path in \~1–3 s ### See also * [TestGen](/pipeline/testgen) verifies the generated apps · [CodeGen](/pipeline/codegen) emits the platforms * [App SDKs](/sdk/apps) — the kind of app surface AppGen produces * [Pipeline overview](/pipeline) ## CodeGen CodeGen is the ENC instance's emit layer: it turns the protocol's checked Lean spec into **every implementation that ships** — byte-deterministically, with each artifact's hash pinned. The software you run is provably the design that was verified, because it was *generated* from it rather than written against it. ### How it works 1. **One source.** The canonical form is `Enc.DSL` — a protocol DSL induced from prose, Lean definitions, implementation observations, and witness failures by [SpecGen](/pipeline/specgen). Humans never hand-author it. 2. **Fan-out emit.** From that Lean, CodeGen emits multiple targets: | Target | What it is | | ------------------------------- | ------------------------------------------- | | Lean reference | the executable referee — the spec that runs | | JS core / client / dataview SDK | `@enc-protocol/{core, client, dataview}` | | JS node | the pure-JS node implementation | | Rust / WASM kernel | the `enc-core.wasm` kernel + Rust host | | Cloudflare Worker | the production Worker source | 3. **Byte-determinism.** Re-running the emit produces **zero diff**; a codegen manifest pins each artifact's `sha256`. Today the manifest tracks **55 generated artifacts** (47 protocol / 7 platform / 1 operational). 4. **Idempotence gate.** Any drift between the committed artifact and a fresh emit fails CI — handwritten protocol residue is a release problem unless it is explicitly classified and accepted. ### What makes it sound Because all targets are emitted from the *same* Lean, they are **byte-identical** where it counts: the JS node, the WASM kernel, and the Lean reference answer the same wire request the same way (cross-implementation parity is asserted byte-equally — see [Run a Node](/guide/node)). There is no spec↔implementation gap to drift, because there is only one source and the rest is a checked projection of it. ### Spec layers ENC's spec is layered so each layer depends only on the ones below it: `kernel → node → app → deployment`, with `appgen` depending on `kernel + node + app`. Changing the kernel changes which histories, states, and proofs are valid; everything above is generated to match. ### See also * [SpecGen](/pipeline/specgen) — the framework CodeGen is an instance of * Its outputs: [Protocol Spec](/spec) · [SDK Reference](/sdk) · [Plugin SDKs](/sdk/plugins) * [Pipeline overview](/pipeline) ## ENC Emulator The ENC Emulator is a **browser testbed** that loads every app definition from the [UI Kit](/pipeline/ui-kit) and runs it against a real ENC node, so the full protocol stack can be previewed and exercised from a single page. It is also a **required witness** in the spec's release gate. ### How it works 1. **Load apps.** Every app's manifests are loaded; the six core enclaves are pre-mounted and the rest of the catalog is install-on-demand, keeping per-workflow boot fast. 2. **Drive the tier matrix.** Each app's workflows run across **8 tiers** — `(mem | cf) × (node | dom | hl | pw)`: pure SDK, happy-dom, and Playwright (headless / headed) renderings, each over either an in-process MemoryAdapter or a real Worker backend. 3. **Codegen-derived MemoryAdapter.** The in-process node emulation is itself *generated* from the Lean spec, so the emulated node shares the protocol semantics of the real one — same RBAC, signatures, and event log, with no network. 4. **Witness.** The full matrix run is the canonical coverage proof and emits a witness result the spec's release gate consumes. ### What makes it sound The same workflow corpus runs through every tier, and the matrix is a single pass that exits green only if **every** tier passes — the operational form of "100% coverage" (the reference run is `8 tiers × 10 apps × 123 workflows = 984 / 984`). Because the emulated node is codegen-derived from the spec — not a separate mock — passing on the `mem` tiers means passing against the real protocol semantics. The emulator is pinned as a required witness: the spec cannot release while its matrix is red. ### See also * [TestGen](/pipeline/testgen) — the matrix-theorem machinery the emulator discharges in the browser * [ENC UI Kit](/pipeline/ui-kit) · [ENC Flow](/pipeline/flow) * [Pipeline overview](/pipeline) ## ENC Flow ENC Flow is the **enclave-resolution engine**. It bridges UI authoring, ephemeral dev backends, and helper configs into the protocol-canonical **AppSpec** — the definition the rest of the pipeline consumes. It is where an app's declarative manifests are derived and reconciled. ### How it works Flow owns four spec-pinned responsibilities: 1. **UI → AppSpec.** A resolver reads `ui.json + flow.json + enclaves/*.json` and derives the canonical `schema.json → app.json → infra.json`. `flow.json` is the source of truth (encryption, `tableMap`, derived fields, sources, enclaves); each later file is a pure function of the ones before it — so you can hand-author `schema.json` and skip the UI inputs entirely. 2. **Ephemeral → real.** UI-Kit "ephemeral" mode flushes through Flow's resolver into real protocol events that **must be byte-equal** to direct commits — an app behaves identically whether driven through the dev backend or the real node. 3. **Helper-config bridge.** Actions, reads, derived views, plugin bindings, validators, aggregators, notifiers, and migrations become first-class AppSpec fields. 4. **DataView runtime.** Flow serves the cross-enclave reads ([dataviews](/sdk/cli-sdk-base#dataview)) that public apps project from. ### What makes it sound Each derivation stage is a pure function of its inputs, and the whole chain is spec-pinned in the app-codegen formalization: the ephemeral-vs-direct equivalence is a named invariant (events must be byte-equal), and the helper-config extension to the AppSpec is formalized too. So "author in the UI" and "author the schema directly" converge on the *same* canonical AppSpec, and "run in dev" and "run in prod" produce the *same* events. ### See also * [AppGen](/pipeline/appgen) consumes the AppSpec · [ENC UI Kit](/pipeline/ui-kit) authors the UI inputs * [App SDKs](/sdk/apps) — the `app.json` / `schema.json` Flow derives * [Pipeline overview](/pipeline) ## ENC Pipeline The ENC Pipeline is the toolchain that makes the protocol, its implementations, and the apps built on it **one artifact in many forms** — each derived from a single formally-verified specification and cryptographically linked to the others. It's how "the spec runs" (see the [litepaper](/litepaper)): humans write intent, agents resolve it to math, and every downstream form is a *verifiable derivation* rather than a hand-written translation. > **Not yet open source.** These tools are being prepared for open release. This section > documents **how they work** — the architecture and the soundness arguments — not how to use > them; there is no install or public API yet. The spine is four meta-protocols, plus three runtime tools the app layer builds on: | Tool | Role | | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | [SpecGen](/pipeline/specgen) | The framework — turns human intent into a checked Lean spec, a green release gate, and a signed report anyone can re-verify | | [CodeGen](/pipeline/codegen) | Emits every implementation (Lean reference, JS SDK, JS node, Rust/WASM kernel, Cloudflare Worker) byte-deterministically from the spec | | [AppGen](/pipeline/appgen) | Turns app intent into a formally-specified app from a closed vocabulary of verified building blocks, across 11 platforms | | [TestGen](/pipeline/testgen) | Proves every app's every workflow conforms across a platform matrix — the operational discharge of the "matrix theorem" | | [ENC Emulator](/pipeline/emulator) | A browser testbed that runs every app against a real node across the full tier matrix; a required release witness | | [ENC UI Kit](/pipeline/ui-kit) | An algebraic, manifest-driven UI layer whose state operations are proven to refine a Lean algebra | | [ENC Flow](/pipeline/flow) | The enclave-resolution engine that turns UI + flow config into the canonical AppSpec the rest of the pipeline consumes | ### The spine ``` intent → SpecGen → CodeGen → AppGen → TestGen │ │ │ └─ Flow ─ UI Kit ─ Emulator (app-layer runtime tools) ``` SpecGen makes the **spec the canonical artifact**; CodeGen derives the protocol's implementations from it; AppGen derives whole *apps* the same way; TestGen proves the result conforms on every platform. Flow, UI Kit, and Emulator are the app-layer tools generated apps run through — declarative app definition (Flow), algebraic UI (UI Kit), and the cross-platform testbed (Emulator). ### Why it exists A normal protocol ships through a chain of *vibe-coded* translations — prose → SDK → ports → CI → publish → deploy — where every arrow can silently disagree. None of the usual tools (typecheckers, unit tests, `npm audit`, signed commits) says the one thing that matters: *the production deployment realizes the human spec*. The pipeline collapses that chain — each form is generated and checked against the one above it — so that statement becomes a property you can verify, not a hope. This is the "agents build for agents" thesis from the [litepaper](/litepaper) made concrete: agents can't be trusted to write correct systems, but they can build the *machinery* that makes systems verifiable. ## SpecGen **A framework for protocols whose spec, code, and deploy are the same artifact.** Humans write intent and review the resolved spec; SpecGen formats the prose, extracts claims, induces a protocol DSL, binds it to Lean, generates the implementation, runs every downstream witness against the generated SDK, checks the published bytes equal the generated bytes, attests the deployment, and emits a signed report any agent can verify in ten deterministic steps. When the gate is green you don't have a spec *and* an implementation *and* a deployment that you hope agree — you have **one artifact in three forms**, each cryptographically linked to the others. ### How it works The loop makes each form a verifiable derivation of the one above it: 1. **Intent → prose.** Humans write seed intent and formatted prose in ordinary protocol language, carrying RFC 2119 normative force. 2. **Prose → claims.** The loop extracts claim sidecars bound to exact source spans of the prose. 3. **Claims → Lean.** Each claim binds to Lean definitions and theorems — the canonical form a kernel checks. 4. **Lean → reference.** An executable Lean model becomes the runnable reference (the referee). 5. **Lean → code.** [CodeGen](/pipeline/codegen) emits the SDKs, the node, the Rust/WASM kernel, and the Cloudflare Worker from that Lean — every emit byte-deterministic, each artifact's `sha256` pinned in a manifest. 6. **Witnesses.** Downstream consumers run their own tests against the *generated* SDK and emit a signed receipt naming exactly which bytes they ran. 7. **Release gate.** A Lean-anchored gate is true *iff* the structural invariants, operational witnesses, provenance records, and content-review decisions all agree. 8. **Signed report.** When the gate is green, SpecGen emits a signed report a third party can verify in ten pure, deterministic steps — no rebuild, no network, no trust in the publisher. A human can enter the loop at any point — prose, a claim, Lean, an implementation observation, or a witness failure — and the loop reconciles the other forms until it reaches the ceiling. ### What makes it sound The DSL vocabulary is **empirically auto-created** by agents and continuously revised — but it's never accepted as "vibe text." The loop admits a structure only when selectors bind to prose, claims bind to Lean, theorems compile, generated artifacts match their hashes, and witnesses pass against the generated SDK. The LLM proposes structure; the validators and the Lean kernel decide. ### Protocol-agnostic SpecGen ships the kernel, schemas, validator, codegen seams, witness registry, publish chain, and verifier — and **zero** application content. The ENC protocol is one *instance* that supplies its own Lean, prose, claims, and a single config; [CodeGen](/pipeline/codegen) is that instance's emit layer. ### See also * [CodeGen](/pipeline/codegen) · [AppGen](/pipeline/appgen) · [TestGen](/pipeline/testgen) * [Pipeline overview](/pipeline) · [Litepaper — closing the formalization gap](/litepaper) ## TestGen TestGen proves that **every implementation behaves identically across every platform**. One formalized workflow corpus runs across a matrix of tiers — **6 surfaces × 2 backends = 12 tiers** — and each tier must conform to a platform-free reference tier. There is *zero* per-platform test duplication: adapters supply thin shims, and a single tier-agnostic runner drives all workflows through all pools. ### How it works 1. **Adapter registry.** Each platform registers a `TestAdapter` per (surface, backend) pair; a factory turns declarative capability data into runnable matrix cells. 2. **Static validation.** Before any tier runs, workflows are checked app-agnostically for hard violations (lookup-before-use, no-self-send, capture-before-ref) — author-time errors that block the whole matrix. 3. **Tier-agnostic execution.** One `runWorkflow` walks a JSON workflow through any pool — onboarding, step execution, variable interpolation, settling — branching **zero** times on platform. 4. **Deterministic settling.** After each step, the runner awaits **frontier coverage** (ingested ≥ written per enclave) so a `see` assertion runs at quiescence — no timing constants, no flaky cross-user races. 5. **Conformance witness.** For a fixed app and seed, the reference tier and a candidate tier must produce *identical* observable trajectories and verdicts; any divergence is a hard failure — this catches tiers that pass for the wrong reason. 6. **Meta-fuzz.** Random *valid* app manifests (built from the same primitives real apps use) are generated by seeded PRNG, surfacing SDK regressions at far greater coverage than hand-written tests. 7. **Nuke protocol.** The first assertion failure SIGKILLs the whole process group; layered time budgets (per step / workflow / app / sweep) make hangs impossible. Results are deterministic by construction. ### What makes it sound This is the operational discharge of the **matrix theorem**: *for every workflow and every tier that accepts it, the tier's realization is observationally equal to the reference.* Settling is proven from sequential consistency on totally-ordered logs; conformance-fuzz proves a tier is a faithful realization rather than merely green; the nuke protocol makes the whole suite reproducible and hang-proof. If TestGen passes on all tiers, the implementation is proven sound for that spec revision. ### See also * [ENC Emulator](/pipeline/emulator) — runs the browser tiers of the matrix * [SpecGen](/pipeline/specgen) · [CodeGen](/pipeline/codegen) · [AppGen](/pipeline/appgen) * [Pipeline overview](/pipeline) ## ENC UI Kit The ENC UI Kit is an **algebraically-specified, manifest-driven UI layer**. It renders an app from its declarative manifests into a working interface on any platform, and its state engine is a pure-functional algebra whose operations are **proven in Lean** to refine the spec. ### How it works The pipeline is **Resolve → Apply → Render**: 1. **Resolve manifests.** A loader merges the app definition, pages, components, and workflow manifests into one canonical app model. 2. **Build the page tree.** Routing and page gates pick the active page; data bindings (`$state.field`) and `render_if` conditionals resolve against current state. 3. **Apply actions.** On a user action, a five-operation algebra — `set / insert / update / delete / query` — interprets the action expression and returns the next state **purely**, with no side effects. The *same* evaluator runs in Node tests and in the browser. 4. **Render.** The result is a platform-agnostic tree of typed atoms (text, input, select, toggle, slider, …); each platform renderer (React, React Native, terminal, extension) maps the tree to native widgets without any logic branching. ### What makes it sound The algebra is **formally specified in Lean**, and every operation is proven to refine that spec — the refinement contracts verify at CI. Because the same pure algebra drives both tests and runtime, *tests passing ⇒ runtime passing*: there is no framework divergence to hide a bug. A lint gate forbids any unregistered component literal, freezing the component catalog so the rendered surface cannot drift from the specified one. ### Figures * a five-operation algebra with 7 / 7 Lean refinement contracts verified * \~41 registered components; one evaluator shared across Node, happy-dom, and browser tiers * \~10 reference apps drive the matrix ### See also * [ENC Emulator](/pipeline/emulator) renders apps through the UI Kit · [ENC Flow](/pipeline/flow) feeds it manifests * [Pipeline overview](/pipeline) ## The ENC CLI `enc` is a **git-style CLI for end-to-end encrypted, append-only data**, built on the ENC protocol kernel compiled to WASM. You `init` a repo, append signed events, snapshot the state, and push to remotes — same shape as git, but every event is cryptographically signed, every state hash is auditable, and every byte is verifiable from a Signed Tree Head down to the last event. ```sh enc init enc identity new alice enc commit -m "hello enc" --priv-key $(enc identity show alice) enc log enc push origin ``` The `verify` commands run entirely against the locally embedded WASM kernel — no network, no trust. See the [Sparse Merkle Tree](/spec/kernel/smt) and [Certificate Transparency](/spec/kernel/ct) specs for what the proofs attest to, and [Run a Node](/guide/node) for the server side that `push`/`pull` talk to. ### Install #### From source ```sh git clone https://github.com/enc-protocol/impl-wasm cd impl-wasm cargo build --release --bin enc sudo install target/release/enc /usr/local/bin/ enc --version ``` You need a recent stable Rust toolchain (`rustup install stable`). The build embeds `enc-core.wasm` (\~256 KiB) at compile time — there's no separate kernel to install. #### Verify the install ```sh enc self-test ``` If that prints OK, you're done. ### Quick tour #### 1. Create a repo ```sh mkdir my-enc && cd my-enc enc init ``` This writes `.enc/` with `state.bin` (the enclave state) and `keys/` (signing identities) — the same shape as `.git/`. #### 2. Create a signing identity ```sh enc identity new alice enc identity list enc identity show alice # prints alice's pubkey ``` Identities are local Schnorr keypairs under `.enc/keys/`. Every commit needs one. Use `enc identity show alice` for the pubkey; the private key lives in `.enc/keys/alice.json`. #### 3. Commit signed events ```sh enc commit -m "first event" --priv-key ``` Commits are signed normative events: they advance the seq counter and feed into the next bundle. #### 4. Inspect state ```sh enc status # bundle counter, seq, memory enc log # linear event log enc log --limit 10 --json # machine-readable enc events # raw event dump enc find # by hex id enc show 0 # by index enc cat # raw content ``` #### 5. Close a bundle + read the STH ```sh enc close-bundle # flush events into a sealed bundle enc sth # current Signed Tree Head enc inclusion 0 # inclusion proof for bundle 0 enc verify-sth # check STH integrity ``` The Signed Tree Head is the canonical hash anyone can use to verify this enclave's state **without trusting you**. #### 6. Snapshot + restore (offline backup) ```sh enc snapshot ./backup.enc # full enclave dump enc verify-pack ./backup.enc # check pack integrity enc restore ./backup.enc # load it back ``` Snapshots round-trip byte-identically, and the same pack works on any host — CLI, Worker, or browser. ### Working with remotes ```sh enc remote add origin https://your-node.example.com enc remote list enc remote set-default origin enc push # send local bundles to the default remote enc push origin enc pull # pull bundles + STH from a remote enc fetch origin # fetch without applying # clone runs init + fetch + apply in one step: enc clone https://your-node.example.com/enclaves/ ./fresh ``` Point a remote at any [ENC node](/guide/node) — a Cloudflare Worker, a self-hosted `encd`, whatever speaks the protocol. ### Proofs The protocol gives you a cryptographic proof for everything: ```sh enc proof commit --commit-hash # commit proof enc proof inclusion --bundle 0 # bundle inclusion proof enc proof consistency --from 0 --to 5 # consistency proof enc verify --proof # verify any proof enc verify-pack ./backup.enc # pack integrity enc verify-sth # STH integrity ``` ### Other operations #### Crypto primitives ```sh enc sha256 hello enc generate-session --priv-key enc ecdh --priv-key --pub-key enc derive-key --shared --label app enc encrypt --key --plaintext "secret" enc decrypt --key --content ``` #### Migrate the sequencer key ```sh enc migrate --to ``` Migrates the enclave to a new sequencer key. The old key is invalid afterward and future commits sign under the new one — but old bundles stay verifiable. #### Append raw bytes (low-level) ```sh enc append "0102030405" ``` Skips the normative event-signing pipeline. Use only when you know exactly what you're doing. ### Full command list ``` Repo lifecycle init Initialize a new enclave repository init-with-priv Initialize from a specific sequencer key clone Clone a remote enclave migrate Migrate to a new sequencer key Identity identity new Create a new signing identity identity show Print pubkey identity list List local identities Events commit Append a signed normative event append Append raw bytes (low-level) status Show enclave state log Linear event log events Raw event dump find Find by event_id show Inspect by index or ref cat Raw content cat-file Typed object retrieval reflog Recent ref movements Bundles + packs close-bundle Seal the current bundle snapshot Full enclave dump restore Load a .enc pack verify-pack Pack integrity check Proofs sth Current Signed Tree Head inclusion Bundle inclusion proof verify-sth STH integrity verify --proof Verify any proof proof Build a typed proof Network remote add | list | remove | set-url | set-default pull [] Pull bundles + STH push [] Push local bundles fetch [] Fetch without applying Crypto sha256 SHA-256 self-test Kernel self-test suite generate-session Signed session token verify-session Check session token ecdh ECDH key agreement derive-key KDF encrypt AEAD encrypt decrypt AEAD decrypt Config config Read or write enclave-local config ``` Run `enc --help` for full flags. ### Where things live ``` .enc/ state.bin linear-memory snapshot of the WASM enclave keys/ signing identities (Schnorr) config local config overrides ``` Override the kernel (for testing) or the state directory: ```sh enc --wasm /path/to/custom.wasm enc --state-dir /elsewhere ``` ## Developer Guide Hands-on guides for building and running ENC infrastructure. Where the [Protocol Spec](/spec) is the normative reference and the [SDK Reference](/sdk) documents the libraries, these guides walk through the tools you actually run. | Guide | What you'll do | | ------------------------------------------- | -------------------------------------------------------------------------------------------------- | | [Run a Node](/guide/node) | Stand up an ENC node — the four runtimes, build, run, deploy (Cloudflare / bare-metal), and verify | | [The ENC CLI](/guide/cli) | Drive enclaves from the terminal with the git-style `enc` binary — init, commit, bundles, proofs | | [zkEnc](/guide/zkenc) | Validity proofs — run the prover service + verifier, deploy to Cloudflare | | [wshub](/guide/wshub) | WebSocket aggregation — collapse per-enclave sockets behind one connection | | [Building with the SDK](/guide/sdk) | Go from an identity to a verified read/write using the SDK packages | | [Agent Skills](/guide/skills) | Install a Claude Code skill so an agent can drive an ENC app via the `enc` CLI | | [Wallet Extension](/guide/wallet-extension) | Custody an ENC identity in the browser and let apps sign via `window.enc` | New to ENC? Read the [litepaper](/litepaper) for the *why*, then start with [Run a Node](/guide/node). To build on ENC from code, head to the [SDK Reference](/sdk). ## Run an ENC Node An **ENC node** hosts enclaves: it sequences signed events, folds them into verifiable state (a [Sparse Merkle Tree](/spec/kernel/smt)) and an append-only history (a [Certificate Transparency log](/spec/kernel/ct)), and serves both to clients over HTTP + WebSocket. The node never has to be trusted — every byte it emits is independently verifiable against the protocol's signed events. See the [Node API spec](/spec/node/node-api) for the full wire surface. The node logic is a single **kernel** compiled from the formally-verified Lean specification. That kernel ships in **four interchangeable runtimes**, all of which answer the same wire request **byte-identically**. ### The four runtimes | Runtime | What it is | Best for | | ---------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | **cf-worker** | TypeScript host loading the WASM kernel on Cloudflare Workers + Durable Objects | Production at scale — billions of per-user enclaves, geo-routed, zero-ops | | **rs-server** (`encd`) | Pure-Rust axum + wasmtime host; one static binary | Self-hosted / air-gapped / sovereign VM + bare-metal deploys | | **pure-JS Worker** | The kernel as pure JavaScript, emitted from the Lean spec | The cross-implementation parity reference | | **lean-host** | Runs the Lean program directly via a small Node byte-pump | Zero translation gap — the wire bytes are interpreted by the program that was *proven* correct; also the correctness referee | All three WASM-based runtimes load a **byte-identical** `enc-core.wasm`. The Lean runtime doesn't go through WASM at all — it *is* the Lean program. Cross-runtime tests assert all four produce identical output for the same input. > **Which should I run?** Pick **cf-worker** for anything that needs to scale past one > machine or be geo-distributed — it's the default for new deploys. Pick **rs-server** for > self-hosted, compliance, or air-gapped deploys where one VM is enough. **lean-host** and > **pure-JS** are correctness references rather than production targets. ### Install and build The runtimes live in the [`impl-node`](https://github.com/enc-protocol/impl-node) repository. The SDK packages, the JS node handlers, and most of the cf-worker host are **generated from the Lean specification** — `yarn build:js` runs that codegen. ```sh git clone https://github.com/enc-protocol/impl-node cd impl-node yarn install yarn build:js # regenerate SDK + node + worker from the Lean spec yarn test:pure # smoke test — 62 assertions, ~800ms, no env needed ``` For the Rust + WASM artifacts: ```sh yarn build:rs # build the Rust VM library (rs/) yarn build:wasm # wasm-pack build of the kernel yarn build # build:js + build:rs + build:wasm # The standalone Rust HTTP server is an independent crate: cd rs-server && cargo build --release ``` The generated files (`sdk/`, `js/node/`, and the codegen'd parts of `cf-worker/src/`) are **byte-pinned to the spec** — don't hand-edit them; changes are overwritten on the next `yarn build:js`, and the codegen idempotence gate will flag the drift. ### Run locally **cf-worker** (the production variant, run locally): ```sh cd cf-worker yarn install yarn wrangler dev # → http://127.0.0.1:8787 ``` Same kernel as production, backed by WASM + per-enclave SQLite Durable Objects. **rs-server** (`encd`): ```sh cd rs-server cargo build --release ./target/release/encd --port 8080 # → http://0.0.0.0:8080 ``` Loads the embedded WASM kernel once at startup; in-memory state by default. **pure-JS Worker** (the codegen reference): ```sh npx wrangler dev --config test/wrangler.toml --local # → http://127.0.0.1:8787 ``` Smoke-test a running node — bootstrap an enclave with a fixed throwaway key and read back the derived sequencer public key: ```sh ENC=$(python3 -c "print('ee'*32)") PRIV=$(python3 -c "print('33'*32)") curl -X POST http://localhost:8080/enclaves/$ENC/init-with-priv \ -H "Content-Type: application/json" \ -d "{\"enclave_id\":\"$ENC\",\"sequencer_priv\":\"$PRIV\"}" # → {"ok":true,"enclave_id":"ee…","sequencer_pubkey":"3c72addb…"} ``` The returned `sequencer_pubkey` is the deterministic BIP-340 derivation from the private key — **identical across every runtime** for the same input, which is how kernel parity is checked by eye. ### Test ```sh yarn test:pure # 62 assertions, ~800ms, no env yarn test # full JS suite (property + node + verifier + fuzz + persistence + …) yarn test:cross # cross-implementation parity vectors (JS ↔ Rust ↔ WASM ↔ Schnorr ↔ RBAC) yarn test:rs # cargo test for the Rust VM library yarn test:all # test + test:cross + test:rs cd rs-server && cargo test --release # rs-server (encd) unit tests ``` Most JS suites spawn `wrangler dev` and exercise the pure-JS Worker; raise `WRANGLER_TIMEOUT` on slower machines. ### Deploy #### Cloudflare (cf-worker) — the scale path Prerequisites: a Cloudflare account with Workers + Durable Objects enabled, an authenticated `wrangler` (`npx wrangler login`), and an R2 bucket for closed-bundle archival. ```sh cd cf-worker yarn install yarn wrangler deploy --dry-run # preflight: type-check + bundle size yarn wrangler deploy ``` The first deploy runs migrations that create the `EnclaveDO` and `RegistryDO` Durable Object classes. Per-enclave isolation is automatic: each `enclave_id` routes to its own Durable Object — `idFromName(enclave_id)` — with independent SQLite storage. Cloudflare spawns and evicts isolates globally on demand, so scaling from one enclave to billions is the same code path. #### VM / bare-metal (rs-server / `encd`) — the self-hosted path Build a static `musl` binary (no glibc dependency) and run it under systemd: ```sh rustup target add x86_64-unknown-linux-musl cd rs-server cargo build --release --target x86_64-unknown-linux-musl # → target/x86_64-unknown-linux-musl/release/encd (one static binary) ``` Copy it to the host and register a systemd unit: ```ini [Unit] Description=encd — ENC protocol node After=network.target [Service] ExecStart=/usr/local/bin/encd --port 8080 Restart=on-failure DynamicUser=yes [Install] WantedBy=multi-user.target ``` The MVP ships with in-memory state; wire an external store (sled / rocksdb / Postgres) for persistence across restarts. ### Verify a deploy The `enc` CLI ships an end-to-end test harness that drives a full workflow against any node URL — enclave creation, signed commits, push, close-bundle, and signed STH retrieval: ```sh # from a checkout of the enc CLI: cargo build --release --bin enc # Personal-enclave workflow against any node: ./scripts/enc-test.sh --workflow personal --remote https://your-node.example.com # Expected: 16/16 ok # Group RBAC workflow: ./scripts/enc-test.sh --workflow group # Expected: 14/14 ok ``` A green run confirms the full host-sign-seq round-trip works against your deploy. ### How a node protects the sequencer key A node signs every sequencing event, but the **sequencer private key never lives in the kernel's WASM memory**. The kernel declares a `host_sign_seq` import; the host answers it: 1. The kernel computes the 32-byte message hash and calls `host_sign_seq`. 2. The host retrieves the private key from off-WASM storage — Durable Object SQLite on cf-worker, the Rust process heap on rs-server. 3. The host signs with BIP-340 Schnorr (`@noble/curves` in the JS hosts, the `k256` crate in rs-server) and writes the 64-byte signature back into WASM memory. 4. The kernel embeds the signature in the event record. Why it matters: portable snapshots (`GET //snapshot`) are **keyless**. An attacker who dumps a node's memory finds the public key but never the private one — so backups can be archived or shared without ever leaking the signing identity. ### Admission hooks — deployment policy, not protocol A node admits commits through a configurable **hook chain**. Each hook either passes a request or rejects it — for example, requiring a valid Cloudflare Access JWT before any state-changing action: ```typescript const cfAccessJwtHook: AdmissionHook = async (request, action, env) => { if (PUBLIC_ACTIONS.has(`${request.method} ${action}`)) return { pass: true }; const jwt = request.headers.get('Cf-Access-Jwt-Assertion'); if (!jwt) return { pass: false, reject: { code: 'unauthorized', message: 'missing JWT', status: 401 } }; const claims = await verifyCfAccessJwt(jwt, env); return claims ? { pass: true } : { pass: false, reject: { code: 'unauthorized', message: 'invalid JWT', status: 401 } }; }; admissionChain.push(cfAccessJwtHook); ``` Hooks are **orthogonal to the protocol**: a Lean theorem (`replay_invariant_under_hook_swap`) proves that adding or removing hooks never changes protocol-layer determinism. They are pure deployment policy — gate access, add auth, throttle — laid over the verifiable event stream without altering a single emitted byte. ### Choosing a runtime for scale A single-VM benchmark says rs-server is several times faster per core — but that's the wrong question for production. At Telegram scale (\~900M users, \~50K peak msgs/sec) what matters is hosting **billions of enclaves with per-user isolation**, geo-routed and crash-surviving: * **cf-worker** gets that for free. Each enclave is its own Durable Object with its own storage, routed by `idFromName(enclave_id)`, spawned and evicted by Cloudflare globally. Scaling 1 → 1B enclaves is the same code path, zero-ops. * **rs-server** can do it too — but you run the VM fleet, sticky-route by `enclave_id`, replicate each enclave's state, and operate that infrastructure yourself. This is safe because **distinct enclaves have disjoint commit histories** — proved in the Lean spec as the theorem `enclave_isolation`. When a request lands on an enclave's Durable Object, that DO's storage holds only that enclave's commits, so no cross-enclave coordination is ever needed. For a hot enclave (a single busy chat at 25–50K msgs/sec), cf-worker can split one logical enclave across N physical Durable Objects via jump-hash routing, with every routing decision anchored by Lean theorems. Both runtimes load the same kernel and produce byte-identical events — the choice is operational, not protocol-level. ### Performance All four runtimes are fast, and the formally-verified ones are not a tax: | Runtime | `GET /` ops/sec (N=1000, single laptop, loopback) | | -------------------------- | ------------------------------------------------: | | **lean-host** (Lean) | **6,083** | | encd (Rust) | 5,497 | | cf-worker (workerd, local) | 660 | Choosing the Lean runtime — the program that was literally proven correct — costs nothing at the framing layer; at N=1000 it actually crosses ahead of native Rust. (Local `wrangler dev` understates cf-worker badly; the production Cloudflare deploy measures roughly 1,755 req/s.) ## Building with the SDK A short tour of building on ENC from code. Where the [SDK Reference](/sdk) documents every function, this gets you from zero to a verified read/write in a few steps using the [`@enc-protocol/client`](/sdk/client) high-level surface (which re-exports the [`core`](/sdk/core) primitives it needs). ### Install ```bash npm config set @enc-protocol:registry https://npm-registry.ocrybit.workers.dev/ npm install @enc-protocol/client @enc-protocol/core ``` ### 1. Create an identity ```js import { createIdentity, createNodeClient } from '@enc-protocol/client' const me = createIdentity() // random Schnorr keypair const client = createNodeClient('https://your-node.example.com') ``` An identity is just a keypair; nothing is registered with anyone. Point the client at any [ENC node](/guide/node). ### 2. Create an enclave An enclave is defined by a **manifest** — its RBAC schema, states, traits, and initial roles. Submitting a signed manifest commit mints the enclave; its id is derived deterministically from the manifest. ```js import { createManifestCommit } from '@enc-protocol/client' const manifest = JSON.stringify({ enc_v: 2, nonce: Date.now(), RBAC: { use_temp: 'none', schema: [ { event: 'post', role: 'owner', ops: ['C', 'U', 'D'] }, { event: '*', role: 'Public', ops: ['R'] }, ], states: [], traits: ['owner(0)'], initial_state: { owner: [me.publicKeyHex] }, }, }) const manifestCommit = createManifestCommit(me, manifest) const enclaveId = manifestCommit.enclave // deterministic id const { sequencer: seqPubHex } = await client.submitCommit(manifestCommit) ``` ### 3. Submit and read events ```js import { createCommit, verifyNodeReceipt } from '@enc-protocol/client' // write const receipt = await client.submitCommit( createCommit(me, enclaveId, 'post', JSON.stringify({ body: 'hello' })), ) console.log(verifyNodeReceipt(receipt, seqPubHex)) // true — node co-signed it // read const { events } = await client.pull(-1, { enclave: enclaveId, limit: 100 }) for (const e of events) console.log(e.seq, e.type, e.content) ``` ### 4. Verify without trusting the node Every read is independently checkable. Verify the Signed Tree Head and each event's signatures locally — no trust in the operator: ```js import { verifySTH, hexToBytes } from '@enc-protocol/core/crypto.js' import { verifyEvent } from '@enc-protocol/core/event.js' const sth = await client.getSTH(enclaveId) console.log(verifySTH(sth.t, sth.ts, hexToBytes(sth.r), sth.sig, hexToBytes(seqPubHex))) for (const e of events) console.log(e.seq, verifyEvent(e)) // author + sequencer sigs ``` For inclusion and consistency proofs, see [`core`'s CT module](/sdk/core#ctjs). ### Pick your level | You want to… | Use | | ---------------------------------------------------- | --------------------------------------------------- | | Drive an existing app with typed methods | an [App SDK](/sdk/apps) — `@enc-protocol/-cli` | | Build your own app / protocol logic | [`core`](/sdk/core) + [`client`](/sdk/client) | | Run the whole protocol in-process (tests, local dev) | [`memory`](/sdk/memory) | | Encrypt content | a [confidentiality plugin](/sdk/plugins) | ### See also * [SDK Reference](/sdk) — the full API * [Run a Node](/guide/node) — what `submitCommit` / `pull` talk to * [Agent Skills](/guide/skills) — drive ENC apps from an agent ## Agent Skills Each ENC app ships a **Claude Code skill** — an installable agent capability that teaches an agent *when* to invoke the app and *what* to run. After installation, Claude Code surfaces it as an `/enc ` slash command in your project, and the agent drives the app through the [`enc` CLI](/sdk/cli). ### Install a skill ```bash # 1. Install the global enc CLI once npm install -g @enc-protocol/cli --registry https://npm-registry.ocrybit.workers.dev/ # 2. Add a skill to your project (current dir) enc skill add personal ``` That command npm-installs `@enc-protocol/skill-personal`, then symlinks its `SKILL.md` into `./.claude/commands/personal.md`. Claude Code picks it up as a project slash command. Remove it with `enc skill remove personal`. ### Skill anatomy A skill is a single `SKILL.md` file with YAML frontmatter plus a body listing the typed commands: ```markdown --- name: enc-personal description: Interact with the enc-protocol `personal` app — write data_types (public, private); read views (public, profiles, private, notices). --- # enc-personal Use this skill to interact with the enc-protocol `personal` app via the `enc` CLI. App-level commands (preferred, v2 app-driven surface): - `enc personal submit public ''` [write] dataType → public - `enc personal submit private ''` [write] dataType → private (encrypted) - `enc personal query public` [read] → public - `enc personal query profiles` [read, cross_enclave dataview] ## When to use this skill - The user asks about the `personal` app on enc-protocol. - The user wants an operation that maps to one of the commands above. ## Output formats - Default: human-readable text (one event per line for queries). - `--json` for structured output (preferred when chaining commands). - `--mem` for the in-process backend; default is cf via NODE_URL. ``` ### How an agent uses it 1. **Discovery** — Claude Code scans `.claude/commands/*.md` at session start; each skill's frontmatter `name` + `description` go into the available-skills list. 2. **Invocation** — when the user's request matches the description (e.g. "post a public note"), the agent selects the skill and follows the body, which tells it *which* command to run. 3. **Execution** — the agent shells out, e.g. `enc personal submit public '{"draft":"hi"}'`. Output returns over stdout — plain text by default, JSON with `--json`. 4. **Removal** — `enc skill remove personal` deletes the symlink. ### Why skills, not MCP The whole interface is `enc …` over Bash; the `SKILL.md` only tells the agent *when* to use it and *what to invoke*. No MCP server is needed — any agent that can shell out (Claude Code, Codex, Aider) consumes skills directly. MCP would matter for browser-only / sandboxed agents that can't shell out; that path stays open via the per-app SDK. ### Available skills | Slash command | Package | App | | --------------- | ------------------------------ | ------------------------------ | | `/enc personal` | `@enc-protocol/skill-personal` | [personal](/sdk/apps/personal) | | `/enc dm` | `@enc-protocol/skill-dm` | [dm](/sdk/apps/dm) | | `/enc group` | `@enc-protocol/skill-group` | [group](/sdk/apps/group) | | `/enc super` | `@enc-protocol/skill-super` | [super](/sdk/apps/super) | | `/enc registry` | `@enc-protocol/skill-registry` | [registry](/sdk/apps/registry) | | `/enc node` | `@enc-protocol/skill-node` | [node](/sdk/apps/node) | ### Generation Each `SKILL.md` is **auto-generated** from the app's CLI definition (`cli.json`) — it lists every typed `submit` / `query` command, examples, flags, and the underlying enclaves, and is drift-checked against the app's schema. A skill is never hand-written: it's a projection of the same app definition that drives the [App SDK](/sdk/apps) and the [CLI](/sdk/cli), so it can't fall out of sync with what the app actually does. ### See also * [`@enc-protocol/cli`](/sdk/cli) — the `enc` binary the skills drive * [App SDKs](/sdk/apps) — the typed programmatic surface behind each skill * [Building with the SDK](/guide/sdk) ## ENC Wallet Extension The ENC Wallet Extension is a Chrome (MV3) browser extension that **custodies a user's ENC identity and signs on behalf of apps** — so an app never touches the private key. It's a BIP-39 seed-phrase HD wallet (an ENC Schnorr identity plus an EVM address) that injects a `window.enc` provider any ENC web app connects to through a standard Connect-Wallet flow. It's also NIP-07-compatible (mirrored as `window.nostr`). This is the **delegated-custody signer** in ENC's [identity model](/spec/kernel/spec): the `identity_priv` stays inside the extension; the page asks it to sign, derive, or encrypt and gets results back — never the key. ### Connecting from an app ```js // the provider is injected on page load; wait for it if it isn't there yet if (!window.enc) await new Promise(r => window.addEventListener('encReady', r, { once: true })) const { approved, pubKey, evmAddress } = await window.enc.connect() // first call shows an approval popup if (approved) console.log('ENC identity:', pubKey) // x-only 32-byte hex ``` Once connected, signing and encryption calls run **without a popup** (the user approved the origin); only `connect()` and `sendTransaction()` prompt. ### The `window.enc` provider #### Connection & accounts | Method | Returns | Notes | | ---------------------- | ----------------------------------------------- | ------------------------------------- | | `connect()` | `{ approved, pubKey, evmAddress }` | approval popup on first use | | `getPublicKey()` | `string` | x-only 32-byte hex (the ENC identity) | | `getEvmAddress()` | `string` | cached `0x…` address | | `isConnected()` | `boolean` | | | `getAccounts()` | `{ accounts: [{ index, pubKey, evmAddress }] }` | HD-derived accounts | | `switchAccount(index)` | `{ pubKey, evmAddress }` | emits `accountChanged` | | `getVersion()` | `string` | | #### Signing (no popup) ```js const sig = await window.enc.signSchnorr(hashHex) // BIP-340 over a 32-byte hash → signature hex ``` This is how an app gets a commit signed: build the commit hash, ask the extension to `signSchnorr` it, and attach the signature. The key never leaves the extension. #### Session keys (no popup) ```js const { session, sessionPriv, expires } = await window.enc.createSession(7200) // seconds ``` A short-lived session key for WebSocket auth — see [the session model](/sdk/core#session-management). #### ECDH & encryption (no popup) ```js const shared = await window.enc.ecdh(peerPubHex) // 32-byte hex const key = await window.enc.deriveKey(shared, 'enc:dm:a-b') // HKDF → 32-byte hex const ct = await window.enc.encrypt(key, 'secret') // XChaCha20-Poly1305 → base64 const pt = await window.enc.decrypt(key, ct) ``` The same primitives as [`@enc-protocol/core`](/sdk/core#ecdh-encryption), but computed inside the extension against the custodied key. #### EVM transactions (popup confirm) ```js const { hash } = await window.enc.sendTransaction({ to, amount, memo }) // shows a confirmation popup ``` #### Events ```js window.enc.on('connect', ({ pubKey, evmAddress }) => {}) window.enc.on('disconnect', () => {}) window.enc.on('accountChanged', ({ pubKey, accountIndex }) => {}) ``` #### Seed import `exportSeed()` returns the 12-word mnemonic — used by an app's "import this wallet" flow so the same identity is available to the in-app SDK. ### Install for development The extension ships as a built zip. To run it locally: 1. Build the zip (`yarn build`) and unzip it somewhere stable. 2. Open `chrome://extensions`, enable Developer Mode, click **Load unpacked**, and select the extracted `extension-chrome/` folder. 3. Reload any open ENC app — the Connect-Wallet row should now show the extension as available. The content script injects `window.enc` on every page, so any ENC web app can detect and connect to it. ### See also * [Building with the SDK](/guide/sdk) — pair the signer with the client SDK * [Identity & custody](/spec/kernel/spec) · [`core` crypto](/sdk/core) ## wshub — WebSocket aggregation **enc-wshub** is a WebSocket aggregator hub for the ENC protocol. It collapses per-enclave client sockets into **one** client connection, multiplexed over shared, refcounted per-enclave upstream sockets to the [node](/guide/node): ``` client ──(1 WS)──> HubDO ──(1 refcounted WS per enclave)──> enc-node DOs ``` ### Why Cloudflare binds an accepted WebSocket to the single Durable Object that accepted it, and each enclave is its own DO. A client that watches its DM + Personal + N group enclaves needs **N+2 sockets**; a 100-member group pins **100 sockets** to that enclave's DO. Connection count is **O(clients × enclaves)**. The ENC wire protocol was already built for multiplexing — auth is per-message (every Query carries its own session) and the node tags every frame with a `sub_id`. The hub reclaims that capacity: * One client socket carries unlimited subscriptions to any mix of enclaves. * The hub keeps **exactly one upstream socket per enclave**, shared by every client of this hub shard, refcounted open/closed. * Query frames are forwarded **verbatim** — `{session, filter}` stays opaque; the node verifies each client's session and enforces per-client RBAC. The hub is a **metadata-only `sub_id` router, never an auth point**. * `sub_id`s are translated client-local ↔ hub-global, so two clients can both use `"s1"` without colliding on the shared upstream. Result: connection count drops to **O(clients) + O(distinct live enclaves)**. This is the network-aggregation layer described in the [litepaper](/litepaper). ### Architecture The hub is a Cloudflare Worker plus one Durable Object class, `HubDO`: * The Worker's `fetch()` routes WS upgrades to a hub shard (`?shard=`, default `hub-0`) via `idFromName`. **Non-WS requests** (Commit / Query / Pull / Info POSTs, `GET /enclave//sth`) are transparently **proxied to the node**, so clients can point a single base URL at the hub. * `HubDO` accepts the WS upgrade as **hibernatable** and assigns a unique connection id. * On each `Query` it writes a route `gid = .`, ensures an upstream socket for that enclave (opening one if needed, bumping its refcount otherwise), and forwards the Query with `sub_id := gid`. * On every upstream frame it translates `sub_id` back to the client's local id and forwards. On `Closed` it drops the route and releases the upstream refcount; an upstream that hits refcount 0 is closed. * If an upstream socket dies, affected clients are notified with a per-sub `Closed { reason: "upstream_…" }` so they re-subscribe (and re-backfill). A client disconnect tears down all of that client's routes. ### Wire contract The hub is the boring side of the contract. Because one upstream WS now carries many subs from different clients, **the node must accept the caller-supplied `sub_id` and echo it on every frame** so the hub can demux. Required node behaviors: 1. **`Query` accepts a caller-supplied `sub_id`** (used verbatim) and echoes it on `Event` / `EOSE` / `Closed` / `Error` frames. 2. **`Close { sub_id }`** removes only that subscription, leaving the other subs on the same WS alive. 3. **DO hibernation preserves all subs on the WS**, not just the last one — the full sub-list is restored on wake-up. 4. **Tags persist across the storage round-trip**, so SQL backfill returns the same `tags` (`["to"]`, `["enclave_id"]`, …) that strict-wire decryption needs — not just the live in-memory broadcast. ENC's reference node implementation satisfies all four, with protocol regression guards in its test suite; a node that drops these behaviors will fail them. ### Develop ```bash # 1. spawn the mock node (default :18890) yarn mock-node # 2. spawn the hub (default :8787 via wrangler dev) yarn dev # 3. drive 100 clients through it N=100 yarn bench # pure-model numbers (no I/O) yarn bench:sim # type-check yarn typecheck ``` ### Deploy ```bash # point at the live node first wrangler secret put ENC_NODE_URL # or set it in wrangler.jsonc vars yarn deploy ``` The Worker is exposed at `enc-wshub..workers.dev` (Cloudflare auto-subdomain). Clients then use `wss://enc-wshub..workers.dev/?shard=` as their WS endpoint and the same hostname for HTTP — the hub transparently proxies non-WS traffic to `ENC_NODE_URL`. ### Sharding `?shard=` picks the `HubDO` instance; the default `hub-0` is a single shard. For horizontal scale, hash the client's pubkey (or any stable per-client key) and pass it as `shard` — each shard is its own DO with its own upstream sockets. The number of distinct enclaves seen across all clients on a shard is that shard's upstream socket budget — roughly `N_clients_on_shard × avg_enclaves_per_client / overlap_factor`. ### Client integration A client integrates the hub behind the same `subscribe()` call it already uses; a single `HubWS` instance per node URL multiplexes every subscription over one socket. Each subscription gets a caller-supplied `sub_id` (derived from the enclave id plus a per-instance counter) that is unique within the `HubWS` instance, so the switch between per-enclave sockets and hub multiplexing is transparent to application code. ### Heartbeats Both directions ping every 25 s with a 10 s pong deadline: * **Client → hub** — keeps the client WS alive across Cloudflare edge idle timeouts; on a missed pong the client closes and reconnects. * **Hub → upstream node** — Cloudflare Workers silently idle-close an outbound WS after \~100 s without always firing a close event, so the hub force-closes a stale upstream on a missed pong, notifies routed clients with `Closed { reason: "upstream_closed" }`, and they re-subscribe (new upstream + backfill catches up missed events). Plain `'ping'` gets a plain `'pong'` (no JSON envelope), and the hub forwards client pings to the upstream so a silent idle drop surfaces on either side. ### Limits * **HubDO hibernation.** When all client WSes are idle, Cloudflare can hibernate the `HubDO`; the in-memory route/upstream maps are gone on wake-up and the first Query rebuilds them and reconnects a fresh upstream. Clients see a `Closed { upstream_closed }` on their next frame and re-subscribe. * **Single-DO hot spot.** A shard handles all its clients in one (single-threaded) DO. For high-traffic regions, shard horizontally via `?shard=`. * **No bandwidth metering.** The hub doesn't enforce per-client throughput; abuse mitigation lives at the Cloudflare edge (rate limiting / WAF). ## zkEnc — validity proofs **zkEnc** gives ENC a succinct proof that an enclave's RBAC state was reached by **valid** transitions — so a node acting as prover **cannot forge authorization**, and a client verifies the whole history in **one O(1) check**: no replay, no trust in the operator for validity. > ENC's `SMT proof + CT inclusion + STH` chain authenticates *what the node says the state > is* — not *that the node computed it honestly*. A malicious node could write > `Mallory = admin`, commit it, sign it, and every membership proof still verifies. zkEnc > closes that gap. This is the implemented form of the validity story in the [litepaper](/litepaper); the normative protocol surface is [`/spec/node/zk`](/spec/node/zk). It ships three things: * a **prover** — a bounded Groth16 circuit plus an unbounded Nova-IVC folding prover; * a **deployable service** (`prover-service`) that folds a node's transitions and serves proofs, running as a **Cloudflare Container**; * an **out-of-circuit verifier** (`verifier`) — the client that checks a proof against the node's signed log. ### Quick start (≈2 min, no node required) Verify a **real, captured** validity proof end-to-end, offline: ```bash git clone https://github.com/enc-protocol/impl-zk zkEnc && cd zkEnc cd verifier cargo run --release --bin verify_anchor -- ../e2e/anchor_vector.json ``` First build is \~1 min; then it recomputes the verifying key (\~8 s) and prints: ``` ✓ ACCEPT — r_n is a valid RBAC successor of the log-anchored r_0, verified in O(1) ``` That fixture (`e2e/anchor_vector.json`) was captured from a live node: the verifier checks the node's **STH** (Schnorr) + **CT inclusion** proofs and the **folding proof** — exactly what a real client does. ### Install * **Rust** (stable) — the only hard dependency for building and testing. * **Node.js** (18+) — only for the `e2e/` drivers (they import the node SDK from a sibling [`impl-node`](https://github.com/enc-protocol/impl-node) checkout at `../impl-node`). * **Docker + buildx** — only to build/run the container or deploy to Cloudflare. No system libraries and **no trusted-setup ceremony** are needed — the folding path uses transparent IPA commitments. ### Run locally #### Build & test each component ```bash # bounded Groth16 circuit (root crate) cargo test --release # circuit + live-node real-root validity proof + cross-impl parity cargo run --release # the bounded prove → verify demo # folding (Nova IVC) prover library cd folding && cargo test --release cd folding && cargo run --release --example fold_chain # fold a chain → compress → verify # out-of-circuit client verifier cd verifier && cargo test --release ``` #### The prover service + a verifier The service folds a node's finalized transitions into one IVC chain per enclave and serves proofs; the verifier consumes one and anchors it to the node's signed log. ```bash # 1. boot the service (precomputes PublicParams once, then listens on :8799) cd prover-service && PROVER_ADDR=127.0.0.1:8799 cargo run --release # 2. verify a captured proof offline (no node needed) cd verifier && cargo run --release --bin verify_anchor -- ../e2e/anchor_vector.json # 3. one service, many enclaves (folds one captured transition under N enclave ids) PROVER_URL=http://127.0.0.1:8799 node e2e/multi_enclave.mjs # → /health enclaves=N ``` #### Against a live node The `e2e/` drivers exercise a real node — a sibling [`impl-node`](https://github.com/enc-protocol/impl-node) checkout run via `wrangler dev`: ```bash # terminal 1 — the node cd ../impl-node && CI=1 npx wrangler dev --local --port 8798 --config test/wrangler.toml # terminal 2 — drive it (drivers honor NODE_URL / PROVER_URL) node e2e/export_transition.mjs # capture one real transition (roots + wire proofs) node e2e/capture_anchor.mjs # full anchor vector (STH + CT inclusion + folded proof) ``` ### Deploy to Cloudflare The service runs as a **Cloudflare Container**: ```bash # build/run the image locally first (needs Docker + buildx) docker build -f prover-service/Dockerfile -t enc-prover . docker run -p 8799:8799 enc-prover # then deploy cd prover-service/cf && npm install wrangler secret put PROVER_AUTH_TOKEN # + PROVER_S3_ACCESS_KEY / _SECRET_KEY for R2 wrangler deploy ``` The full production runbook — R2 checkpoints, auth, verifying-key publishing, the node → prover follower, instance sizing — lives in [`prover-service/DEPLOY.md`](https://github.com/enc-protocol/impl-zk/blob/main/prover-service/DEPLOY.md). ### Cost, performance & scaling One container = one node's enclaves (a shared `PublicParams` plus a per-enclave IVC chain). Measured for the `UniversalStep` (\~152k constraints/step) on a full CPU core: | Operation | Cost | Notes | | ---------------------------------------- | ------------- | --------------------------------------------------- | | Boot (params + pp + compress keys) | **\~8 s** | one-time per cold start — keep the instance warm | | **Fold one transition** (prove + verify) | **\~0.45 s** | only state-changing transitions are folded | | Compress + serve a proof (Spartan) | **\~11–16 s** | **cache it**; recompute on a cadence, not per query | | `GET /root` (freshness) | \~0.2 ms | cheap; poll this, fetch `/proof` only when needed | | Compressed proof size | \~28 KB | served to clients (`O(log n)`) | Memory is the sizing driver: **\~900 MB (shared pp + commitment keys) + `PROVER_MAX_HOT` × \~10 MB** per hot chain. The defaults (`max_hot=256`) fit a 4 GiB instance. Cold chains live in the store, not RAM, so **the number of enclaves is bounded by storage, not memory**. The economics hinge on one fact: **proving touches only state-changing transitions, and RBAC events are rare** — so steady state is a warm, mostly-idle instance (paying memory) with occasional CPU bursts, not continuous proving. Two levers matter most: **cache proofs** (the \~11–16 s compress dwarfs everything; serve cached and recompress on a checkpoint cadence), and choose **keep-warm** (pay memory, skip the 8 s cold-start) vs **scale-to-zero** (pay cold starts) per your transition cadence. To scale past one instance, **shard enclaves by id** across containers — throughput scales linearly, with the \~900 MB pp as the fixed per-shard overhead. ### How it works | Role | Who | Trust | | --------- | ----------------------------- | ----------------------------------------- | | Sequencer | Node | Orders events; **untrusted** for validity | | Prover | Node (or a delegated service) | Holds the witness; produces the proof | | Verifier | Client | Public inputs + proof only, **O(1)** | The validity relation `old_root → new_root` is proven in two complementary forms: * **Bounded** — a Groth16 proof of one transition (or a fixed batch). On-chain-friendly, O(1) verify. * **Unbounded (folding)** — a Nova IVC proof folding the enclave's *entire* transition history (O(1) prover work per step), then Spartan-compressed to one succinct proof. Needed because the SMT also holds arbitrary app state (KV namespace `0x02`), so transitions are unbounded over an enclave's lifetime. The SMT hashes with **Poseidon2 over BN254** (`t=3`, `x^5`, `R_F=8`, `R_P=56`) so membership is proven natively in-circuit, byte-identical across the JS node, the `encvm` Rust logic, and every circuit here. Signatures, CBOR/event hashing, CT inclusion, and STH verification stay **out of circuit** (ordinary crypto), linked to the proof only by equality of the public roots — so the client does \~3 cheap checks plus the O(1) proof. ### Why this stack Every choice is forced by the shape of ENC's problem: a node is an **untrusted sequencer**, each enclave's state is a **Sparse Merkle Tree**, transitions are **RBAC events**, and the client must verify in **O(1)**. The hard part is proving SMT membership inside the circuit; everything else is comparatively cheap. * **Folding (Nova IVC), not just a bounded SNARK.** The SMT holds arbitrary application state, so transitions per enclave are unbounded — a bounded circuit always has a ceiling. Nova folds each transition into one running instance (O(1) work/step, constant memory), then Spartan-compresses to one O(1)-verifiable proof of the whole history. * **Poseidon2 over BN254 — match the hash to the field.** SMT membership dominates the circuit. A byte-oriented hash (SHA-256) costs tens of thousands of constraints per call; an algebraic `x^5` hash costs a few hundred. BN254 is both the Groth16 pairing curve and the base of Nova's BN254/Grumpkin cycle, so one field serves the SMT, the bounded circuit, and the folding cycle. * **Transparent IPA commitments — no ceremony.** ENC is permissionless, so a per-circuit trusted setup is a trust liability. Nova on IPA/Pedersen is transparent: no setup, deterministic verifying key. The tradeoff is an `O(log n)` (not `O(1)`) compressed proof, acceptable for a client already doing CT/STH checks. * **Witness the op and the schema — one `PublicParams` for the whole node.** Nova requires identical constraint matrices across folded steps. zkEnc makes the op a one-hot input and the schema a witnessed input bound to a manifest commitment, so one \~900 MB `PublicParams` folds every enclave's transitions under any schema — which is what lets a single `prover-service` serve a whole node. * **Prove only what needs proving; bind the rest by equality.** Putting SHA-256, CBOR, and Schnorr in R1CS would cost millions of constraints per event for zero soundness gain. zkEnc keeps them out of circuit and links them by equality of the public field elements — reusing ENC's existing STH/CT/Schnorr infrastructure instead of re-deriving it in a SNARK. The net: unbounded history → folding; membership-dominated cost → Poseidon2/BN254; permissionless prover → transparent IPA; many enclaves/schemas → witnessed op+schema, one shared key; existing signature/log infra → out-of-circuit binding. Each is the cheapest sound choice for *this* relation.