# The locks, pre-built Microtoll Engine is the security layer of an end-to-end-encrypted app, packaged so the next app does not have to invent it: **sign-in without passwords, keys that never reach the server, sharing by capability, and revocation that actually takes access away.** Browser packages with zero dependencies, a server that holds only what it cannot read, a threat model that says what is not protected, and test vectors from the RFCs for every primitive. ## The problem it exists for Most new code is now written with an AI coding agent, and the four things those agents get wrong are the four things that cannot be got wrong: how a person signs in, where the keys live, who may read what, and what happens when someone is removed. The code they produce looks right — a bcrypt here, an AES call there, a token in local storage — and passes review, because security code that is wrong reads exactly like security code that is right. The failures surface a year later, in someone else's inbox. The engine's answer is not another library of primitives. It is the **finished constructions**: an account with no password and no email; a root key wrapped by a passkey and by a recovery code; a session that survives a reload and dies on "sign out everywhere"; an object whose key is sealed to each member and rotated when one is removed; a share link whose secret never reaches the server; a mailbox two people can compute and the server cannot. Each was built for a real app, fixed where an audit found it wanting, and comes with the reasoning written down. ## What you get - **`@microtoll/crypto-core`** — AES-256-GCM, HKDF, PBKDF2, Ed25519, P-256 sealing, the versioned wire formats, and an optional hybrid post-quantum seal. Web Crypto only; RFC and NIST vectors through the public API. - **`@microtoll/identity`** — the account root key, passkeys as a PRF and recovery codes as the two ways in, the trusted-device session, the private settings blob, deletion. Your screens, as callbacks. - **`@microtoll/access`** — an object with a key sealed per member, admin and read capabilities the server holds only hashes of, share links, removal that re-keys everything in one checked transaction, a second tier for the sensitive part. - **`@microtoll/mailbox`** — direct invitations under labels only the two parties can compute. - **`@microtoll/blind-store`** — the server: the handshake, the sealed tables, the coarse-selector query, live watches, the sweep, a hardened Docker deployment. It stores ciphertext, hashes and one coarse selector per object, and a test fails if a column that could identify anyone is ever added. - **`@microtoll/mcp`** — the docs and a scaffold inside Claude Code, Cursor or any Model Context Protocol host, so an agent can wire the engine in without leaving the editor. ## What it is not - **Not a hosted service.** You run the server; there is no account with us, no dashboard, no telemetry, and nothing financial anywhere in the code. - **Not a compliance certificate.** It is a set of constructions with their threat model attached. [The honest limits](honest-limits.html) page says what is not protected: traffic shape, a compromised device, a script injected into your page. - **Not "post-quantum secure".** The public-key seals are classical unless hybrid mode is on for every recipient; the docs say "hybrid mode" and mean it. - **Not finished.** Pre-1.0: function names and options may change between minor versions; the bytes it writes never will ([formats and stability](formats-and-stability.html)). ## Start [Start here](start-here.html): `docker compose up` the notes example, read one file, and then the four packages in the order they build on each other. # Start here One sitting, no questions: a working end-to-end-encrypted app in front of you, then the packages in the order they build on each other. ## 1. Run the notes example (ten minutes) ```sh git clone https://github.com/microtoll/engine cd engine/examples/notes-app docker compose up ``` Open . Sign up with a recovery code, write a note, share it by link, open the link in a private window as a second person, join, then remove that person and watch their copy go stale. The [notes example](examples/notes-app.html) page walks through it and says, honestly, what the server learned. Then read [`notes.js`](https://github.com/microtoll/engine/blob/main/examples/notes-app/notes.js): about two hundred lines, the whole model. ## 2. The packages, in order 1. **[crypto-core](packages/crypto-core.html)** — one call gives you a `cryptoCore` bound to your app's namespace. Everything else takes it. 2. **[identity](packages/identity.html)** — `createIdentitySession` with your screens as callbacks: boot, register, unlock, lock, delete. 3. **[access](packages/access.html)** — `createAccess` with your three choices (the fields in a pointer, what goes in the second tier, who is owed it), then objects, members, links and rotation. 4. **[blind-store](packages/blind-store.html)** — the server, as a library you mount handlers on or as the reference binary the examples run. The [invite example](examples/invite-app.html) adds the fifth, **[mailbox](packages/mailbox.html)**: inviting a known person with nothing to forward. ## 3. Your own app Either scaffold it from inside your editor with the [MCP server](for-agents.html) (`microtoll_scaffold` writes the notes starter into an empty directory), or copy `examples/notes-app` and change three things: the collection (`BLIND_STORE_COLLECTIONS`), the namespace (the same string in `createCryptoCore` and `BLIND_STORE_NAMESPACE`), and the origins the server accepts. ## 4. Before you ship - Read [the honest limits](honest-limits.html) and put its list in your own "about" page. Users are owed it. - Deploy with [the kit](deploy.html): the database on the internal network, the server as its own database role, the proxy blanking the client's address, no access log. - Run the schema-conformance test against your database (`packages/blind-store/test/schema.test.mjs`) whenever you add a table. - Pin the versions. [Formats and stability](formats-and-stability.html) says what a version number promises. # @microtoll/crypto-core The primitives and wire formats of the Microtoll Engine. Web Crypto (`SubtleCrypto`) only; no runtime dependencies; runs in current browsers and Node ≥ 24. **Status:** pre-release; not published. Every format is pinned by frozen fixtures (`test/fixtures/frozen-v1.json`) that each later version must open. ## Five-minute quickstart ```js import { createCryptoCore } from '@microtoll/crypto-core'; // One instance per app. The namespace prefixes every derivation label, so no // two apps ever share a key derivation by accident. It is required. const cc = createCryptoCore({ namespace: 'myapp' }); // A 32-byte root secret, and keys derived from it under labelled purposes. const root = cc.generateSymmetricKey(); const routingSeed = await cc.deriveBits(root, 'routing'); // HKDF, label "myapp/routing/v1" const signingKey = await cc.importEd25519PrivateKeyFromSeed(routingSeed); const ownDataKey = await cc.deriveAesKey(root, 'symm'); // AES-256-GCM, non-extractable // Symmetric sealing: [0x01][12-byte IV][ciphertext ‖ tag], optional bound context. const sealed = await cc.sealSymmetric(ownDataKey, new TextEncoder().encode('hello')); const opened = await cc.openSymmetric(ownDataKey, sealed); // Sealing to another person: they hold a P-256 key pair stored as a JWK. const alice = await cc.generateSealingKeyPair(); // { privateKey, publicKeyRaw, jwk } const forAlice = await cc.sealToRecipient(alice.publicKeyRaw, opened); const back = await cc.openWithPrivateKey(alice, forAlice); // needs the pair, not a bare key // A recovery code a person can write down: 128 bits, Crockford base32, checksum. const { secretBytes, displayString } = await cc.generateRecoveryCode(); // "ABCD-EFGH-…-XYZ" const unwrapKey = await cc.deriveAesKeyFromSecret(secretBytes, cc.randomBytes(16)); // PBKDF2, 310,000 iterations ``` Stateless primitives are also exported directly (`hkdfDeriveBits`, `sealSymmetric`, `verifyBytes`, the encoders); everything derived under a label lives on the instance. ## What is here - **Encoding helpers:** hex, base64url, Crockford base32, and the fixed-length frame `context ‖ 0x00 ‖ parts` for bound contexts. - **HKDF-SHA-256** (RFC 5869) in one shape: empty salt, the label as info. - **Ed25519** from a 32-byte seed through the RFC 8410 PKCS#8 wrapper; sign and verify (verify returns `false`, never throws). - **The stored P-256 sealing key**: generated once, kept as a private JWK, imported non-extractable. Stored rather than derived because Safari has no X25519 and Firefox cannot import a P-256 private key from a bare scalar. - **AEAD v1:** `[0x01][12-byte IV][AES-256-GCM ciphertext ‖ tag]`, optional additional authenticated data. - **ECIES v3:** `[0x03][65-byte ephemeral P-256 point][AEAD v1]`, key = HKDF(ECDH secret, `"/ecies/v3"` ‖ SHA-256(ephemeral ‖ recipient)), version byte authenticated. The retired X25519 v1 format is refused by name. - **ECIES v2, hybrid post-quantum:** `[0x02][1120-byte MLKEM768-X25519 ciphertext][AEAD v1]`, same binding. **Off by default** (`hybridSealing: false`): it needs a browser with native `MLKEM768-X25519` (Chrome 154+) and has not yet been cross-checked against one. In tests it runs through a test-only X-Wing composition verified against the draft's vectors. - **PBKDF2-SHA-256** and the **recovery-code format, version 3** (D-46): 16 bytes in Crockford base32 plus a weighted check character over GF(32) (`XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXX`). Every single wrong character and every swap of two characters is caught; a random typo, 31 times in 32. Version 2 (a SHA-256-derived check) is read only with `{ version: 2 }`. - **The label profile:** `createProfile({ namespace })`, `//v`; retired labels are refused in every namespace. `DEFAULT_PBKDF2_ITERATIONS` is 310,000. ## Tests `npm test` at the repository root. Published vectors run through this package's own API: RFC 5869 (test cases 1 and 3), RFC 8032 (tests 1, 2, 3, SHA(abc)), RFC 5903 §8.1, RFC 7914 §11, NIST CAVP AES-256-GCM, and X-Wing draft-10 Appendix C plus the working-group `MLKEM768-X25519` vector. Then property and tamper tests, and the frozen fixtures. ## Threat model See `THREATMODEL.md` §3 at the repository root. In one line: this package protects sealed bytes against the server, the network and strangers; it protects nothing against a compromised device or page, and its classical seal is not quantum-safe. ## Notes on the API - **Labels come from a namespace profile** (DECISIONS.md D-05), never from constants an app could edit in place; retired labels are refused in every namespace. - **The post-quantum switch is an instance option** (`hybridSealing`, off by default, D-07); the test seams are `_setHybridSealing` and `_resetHybridSupport`. - **`pbkdf2DeriveBits`** is exposed so the RFC 7914 vectors run through the package; `deriveAesKeyFromSecret` takes its iteration count from the profile. - **`randomBytes(length)`** is chunked past Web Crypto's 65,536-byte limit; `generateSymmetricKey` gives 32 random bytes, and the identity and access packages name its uses (root key, capability secret). - **`fromHex` rejects non-hex characters** instead of decoding them as zero. - **The recovery-code check character is version 3** (D-46): Σ aⁱ⁺¹·sᵢ over the 26 data characters in GF(32) = GF(2)[x]/(x⁵ + x² + 1), a = x, and the parser refuses non-zero padding bits, so one string names one secret. A version-2 code (D-26) looks the same, so it is never tried as a fallback: pass `{ version: 2 }` to read one. `formatRecoveryCode` and `parseRecoveryCode` stay asynchronous, because version 2 needs SHA-256. Errors carry a `code` (`recovery-code-checksum`, …) for the app to word. - **Not in this package** (they live in identity, access or mailbox): the root-key envelopes, the recovery lookup hash, the URL-token and capability helpers, the handshake signature and the mailbox label. Their primitive-level fixtures are in `test/fixtures/frozen-v1.json` for those packages' tests. # @microtoll/identity Sign-in and accounts without the server ever holding a key: one Account Root Key per person, wrapped once per unlock method (a passkey, a recovery code), a trusted-device session, a private settings blob, step-up for sensitive actions, and a deletion order that leaves nothing behind. Web Crypto only; depends only on `@microtoll/crypto-core`. **Status:** pre-release (M2 complete); not published. Formats are version 2 (`FORMATS.md`); version 1 is not read. ## What the server learns The routing public key (a locker number), one row per unlock method holding ciphertext and a credential id or lookup hash, the sealed blob, a compare-and- swap token and a generation counter. Never a key, a name, or when. The server never verifies a passkey: WebAuthn is used only as a PRF oracle whose 32-byte output, through HKDF, unwraps the root key. ## Five-minute quickstart ```js import { createCryptoCore } from '@microtoll/crypto-core'; import { createIdentitySession, createSessionStore, createKnownAccountStore, createWebAuthn } from '@microtoll/identity'; const cc = createCryptoCore({ namespace: 'myapp' }); const session = createIdentitySession({ cryptoCore: cc, origin: location.origin, // bound into the sign-in signature transport: { connect: () => openWebSocket('/ws') }, // your socket factory sessionStore: createSessionStore({ cryptoCore: cc }), // IndexedDB "myapp-session" knownAccounts: createKnownAccountStore({ cryptoCore: cc }), webauthn: createWebAuthn({ rpName: 'My app' }), ui: { askRecoveryCode: async (reason) => promptUser(`Recovery code needed to ${reason}`), confirmDeletion: async () => confirmUser('Delete everything?'), passkeyName: (identity) => 'My app account', }, hooks: { afterUnlock: (state) => showApp(state), onLocked: () => showGate(), beforeDeleteAccount: ({ ws, identity }) => deleteMyRows(ws, identity), // while the keys still exist }, }); // First visit: browse as a guest, register when something is worth keeping. if (!(await session.bootFromTrustedSession()).restored) await session.bootGuest(); const { recoveryCode } = await session.registerCurrentIdentity({ passkey: 'platform' }); showOnce(recoveryCode); // the second way in; never stored // Later, on the same device / a new browser / anywhere: await session.unlockWithPasskey(); await session.unlockWithDiscoverablePasskey(); await session.unlockWithRecoveryCode(codeTyped); // The private settings blob: read-modify-write under a compare-and-swap. await session.saveIdentityBlob((blob) => ({ ...blob, theme: 'dark' })); // Sensitive actions ask for a fresh proof of the person (5-minute grace). await session.addPasskey({ label: 'laptop' }); const { recoveryCode: newCode } = await session.rotateRecoveryCode(); await session.signOutEverywhere(); await session.deleteAccount(); ``` Lower layers are exported too (`wrapRootKeyWithPrf`, `openIdentityBlob`, `createSessionStore`, `authenticateConnection`, …) for apps that need them. The screens are the app's (D-25): the package asks through `ui` and `hooks` and words nothing itself. Errors carry a `code` (`not-allowed`, `prf-unsupported`, `identity-blob-unreadable`, `stale-session`, `different-account`, …) and a `diagnostic` that never holds a secret. Whatever the host's registration policy needs on the wire, such as terms or age acceptance, comes from `hooks.registrationFields` (D-17); the package stores no policy of its own. The `every-open` lock interval clears only the session; clearing the app's own offline caches belongs in `hooks.onLocked`. ## The server side The package speaks the account protocol of `@microtoll/blind-store` (M4): `challenge`/`auth`, `lookup-unlock-method` before sign-in, `register` with every method in one transaction, the unlock-method messages, `update-identity-blob` with a compare-and-swap, `bump-session-generation`, `delete-account`. `test/tooling/fakeServer.mjs` is an in-process stand-in that keeps the contract; the handshake signature verifies with `verifyAuthSignature`. ## Threat model `THREATMODEL.md` §4 at the repository root. In one line: the server and a database copy learn nothing about the person; a compromised device or page wins; a trusted-device session is as safe as the unlocked device it sits on; "sign out everywhere" and the blob's revision are cooperative, not cryptographic. # @microtoll/identity — formats and the hardening design (M2, D-24) **Status:** decided 2026-09-25 (DECISIONS.md D-28, D-29): the design below is what M2 builds. Two items moved to version 3 on 2026-09-27 (D-46, D-47; §2.8): the unlock-method label's binding and the recovery code's check character. The package writes those in version 3 and everything else in version 2; it reads a version-2 label or code only when a caller asks for it by name. ## 1. What version 1 left unbound Version 1 is described here only to explain why version 2 exists and why a version-1 record is not read. | Item | Where it lives | How it is made | Bound to | |---|---|---|---| | Wrapped root key | `user_unlock_methods.wrapped_root_key` (one row per unlock method) | AEAD v1 of the 32-byte root key under the method's unwrap key: HKDF(PRF output, `/envelope/prf/v1`) or PBKDF2(recovery bytes, salt, 310,000) | **nothing** | | Unlock-method label | `user_unlock_methods.encrypted_label` | AEAD v1 of the UTF-8 label under `K_master_symm` | nothing | | Identity blob | `users.encrypted_identity_blob` | AEAD v1 of JSON under `K_master_symm`; compare-and-swap by a 16-byte random token | nothing | | Trusted-device session | IndexedDB record `{v:1, sessionKey, wrappedRootKey, routingPublicKey, unlockedAt, expiresAt, sessionGeneration}` | AEAD v1 of the root key under a fresh non-extractable AES key kept beside it | nothing; the plaintext fields beside it are unauthenticated | | Handshake | `auth {routingPublicKey, signature}` | Ed25519 by the routing key over the **bare 32-byte nonce** | nothing: no purpose label, no origin | | Recovery lookup | `user_unlock_methods.recovery_lookup_hash` | HKDF(recovery bytes, `/recovery-lookup/v1`) | — (unchanged) | Consequences, all within the threat model's A1 (the server or a database copy) and A8 (a copied browser profile): - A wrapped root key can be moved between unlock-method rows, or a passkey row's blob served in answer to a recovery lookup. It still needs the right unwrap secret to open, so this is a confusion, not a break. - An identity blob or a label from account X could be served to account Y's device. It would fail to open (different `K_master_symm`), so again a confusion. But a **rolled-back** blob from the same account opens fine and is indistinguishable from current. - A copied session record's plaintext `routingPublicKey`, `expiresAt` and `sessionGeneration` can be edited without the wrapped key noticing: an expiry pushed into the future, a generation raised to defeat "sign out everywhere". Cooperative checks, so the harm is bounded, but the record claims more than it proves. - The handshake signature is over 32 random bytes with nothing else. If the routing key ever signed anything else in another context, or a nonce from another site's server were relayed, the signature would be replayable across contexts. Today the routing key signs nothing else, so this is the least urgent item, and the cheapest to fix. ## 2. The hardening (version 2), item by item Every change below adds binding to an existing construction using primitives crypto-core already has (`frameContext`, `sha256`, AEAD v1's additional authenticated data). No primitive, mode or KDF changes. Version numbers are carried in the profile label of each context, so a reader knows what it is opening and a v1 blob can never be mistaken for v2. ### 2.1 AAD on the wrapped root key ``` context = frameContext(profile.label('aad/unlock-method', 2), methodType, methodId) methodType = 0x01 passkey-prf | 0x02 recovery-code (1 byte) methodId = SHA-256(credentialId) for passkey-prf (32 bytes) = recovery lookup hash for recovery-code (32 bytes) wrapped = sealSymmetric(unwrapKey, rootKey, context) ``` Both identifiers are known **before** unwrapping: the credential id comes from the device's record or the discoverable assertion, and the lookup hash from the entered code. The credential id is hashed because it is variable length and `frameContext` takes fixed-length parts only. Effect: a blob from one row cannot be presented as another, and a passkey blob cannot be served on the recovery path or the reverse. ### 2.2 AAD on the identity blob ``` context = frameContext(profile.label('aad/identity-blob', 2), routingPublicKey) (32 bytes) ``` The routing key is derived from the root key, so it is known when the blob is opened. Effect: a blob cannot be moved between accounts. **Rollback (optional, recommended):** the blob's plaintext gains a `revision` integer that every writer increments. The device keeps the last revision it saw beside its known-account record; a blob that opens with a lower revision is refused with `identity-blob-rolled-back`. This is a behaviour addition, not a format change, and it is cooperative (a wiped device has no memory), but it turns a silent rollback into a loud one on every device that was there. **It is not part of the format decision;** it is listed so the decision is taken knowing AAD alone does not stop rollback. ### 2.3 AAD on the unlock-method label (version 3 since D-47) Version 2, read only (`labelContextV2`, `openMethodLabelV2`): ``` context = frameContext(profile.label('aad/unlock-label', 2), routingPublicKey) ``` Effect: a label cannot be moved between accounts, which the account's own key already ensured. It did **not** stop the server showing one passkey's label against another passkey of the same account, the case that misleads a person choosing which method to remove. Version 3, written since D-47 (`labelContext`, `sealMethodLabel`, `openMethodLabel`): ``` context = frameContext(profile.label('aad/unlock-label', 3), methodType, methodId) methodType = 0x01 passkey-prf | 0x02 recovery-code (1 byte) methodId = SHA-256(credentialId) for passkey-prf (32 bytes) = nothing for recovery-code ``` The binding matches the wrapped root key's (§2.1), except that a recovery code is bound by its type alone: the method listing carries no lookup hash to bind to, and an account holds one recovery code at a time. Effect: a label shown against any method but its own reads as null. ### 2.4 The trusted-device session record, version 2 ``` context = frameContext(profile.label('aad/session', 2), routingPublicKey, u64be(expiresAt), u32be(sessionGeneration or 0)) record = { v: 2, sessionKey, wrappedRootKey: sealSymmetric(sessionKey, rootKey, context), routingPublicKey, unlockedAt, expiresAt, sessionGeneration } ``` The three plaintext fields that decide whether the record may open are now authenticated by the wrapped key. Editing `expiresAt` or `sessionGeneration` in the profile makes the record fail to open (and be deleted). On restore the package also derives the routing key from the recovered root key and refuses a record whose stored key differs, so an edited routing key cannot pass the record off as another account's. Needs one new crypto-core helper, `u64be`, for the millisecond timestamp. `unlockedAt` stays unauthenticated: nothing decides on it. ### 2.5 The handshake signature ``` message = frameContext(profile.label('auth', 2), SHA-256(UTF-8(origin)), nonce) (32 + 32 bytes) signature = Ed25519(routingPrivateKey, message) ``` - `origin` is the web origin the client believes it is talking to (`location.origin` in a browser; passed explicitly in Node). The server verifies against its configured allowed origins, trying each. - The label ties the signature to this purpose and this app; the origin ties it to this deployment; the nonce ties it to this connection. - **Mirrored:** the client half ships in `@microtoll/identity` (M2); the server half in `@microtoll/blind-store` (M4), with a cross-implementation test that a browser-side signature verifies under Node. Until M4, identity's tests verify the message with crypto-core's own `verifyBytes`. ### 2.6 Non-extractable signing keys (D-24 item 4; already decided) ``` seed → import pkcs8 (extractable) → export JWK → public key → import pkcs8 again, extractable: false → the working key ``` The outputs do not change (the fixture signatures still match). The routing and identity-signing working keys become non-extractable; the raw root key and the HKDF seeds still exist as bytes in JavaScript, which the threat model states. The seed bytes are zeroed after the second import (best effort; a `Uint8Array.fill(0)` — JavaScript gives no stronger guarantee). ### 2.7 Unchanged - The recovery lookup hash and its label (a version-3 code decodes to the same 16 bytes as before; only its check character differs, §2.8). - PBKDF2 parameters and the PRF derivation label. - The `register` message with both unlock methods in one transaction (already atomic in version 1). - The open, pre-authentication `lookup-unlock-method` (D-20; the server caps its answers per connection, so one connection cannot harvest wrapped root keys in bulk). - Session length (30 days), lock intervals, the step-up grace (5 minutes, bound to the account) and the deletion order. ### 2.8 Version 3 (2026-09-27, D-46 and D-47) - **The unlock-method label** is bound to its own method (§2.3). - **The recovery code** a person writes down is crypto-core's version 3 (D-46): 16 bytes in Crockford base32 (26 characters) and a check character Σ aⁱ⁺¹·sᵢ over GF(32) = GF(2)[x]/(x⁵ + x² + 1), a = x. Every single wrong character and every swap of two different characters is caught before the lookup, and the two unused bits must be zero, so one string names one secret. Version 2's check (SHA-256-derived, D-26) caught each such error only 31 times in 32, and the rest failed later as "no such account". - **Reading version 2.** A version-2 and a version-3 code have the same shape, and a label carries no version byte, so neither can be recognised by looking at it; trying version 2 after version 3 would give back what version 3 closes. Version 2 is therefore read only when a caller names it: `{ recoveryCodeVersion: 2 }` on `lookupHashForEnteredCode` and `unwrapRootKeyWithRecoveryCode`, and `openMethodLabelV2`. No version-2 code or label exists outside tests. - Nothing else changes: the wrapped root key's binding (§2.1), the unwrap keys, PBKDF2 and the lookup hash are as in version 2. ## 3. Frozen fixtures The crypto-core frozen fixtures (`crypto-core/test/fixtures/frozen-v1.json`) pin the primitives and derivations underneath: their version-1 envelopes still open, which proves the unwrap keys are unchanged and only the binding is new. The identity package's tests pin every version-2 context byte for byte, and `test/fixtures/frozen-v2.json` holds version-2 bytes this package wrote (both wrapped root keys, the method labels, two identity-blob revisions, two session records and the handshake message), which every later version must open and reproduce. `test/fixtures/frozen-v3.json` holds the version-3 bytes for the same account (labels bound to two passkeys and the recovery code, and a root key wrapped for a version-3 code), and crypto-core's `test/fixtures/frozen-recovery-v3.json` pins eight version-3 codes. ## 4. Package shape (for orientation; the API review is at M2 acceptance) ```js import { createCryptoCore } from '@microtoll/crypto-core'; import { createIdentity } from '@microtoll/identity'; const cc = createCryptoCore({ namespace: 'myapp' }); const identity = createIdentity({ cryptoCore: cc, origin: location.origin, // for the handshake binding transport, // { connect(): ws } — the app's WebSocket factory storage: { session, knownAccount }, // adapters; browser defaults use IndexedDB + localStorage webauthn, // the package's own, or a stub in tests ui: { askRecoveryCode, confirmDeletion, showRecoveryCode, status }, hooks: { beforeDeleteAccount, afterUnlock, onLocked, registrationPolicy }, }); ``` The package ends at "an authenticated connection, the `auth-ok` fields, the opened blob, the adopted sealing key" (D-11). Everything after that is the app's. Wire message names are fixed (D-27). # @microtoll/access Sharing something with a group without the server being able to read it: one random key per object, sealed person by person; membership rows the server authorises by capability, never by identity; share links whose secret never reaches the server; removal that re-keys everything in one checked transaction; signed rows so nobody can pass for anyone else; and a second tier (two-tier disclosure: an address, say) granted only to those the app says. Web Crypto only; depends on `@microtoll/crypto-core` and `@microtoll/identity`. **Status:** pre-release (M3 complete); not published. Formats are version 2 (`FORMATS.md`). ## The model in one paragraph An **object** has `K_object` (32 random bytes). Its **content** is sealed under it. An optional **second tier** is sealed under its own random `K_detail`, never derived from `K_object`, and granted per recipient inside the content. Each **member** has a row: a signed envelope under `K_object` and a copy of `K_object` sealed to their key. A member's own **pointer** (the key, the epoch, their capability secrets) is sealed under their `K_master_symm`. **Capabilities** the server holds only hashes of: admin (in padded seats inside the content), read (derived from `K_object`), row (random per row) and link management. **Share links** carry `K_object` under a key derived from a token that stays in the URL fragment. **Removal** rotates every key and re-seals every remaining row; the server refuses the plan unless it names every active row at the expected epoch. **Revoking** a link deletes its row only: whoever already redeemed keeps what they hold. ## Five-minute quickstart ```js import { createCryptoCore } from '@microtoll/crypto-core'; import { createIdentitySession /* … */ } from '@microtoll/identity'; import { createAccess, goingGrantDue, oneOf } from '@microtoll/access'; const cc = createCryptoCore({ namespace: 'myapp' }); const access = createAccess({ cryptoCore: cc, pointerFields: { myStatus: { wire: 'myStatus', ...oneOf(['going', 'interested']) } }, // your fields split: (content) => { const { address, ...preview } = content; return { preview, detail: { address } }; }, merge: (preview, detail) => ({ ...preview, ...(detail || {}) }), grantDue: goingGrantDue, // who is owed the second tier }); // The owner creates an object (an event, a document, a group) with a hidden address. const created = await access.createObject({ identity: me, content: { title: 'Book club', address: '12 Secret St' }, twoTier: true }); await access.createObjectMessage(ws, created, { /* your coarse selector fields */ }); // A share link for the group chat: the secret stays after the '#'. const link = await access.createShareLink({ objectId: created.objectId, kObjectRaw: created.kObjectRaw, maxUses: 10, expiresAt, creator: me, creatorName: 'Ada' }); await access.publishShareLink(ws, link); const url = `${location.origin}/#token=${link.token}`; // Someone opens it: read access, then a first reaction makes them a member. const payload = await access.redeemShareLinkMessage(ws, await access.hashToken(token)); const { objectId, kObjectRaw, creatorName } = await access.redeemShareLink(token, payload); await access.joinObject(ws, { objectId, pointer: await access.buildReadOnlyPointer({ identity: me, objectId, kObjectRaw, keyEpoch }) }); const first = await access.buildFirstReaction({ identity: me, objectId, kObjectRaw, keyEpoch, content: { status: 'going' } }); await access.createMemberRow(ws, { objectId, row: first.row, pointerId, pointer: first.pointer, readCapabilitySecret: first.readCapabilitySecret }); // The roster, with the display rule applied: verified rows show; quiet rows show no identity; unverified rows show nothing. const roster = access.roster(await access.openRows({ kObjectRaw, objectId, rows: await access.fetchMembers(ws, objectId, { readCapabilitySecret }) })); // Removing someone: a new key for everyone else, in one checked transaction. const plan = await access.buildRotationPlan({ objectId, identity: me, oldKObjectRaw, oldEpoch, content, rows, remove: [rowId], selfRowId, twoTier: { detailOpened: true } }); await access.rotateObjectKey(ws, objectId, adminCapabilitySecret, plan); ``` ## What the app supplies - **`pointerFields`**: the app's own fields in a member's pointer (mirrors for badges and lists), each `{ wire, seal, open }`. - **`split`** and **`merge`**: what goes in the second tier, and how the two parts come back together for someone who holds a grant. - **`grantDue`**: which roster entries are owed the second tier. `goingGrantDue` is an example rule: a verified or quiet row that says "going" and has not opted out. - **The coarse selector**: the plaintext blind-store indexes objects by (`collection`, a fixed-length `selector`, an optional `windowStart` and `windowEnd`; for example a region code and dates), passed through untouched in `createObjectMessage` and `updateObject`, and queried with `queryObjects` — the cover-traffic query: decrypt what you hold keys for, discard the rest, never ask by a list of ids. `watchObjects`, `watchImminent` and `onLiveObject` are the live half. - **The transport** (a WebSocket-like object) and everything on screen. ## Tests `npm test`: 29 tests, all through the public API, including the adversarial suite the build plan names (`THREATMODEL.md` §5 maps them). ## Formats and scope - **Formats version 2** (D-31): purpose labels on the member-row and share-link signatures; additional authenticated data on content, second tier, member rows, pointers and link payloads. The per-member sealed copy of `K_object` is an ECIES seal without extra data (stated in the threat model). Envelope `v: 3` is the signed row; `v: 2` the quiet row; an envelope with any other version, or none, is refused. - **Generic names** (D-30): object, member row, owner, `K_object`. Some wire message and field names say "event" (`create-event`, `eventUserId`): they are the server protocol (D-27). - **The split, the merge, the grant rule and the pointer's app fields are supplied by the app**; the package knows nothing of what the content means. - **Admin box label** is `/object-adminbox/v1` (`FORMATS.md` §3.2). - **Direct invites, acknowledgements, contacts and favourites** are not here (mailbox, M3b); nor are product features that ride in rows or content. - **`openRows` skips a row that will not open** rather than surfacing it; the rotation plan is where an unreadable row is reported (set aside). # @microtoll/access — the object model and the hardening design (M3) **Status:** decided 2026-09-25 (DECISIONS.md D-30, D-31, D-32): the design below is what M3 builds. ## 1. What the access layer is, in one paragraph An **object** (an event, a document, a group) has one random symmetric key, `K_object`. Its **content** is sealed under that key; an optional **second tier** (`K_detail`, an address, say) is sealed under its own random key and granted person by person. **Members** each have a row: a signed envelope sealed under `K_object`, and a copy of `K_object` sealed to their sealing key. A member's own note about the object (the key, the epoch, their capability secrets) is a **pointer**, sealed under their `K_master_symm`. The server authorises writes by **capability secrets** it holds only the hashes of: admin (a random secret carried in a sealed seat), read (derived from `K_object`), row (random per row), and link management (random per link). **Share links** carry `K_object` in a payload sealed under a key derived from a token that never reaches the server. **Removal** rotates every key and re-seals every remaining row in one server transaction that checks the epoch and that every active row was named. **Revocation** of a link deletes its row only. ## 2. Decision D-30: the object model The object model answers D-13 (a generic collection model) with five rules: an epoch on every write, a rotation the server refuses unless it names every active row, an admin capability replaced on every rotation and carried in padded seats, rows that cannot have come from an honest client set aside, and one rule for who gets the second tier. Version 2 adds additional authenticated data to the object-layer seals (§3). **The parts:** | Part | Notes | |---|---| | object, `K_object`, `objectId` | a client-generated UUID | | content | JSON the app owns; the package owns `adminSeats`, `adminBox`, `detailGrants`, `ownerSigningKey` | | second tier, `K_detail`, `hasDetail` | the **split** is caller-supplied: `split(content) → { preview, detail }`; what goes in the second tier is the app's choice | | member row | a signed envelope (named) or an unsigned one (quiet), sealed under `K_object` | | `grantDue(row) → boolean` | caller-supplied: who is owed the second tier. The package ships an example rule: "verified or quiet, going, not opted out" | | pointer | the package owns `objectId`, `kObject`, `keyEpoch`, `rowCapabilitySecret`, `adminCapabilitySecret`, `quietRotationKey`, `quietRotationKemSeed`, `sharedLinks`, `invitedBy`; the app's fields (a status mirror, a notification flag, …) ride through a registered extension table | | owner, co-owner | holders of the admin capability | | the **coarse selector** | an opaque app value the server indexes (M4); not the package's | | admin seats, admin box | `ADMIN_SEAT_BUCKET` 4, seat plaintext 256 bytes, box bucket 1024 bytes | | quiet row (`v: 2`) | a per-object P-256 (+ KEM) rotation key in the pointer | | share link | token in the URL fragment; hashed token, N uses, expiry, management secret | | direct invite, ack | **`@microtoll/mailbox`** (M3b): the bundle formats, and the binding of the mailbox and the recipient into the signature, live there | **Some wire message names say "event"** (`create-event`, `rotate-event-key`, …) because they are the server protocol `blind-store` speaks (D-27); the package's function names are generic. **What the package does not do:** decide who is granted the second tier (`grantDue`), split content (`split`), choose link expiry or group sizes, hold invites for people without accounts, render anything, or fetch anything itself (it builds messages and opens replies; the transport is the app's, as in identity). ## 3. Decision D-31: the hardening (formats version 2) Every item adds binding with primitives crypto-core already has (`frameContext`, `sha256`, AEAD v1's additional authenticated data); no primitive, mode or KDF changes. Labels come from the profile, so a v1 blob can never be mistaken for v2. ### 3.1 Purpose labels on signatures | Signature | Version 2 signs | |---|---| | member row | `frameContext("/sig/member-row/v2", uuidBytes(objectId), uuidBytes(rowId)) ‖ UTF-8(payloadJson)` | | share link | `frameContext("/sig/share-link/v2", hashedTokenBytes32) ‖ UTF-8(payloadJson)` | | direct invite / ack (mailbox, M3b) | `frameContext("/sig/invite/v2", mailboxLabel32, SHA-256(recipientKey)) ‖ UTF-8(payloadJson)` — the mailbox and the recipient are in the signature, so a bundle re-sealed into another mailbox or for another recipient verifies for nobody (D-40; the collection-side check stays too). Built in `@microtoll/mailbox`, `FORMATS.md` §2 | The frame's fixed-length parts (UUIDs as 16 bytes, a hash as 32) and the NUL-terminated label mean no field can shift into another and no signature made for one purpose verifies for another. Version 1 signed text fields joined by NUL bytes (or the payload alone) with no purpose label, so nothing stopped a signature made for one purpose being offered for another; this package does not read version 1. The signed envelope keeps the outer shape `{ v, payloadJson, sig }` with `v: 3`, so a reader knows which message to rebuild; `v: 2` stays the quiet row. ### 3.2 Additional authenticated data on the object-layer seals | Seal | Key | Context (version 2) | Prevents | |---|---|---|---| | content | `K_object` | `frameContext("/aad/object-content/v2", objectId, u32be(epoch))` | content moved between objects, or an earlier epoch's content replayed after a rotation under the same… (the key changes at rotation; the epoch binding is belt and braces and lets a reader assert the epoch it was told) | | second tier | `K_detail` | `frameContext("/aad/object-detail/v2", objectId, u32be(epoch))` | same | | member row | `K_object` | `frameContext("/aad/member-row/v2", objectId, uuidBytes(rowId))` | a row's ciphertext presented under another row id (the signature already binds the row id for named rows; this covers quiet and legacy rows too) | | pointer | `K_master_symm` | `frameContext("/aad/pointer/v2", routingPublicKey)` (D-37, M4: the account, not the object id — the server returns an account's pointers without an id, so a binding that needed one could never be opened on a fresh device; the object id is inside the sealed pointer) | a pointer moved to another account, or another `K_master_symm` blob presented as a pointer | | share-link payload | token-derived key | `frameContext("/aad/share-link/v2", hashedTokenBytes32)` | a payload served under another link's hash | | admin box | `K_adminbox` | `frameContext("/object-adminbox/v1", objectId, u32be(epoch))` | a box moved to another object, or an earlier epoch's box replayed | Rotation re-encrypts each remaining row byte-for-byte under the new key with the same row context, so signatures survive it. **Not bound, stated plainly:** the sealed copy of `K_object` per member (`encryptedSharedEventKey` on the wire) is an ECIES seal, which binds the recipient's key and its version but takes no additional data; binding the row would need an ECIES v4 in crypto-core, which is out of scope. A server that moves a member's sealed key to another of the same member's rows gains nothing the member could not do. ### 3.3 Not changed by version 2 The admin seats and box (frozen sizes), quiet rows, the seal-target chooser (a quiet row's per-object keys first, a fresh hybrid key before the classical one, a stale advertisement treated as absent), the set-aside rule, the grant rule's shape, the pointer's compare-and-heal on a lagging epoch, link tokens (160 bits), capability secrets (256 bits, SHA-256 on the server), and every wire message. ## 4. Decision D-32: what M3 ships and what waits **M3, `@microtoll/access`:** - object creation; sealing `K_object` to a member; the read capability; member rows (named, quiet, legacy readers); pointers with the extension table; admin seats and the admin box; the rotation plan and the member's pointer refresh; two-tier disclosure (split by the caller; grants, the sweep, the viewer's merge); share links (create, redeem, revoke, the creator's record, stats); the unverified display rule as a pure function over opened rows; the message builders and reply openers for the object wire (`create-event`, `join-event`, `fetch-event-members`, `rotate-event-key`, `create-participation`, `update-participation`, `update-event`, `delete-event`, `delete-participation`, the four link messages). - Tests: the adversarial suite the build plan names, each case against the in-process server stand-in extended with the object messages (the same stand-in identity uses, so the contract stays one). - Fixtures: the version-2 contexts frozen in this package's tests; crypto-core's frozen fixtures (`test/fixtures/frozen-v1.json`) pin the read capability and the detail-grant label. This package's `test/fixtures/frozen-v2.json` holds version-2 bytes it wrote (content, second tier and grants, named and quiet rows, pointers, admin seats and box, share links, and their hybrid variants), which every later version must open and reproduce. **Waits:** direct invites and acknowledgements (mailbox, M3b); contacts and favourites (app or mailbox); product features that ride in rows or content as app fields (a first-time flag, repeat grants, proposals, signals) stay the app's. ## 5. The adversarial suite (build plan M3), mapped | Requirement | Cases | |---|---| | A removed member's old key cannot read post-rotation writes | new content, new detail, every resealed row and sealed key; the server refuses a write at the old epoch; the removed co-owner's admin secret refused; an incomplete plan refused as stale; a substituted old row set aside and never sealed to | | A revoked link cannot be redeemed | revoke → not-found; expiry → expired; N uses then exhausted; a concurrent last use taken once; stats absent after revoke | | An already-redeemed link is unaffected | a pointer holder still reads after revoke; and IS cut off by a later rotation | | A forged signature is flagged unverified | a bit-flipped, truncated or wrong-key signature on a row and on a link; a row lifted to another row id or object; a v2 quiet row carrying identity claims is still quiet, not verified; the display rule strips name and keys | | Holding the object never yields the second tier | a `K_object`-only holder cannot open the detail; a link holder cannot; a rotation grants only to the owner, co-owners, rows `grantDue` says and rows that held a grant; a grant label is object-scoped | # @microtoll/mailbox Inviting a known person directly, with nothing to forward: a sealed, signed invitation dropped under a **mailbox label only the two of them can compute**, collected by the recipient's own client. The server holds rows under labels it cannot compute, attribute or read. Web Crypto only; depends on `@microtoll/crypto-core` and `@microtoll/identity`. **Status:** M3b, built inside M5 (D-40); not published. Formats in `FORMATS.md` (the label frozen by crypto-core's fixture file; the bundle version 2). ## The model in one paragraph Two people who hold each other's public keys share, for each calendar month, a **label**: HKDF over the P-256 agreement of their sealing keys, in one direction. The sender drops a **bundle** under it — the object's key and epoch, the sender's name and claims, signed with the mailbox and the recipient bound in, then sealed to the recipient — and the recipient polls the labels of everyone they know, opens what is theirs, and consumes it. The sender polls the same labels to see who has collected, and can take an uncollected drop back. An unsigned or re-sealed drop is usable but attributed to nobody. ## Five-minute quickstart ```js import { createCryptoCore } from '@microtoll/crypto-core'; import { createMailbox } from '@microtoll/mailbox'; const cc = createCryptoCore({ namespace: 'myapp' }); const mailbox = createMailbox({ cryptoCore: cc }); // A contact, as the app keeps it: the person's signing key and sealing key (base64url). const bob = { signingKey: '…', identityKey: '…' }; // Alice invites Bob to an object she holds the key for. const [sent] = await mailbox.send(ws, alice, [bob], { objectId, kObjectRaw, keyEpoch, senderName: 'Alice', claims: { greeting: 'hello' } }); keep({ identityKey: bob.identityKey, epoch: sent.epoch, inviteId: sent.inviteId }); // in Alice's own pointer // Bob collects from everyone he knows: verified invitations, acknowledgements, and unsigned drops held for a tray. const { invites, acks, unsigned, counts } = await mailbox.collect(ws, bobIdentity, contacts); for (const inv of invites) { await joinTheObject(inv.objectId, inv.kObjectRaw, inv.keyEpoch, inv.senderName); // the app's, with @microtoll/access await mailbox.consumeInvite(ws, inv.rowId); // then it is collected } // Alice checks, or takes an uncollected one back. const [{ collected }] = await mailbox.status(ws, alice, kept); await mailbox.withdraw(ws, alice, cc.fromBase64Url(bob.identityKey), sent.epoch, sent.inviteId); // Live: the labels Bob polls, watched; a drop wakes him. const labels = await mailbox.receivingLabels(bobIdentity, contacts); await mailbox.watchInvites(ws, [...labels.values()].map((l) => l.mailboxId)); mailbox.onLiveInvite(ws, () => mailbox.collect(ws, bobIdentity, contacts).then(show)); ``` ## What the app supplies - **Contacts**: who this account knows (signing key and sealing key), kept in its identity blob; the invite example (`examples/invite-app`) records them from the rosters of notes the two share. - **Policy**: which senders go straight through and which are held (an app might hold invitations from anyone not a favourite, and let a pairwise label be dismissed); who is blocked; whether an acknowledgement is recorded; any push subscription (`pushEpochs` gives its months). `collect` returns the drops and consumes only acknowledgements and unknown kinds (a retired kind is never acted on); the app consumes an invitation when it has acted on it. - **The object side**: joining with the key in the invitation is `@microtoll/access` (a read-only pointer, then a first reaction). ## Tests `npm test`: the label against crypto-core's frozen fixture from both ends, the epochs, the bundle round trip and the re-sealing cases (a signed invitation passed on to someone else verifies for nobody), the flows against the server stand-in. blind-store's suites prove the server half. # @microtoll/mailbox — formats Status: **decided** (D-40, 2026-09-25; the signature's binding reserved by D-31). The label is frozen by the crypto-core fixture file (its `mailbox` section, under the test namespace), which every later version must reproduce; the bundle is version 2. Version-2 bundles this package wrote under that label (an invitation, an acknowledgement and a hybrid invitation) are frozen in `test/fixtures/frozen-v2.json`. ## 1. The label ``` shared = ECDH-P256(myPrivateSealingKey, theirPublicSealingKey) (256 bits) info = "/invite-mailbox/v2||" + base64url(senderPublicKey) + "|" + base64url(recipientPublicKey) label = HKDF-SHA-256(shared, salt ∅, info, 256 bits) (32 bytes) ``` - The keys are the two long-term P-256 **sealing** keys (65-byte raw public keys). Both sides derive one value from opposite ends. - The epoch is the calendar month in UTC. A recipient polls this month and the previous; a standing subscription covers this month and the next. - `invite-mailbox/v1` (the X25519 label) is retired in every namespace (D-05) and cannot be produced through the profile. - What the server sees: the 32-byte label, and nothing else. It cannot compute one, attribute one, or read what is under it. ## 2. The bundle (version 2) ``` payloadJson = JSON { kind, objectId, kObject, keyEpoch, senderName, , senderIdentityKey, senderSigningKey } message = frameContext("/sig/invite/v2", label, SHA-256(recipientKey)) ‖ UTF-8(payloadJson) sig = Ed25519(senderSigningKey, message) bundle = sealToRecipient(recipientKey, UTF-8(JSON { v: 2, payloadJson, sig })) ``` | Field | Invitation (`kind: "invite"`) | Acknowledgement (`kind: "invite-ack"`) | |---|---|---| | `objectId` | the object | the object the link opened | | `kObject` | base64url of `K_object` | `null` | | `keyEpoch` | the epoch the key is for | `null` | | `senderName` | shown only when verified | shown only when verified | | `hashedToken`, `stage` | — | which link was used; what is reported (`"seen"`, …) | | claims | the app's own fields, inside the signed payload | the same | - **What the signature binds**: the purpose label, the label of the mailbox the drop is for, and the hash of the key it is sealed to. A bundle re-sealed into another mailbox, or for another recipient, or with a changed payload, opens (the key in it is still a key) but verifies for nobody: no name, no claim. - **Which recipient key:** `sealToRecipient` dispatches on the key's length — the classical sealing key (65 bytes, ECIES v3) or the hybrid KEM key (1216 bytes, ECIES v2, when the recipient advertises one). The bundle's first byte tells the recipient which of its keys to open with, and the signature is checked against that key. - **Version 1 bundles are not read.** Version 1 signed `payloadJson` alone, so a contact could re-seal somebody else's signed invitation into a mailbox shared with a third person and it arrived as the signer's. No version 1 data exists to migrate. - **Post-quantum:** the label is classical by necessity (no standard post-quantum non-interactive key exchange); the bundle is hybrid when the recipient's key is. ## 3. The server's part `send-invite { mailboxId, encryptedBundle, expiresAt }` → `{ inviteId }`; `poll-invites { mailboxIds }` → rows with the bundle for uncollected drops and `consumed: true` without it for collected or withdrawn ones; `consume-invite { inviteId }` (collection and withdrawal alike; the row's bundle is emptied, so a collected or withdrawn drop keeps no key); `watch-invites { mailboxIds }` and the `invite-live { mailboxId }` push. All in `@microtoll/blind-store`. # @microtoll/blind-store The server that holds only what it cannot read. It authenticates a connection by a signed challenge, authorises writes by capability secrets rather than identity, indexes sealed objects by a coarse public selector the app chooses, pushes live changes by that same selector, sweeps what has expired, and never logs a message body. A **library** with a **thin reference server** around it: a host app mounts the library and registers its own handlers beside it; the example app runs the reference server as it is. **Status:** M4 built; not published (the publish gate, DECISIONS.md D-01, is closed). Licence AGPL-3.0-only (D-02). Design: `DESIGN.md` (decisions D-33 to D-36). Node 24 or later; Postgres 13 or later. ## What the server sees, in one paragraph Eight tables (`schema/000_blind_store.sql`): `users` (a routing public key, a sealed identity blob, a generation counter, a compare-and-swap token), `unlock_methods` (wrapped root keys under a credential id or a lookup hash), `pointers` (an account's sealed records that name no object), `objects` (sealed content and second tier under a collection, a selector, an optional date window, an epoch and two capability hashes), `object_members` (sealed rows with no identity and no reference to `users`), `share_links` (sealed payloads under the hash of a token that stays in a URL fragment), `mailbox_drops` (sealed bundles under labels only two parties can compute) and `rate_limit_counters` (routing key × action × day, the one place a key sits beside an action). Random UUID keys, no timestamps but two functional expiries, no identity columns — and a test (`test/schema.test.mjs`) that fails if any of that changes. `THREATMODEL.md` §6 lists exactly what it learns anyway. ## Five-minute quickstart (the library) ```js import pg from 'pg'; import { createBlindStore } from '@microtoll/blind-store'; const pool = new pg.Pool({ host: 'db', user: 'blind_store_app', password, database: 'app' }); const store = createBlindStore({ namespace: 'myapp', // the same namespace the app gives createCryptoCore allowedOrigins: ['https://app.example'], // browsers elsewhere are refused at upgrade pool, port: 8020, collections: { notes: { selectorLength: 2 }, // a shelf; no window events: { selectorLength: 5, window: true, allowAll: true, imminentDays: 2 }, // with a date window }, live: { connectionConfig: { host: 'db', user: 'blind_store_app', password, database: 'app' } }, }); // A host's own message, beside the engine's: store.handle('my-type', async ({ pool, ws, msg, state, routingPublicKey }) => { /* answer with send(ws, {...}) */ }); ``` The reference server (`bin/blind-store.mjs`) is the same call with its options read from the environment; `deploy/` holds a hardened Compose file and an Nginx sample; `examples/notes-app` runs it unchanged. ## The protocol, briefly Plain JSON over one WebSocket. The server opens with `challenge`; the client signs `"/auth/v2" ‖ 0x00 ‖ SHA-256(origin) ‖ nonce` with its routing key and sends `auth` (a version-1 signature over the bare nonce is refused: it bound neither purpose nor origin); `auth-ok` says whether an account exists and carries the sealed identity blob. Every request carries a `requestId` that its answer echoes. Before sign-in only `lookup-unlock-method` (three per socket) and what a host registers with `auth: 'none'` or `'any'` are answered; anything else closes the socket. After it, an unknown type is refused by name and never reflected. Message names keep the protocol's event vocabulary (D-27); the client side of every one is in `@microtoll/identity` (accounts) and `@microtoll/access` (objects, links, the query and the watches). The mailbox's server half is here; its client package is M3b. Query and fetch replies never carry `adminCapabilityHash`: it would be a stable per-object token handed to every querier. `delete-pointer` lets a client discard its own stale pointer. A member-row write carrying `quietPush: true` sets the transaction-local `blind_store.quiet_push` flag for a host's own trigger to read. **The selector query.** `query-events { collection, selectors | all, windowStart?, windowEnd? }` answers everything active that matches, filtered by nothing else. The client's obligations, which are what make the model work: decrypt only what you hold keys for (from your pointers); query the whole area you show, at a precision you fix; never query by a list of ids. `fetch-event` by one id exists for redeeming a link and healing a stale pointer, and is the one read that tells the server which object a routing key asked about. A query whose answer would exceed `maxQueryRows` (5,000) is refused as `too-many`; narrow the selector. **Live watches.** `watch-events` takes the same shape as a standing watch; changes are routed by the selector they fall in (now, or before a move) and re-read before sending; a member-row change is pushed content-free and debounced. `watch-imminent` carries nothing: a collection with `imminentDays` pushes every change inside the server's own window. ## Limits, all backstops Frame 4 MiB; sign in within 120 s; 300 messages per socket, refilled 60 a second; 2,000 sockets; per-field ciphertext caps (`src/limits.js`); links of at most 200 uses and 400 days; daily counters (20 objects, 20 links, 50 drops per routing key) that fail open. Every one is configurable; every one fails closed for the connection or request that crosses it and changes nothing for anyone else — except the counters, where refusing a real action because a counter table was down would be the wrong failure. ## Tests `npm test` runs the suites that need no database (the handshake cross-implementation check, the transport limits over real sockets, "the server cannot decrypt"). The database-backed suites (the schema and role checks, the whole protocol, the live watches, the fixture round trip) run when a Postgres answers at `BLIND_STORE_TEST_DB` (default: the throwaway container `node scripts/test-db.mjs up` starts on port 15433) and skip with one line otherwise; in CI they must run. # `@microtoll/blind-store` — design for decision (M4) Status: **decided**, 2026-09-25 (D-33 to D-36 in `DECISIONS.md`, all four as recommended). This document is the design the package follows; the README describes what was built. ## 1. What the package is, in one paragraph The server the other three packages talk to. It holds only what it cannot read: sealed blobs, pointer rows that name no object, hashed capabilities and hashed link tokens. It authenticates a connection by a signed challenge, authorises writes by capability secrets rather than identity, indexes objects by a coarse public selector the app chooses, pushes live changes by that same selector, sweeps what has expired, and never logs a message body. It is a **library** with a **thin reference server** around it (D-23): a host app mounts the library and registers its own handlers beside it; the example app runs the reference server as it is. ## 2. Decision D-33: the collection model (the server half of D-13/D-30) An object is a sealed record in a named collection, filed under a coarse public selector and, where the collection has one, a date window: the server half of the model D-30 set for the client side. ### 2.1 What an object row holds | Column | Notes | |---|---| | `id UUID PK` | client-generated random UUID (the row id is inside what the owner signs) | | `collection TEXT` | which kind of object; validated by the handler against the configured collections; one accepted low-cardinality plaintext | | `selector TEXT` | the coarse public selector: fixed length per collection, set by the app; characters `A–Z a–z 0–9 . _ : -` | | `window_start DATE`, `window_end DATE` | the date window, both set or both null (a collection without a window) | | `sealed_content BYTEA` | sealed under `K_object` | | `sealed_detail BYTEA` | the second tier, nullable | | `key_epoch INT ≥ 1` | bumped by the server on rotation | | `admin_capability_hash BYTEA(32)` | replaced on every rotation, so a co-owner removed by one cannot carry on with the secret they were given | | `read_capability_hash BYTEA(32)` | derived from `K_object` by the client | | `roster_members_only BOOLEAN` | the roster needs a row capability, not the read capability | | `status TEXT` | `'active'`; the engine never sets anything else — a host that needs to hide an object (a moderation suspension, say) sets another value from its own code, and every engine read path then treats it as not found and every admin action refuses it | `object_members` holds one row per member (`sealed_row`, `sealed_object_key`, `row_capability_hash`, `key_epoch`, `status IN ('active','removed_by_admin','left')`). **No identity column and no foreign key to `users`.** A host adds its own columns in its own init file, as it may for `users`. ### 2.2 The query, and the cover-traffic obligation `query-events` (the protocol's message name, D-27/D-30) takes: ``` { collection, selectors: [ ... ] | all: true, windowStart?, windowEnd? } ``` - `selectors`: 1 to `maxSelectors` (default 10,000) values of the collection's fixed length; **`all: true`** is the calendar-view variant (the window alone, every selector) and is allowed only where the collection is configured `allowAll: true`; the two are mutually exclusive, and an empty list is a hard error. - The window is required when the collection has one, refused when it has not; overlap is `window_start <= windowEnd AND window_end >= windowStart`, with dates as `YYYY-MM-DD` text both ways (never a JS `Date`, so the server's time zone cannot shift a date by a day). - The answer is every active row matching, and nothing is filtered by identity. **The client's obligation**, documented in the README and the threat model: decrypt only the rows it holds keys for (from its pointers), query the whole area it shows at a fixed precision, never query by a list of ids. `fetch-event` by one id stays for redeeming a link and healing a stale pointer, and is documented as the path that does tell the server which object a routing key asked for. - **A backstop:** a query whose answer would exceed `maxQueryRows` (default 5,000, configurable) is refused with reason `too-many` — closed for that request, nothing else affected; the client narrows its selector. - **Piggy-backing hook:** a collection may supply `queryExtras(pool, query)` whose fields ride on the same `events` reply (a host's public listings, say), so an app never needs a second request that would reveal intent. ### 2.3 Live watches (D-14: kept) `watch-events` / `unwatch-events` take the same query shape and are validated by the same parser; a change to an object or a member row is routed to every connection whose selector covers where it is now or where it just was, re-reading the row before sending (the notification payload carries the selector, never content). A member-row change is pushed as a content-free `participation` wake-up, debounced 250 ms. `watch-imminent` carries no parameters: a collection configured with `imminentDays: n` pushes every change to an object whose window falls within the next `n` days, whatever its selector — "tonight's plans changed while I was looking elsewhere", with the server owning the window so there is no parameter to grow a membership graph through. ### 2.4 The wire - Selector fields are generic: `collection`, `selector`, `windowStart`, `windowEnd`, `rosterMembersOnly`. - **`adminCapabilityHash` is not sent** in query and fetch replies: it would be a stable per-object token handed to every cover-traffic recipient. The access package's admin check is the admin box seat, which never needed it; `decodeObjectWire` keeps the field as `null`. - Message names, reply names and reason codes keep the protocol's event vocabulary (D-27). The handlers keep a fixed order of checks, one transaction per write, epoch guards, completeness on rotation, atomic redemption and the link limits. - The mailbox's server half (`send-invite`, `poll-invites`, `consume-invite`, `watch-invites`; the `mailbox_drops` table) is here: it has no cryptography. The client package is M3b. ### 2.5 Table names `users`, `unlock_methods`, `pointers`, `objects`, `object_members`, `share_links`, `mailbox_drops`, `rate_limit_counters`. A host extends `users` by `ALTER TABLE` in its own later init file. ## 3. Decision D-34: the bound handshake, server half (D-29), and the transport limits ### 3.1 The verifier ``` message = UTF-8("/auth/v2") ‖ 0x00 ‖ SHA-256(UTF-8(origin)) ‖ nonce (nonce: 32 random bytes per connection) verify = Ed25519(routingPublicKey, message, signature) (Node's crypto.verify; RFC 8037 JWK import) ``` - `namespace` is a required option (no default, as D-05). - `origin` is the upgrade request's `Origin` header when a browser sent one (already checked against the allowed list before the socket exists); when there is none (a non-browser client) each allowed origin is tried. - **No import of `@microtoll/crypto-core`.** The server frames the message with `Buffer` and verifies with `node:crypto`; a test proves the framed bytes equal `@microtoll/identity`'s `authMessage` and that a signature made with Web Crypto verifies here — the cross-implementation test D-29 asks for. The server package can then contain no code able to decrypt anything (§5.4). - One nonce, one chance: a wrong signature sends `auth-failed` and closes with 1008; a second `auth` on an authenticated connection is an unknown type. A version-1 signature over the bare nonce is refused: it bound neither purpose nor origin. ### 3.2 Transport limits | Limit | Default | Fails | |---|---|---| | frame size | 4 MiB (`ws` maxPayload) | that socket, 1009 | | pre-authentication timeout | 120 s | that socket, 1008 | | messages per socket | token bucket: 300, refilled 60/s | that socket, 1008 | | open sockets | 2,000 | the new connection, 503 at upgrade | | `Origin` | must be in `allowedOrigins`; no header allowed | 403 at upgrade | | `lookup-unlock-method` per socket | 3 (D-20) | `rate-limited` | | per-field ciphertext caps | the table in `src/limits.js` (identity blob 2 MiB, content 512 KiB, detail 256 KiB, row 64 KiB, pointer 256 KiB, link and mailbox payloads 256 KiB; nested: sealed object key 4 KiB, wrapped root key 1 KiB, label 4 KiB, credential id 1,023 B, salts 256 B) | `invalid`, with the field named | | share links | `maxUses` ≤ 200, expiry required and ≤ 400 days | `invalid` | | query answer | `maxQueryRows` 5,000 (§2.2) | `too-many` | | daily counters | `create-event` 20, `create-url-invite` 20, `send-invite` 50, per routing key per day; **fail open** (a counter outage never refuses a real action); the one place a routing key is written beside an action | `rate-limited` | All configurable in `createBlindStore({ transport, limits, rateLimits })`; the defaults, each justified, are in `src/limits.js`. Every limit is a DoS backstop, not a product rule, and the README says so. ## 4. Decision D-35: schema rules as tests, the sweep, the database role ### 4.1 The rules, checked against a live database A test reads `information_schema` for the engine's tables and fails on: - a primary key that is not a client-random UUID, outside the whitelist (`users.routing_public_key`, `share_links.hashed_token`, `rate_limit_counters` composite); - any column default using `nextval` or an identity column; - any `TIMESTAMP`/`TIMESTAMPTZ` column other than `expires_at` on `share_links` and `mailbox_drops` (functional: the sweep), and any column named `*_at`, `created*`, `updated*`; - any identity-named column (`email`, `phone`, `name`, `ip`, `address`, `user_agent`, `created_by`, `owner_id`, `account_id`, …) — the only account-bearing columns are `*routing_public_key` on `unlock_methods` and `pointers`, the account's own rows; - any column on `objects`, `object_members`, `share_links` or `mailbox_drops` that references `users`; - a capability-hash or lookup-hash column without a 32-byte `CHECK`; - a table in the schema file that is not in the documented list. The same test is what a host runs against its own extended database. ### 4.2 The sweep (D-19) `blind_store_sweep()` empties the payload of every share link used up or past its expiry, deletes a link a week after its expiry, deletes a mailbox drop once past its expiry, and deletes rate counters older than two days. The library runs it at start and (`sweep: { intervalMs }`, `false` to leave it to the host) and logs a count only. Objects past their window are **not** swept — that is the app's decision; the threat model says what a database copy therefore holds. ### 4.3 The database role The server never connects as the schema's owner or a superuser, so a compromised server can touch the engine's tables and nothing else. The schema creates `blind_store_app` (`NOLOGIN NOSUPERUSER NOCREATEROLE NOCREATEDB NOREPLICATION`) with exactly the table rights the handlers use and `EXECUTE` on the sweep; the schema is owned by the deployment's owner role. The reference server and the tests connect as `blind_store_app`, so a query the role may not run fails in the suite. The login and password are given at deployment, never in the schema file: the Compose kit sets them from a secret file at first start. ## 5. Decision D-36: the library, the reference server, the deployment kit, the example ### 5.1 The library ```js import { createBlindStore } from '@microtoll/blind-store'; const store = createBlindStore({ namespace: 'myapp', // required: the label prefix the handshake verifies pool, // a pg Pool connected as blind_store_app port: 8020, // listens at creation allowedOrigins: ['https://app.example'], collections: { notes: { selectorLength: 2 } }, // window: false; or events: { selectorLength: 5, window: true, allowAll: true, imminentDays: 2, queryExtras } registration: { columns, onRegister, authOkFields }, // optional host policy (D-17) live: { connectionConfig, extraChannels }, // optional: LISTEN for the live watches sweep: { intervalMs: 3600_000 }, transport, limits, rateLimits, httpRoutes, onAuthenticated, onSocketClose, onDeleteAccount, log, }); store.handle('my-type', handler, { auth: 'required' | 'none' | 'any', needsPool }); store.setFallback(async (ctx) => false); store.close(cb); ``` `createCore` is exported as an alias of `createBlindStore`. The wire helpers a host's own handlers share with the engine's (`send`, the base64url codecs, `blobField`, `hashField`, `hashSecret`, `expiryField`, the limits, `rateLimit`, `insertPointer`, `parsePointerField`) are exported as they are. Dependencies: **`ws` and `pg`, pinned exactly**, nothing else. `pg-listen` is dropped: the LISTEN connection is a plain `pg` client with a reconnect loop (about forty lines), and a live hub that cannot subscribe logs loudly at start and on every retry, because silent live-update failure is worse than a crash. Ed25519, SHA-256 and random bytes come from `node:crypto`. Node 24 or later. ### 5.2 The reference server `packages/blind-store/bin/blind-store.mjs`: reads `BLIND_STORE_NAMESPACE`, `BLIND_STORE_PORT`, `BLIND_STORE_ALLOWED_ORIGINS`, `BLIND_STORE_COLLECTIONS` (JSON), `DB_HOST/PORT/USER/NAME` and `DB_PASSWORD_FILE` (a file, never the environment), creates the pool and the live hub, starts the sweep, serves `/healthz` (a real `SELECT 1`; 200 or 503, nothing else) and stops on SIGTERM. Logs carry counts and reasons, never a message body or a routing key. ### 5.3 The deployment kit (`deploy/`) - `docker-compose.yml`: `postgres:17` on the internal network only (no host port), init from the package's `schema/`, `pg_isready` healthcheck, UTC; the server built from `deploy/Dockerfile` (`node:24-alpine`, the workspace copied in, `npm ci --omit=dev`), running as `node`, `read_only`, `tmpfs /tmp`, `cap_drop: [ALL]`, `no-new-privileges`, a memory limit, `depends_on: service_healthy`; the database passwords from files declared under `secrets:`, and the application role's login set by an init script from its secret at first start. - `nginx.sample.conf`: TLS 1.2/1.3 with `X25519MLKEM768:X25519:prime256v1`, `server_tokens off`, `access_log off`, HSTS, `nosniff`, `Referrer-Policy no-referrer`, a strict CSP on every location (re-added per location: `add_header` there cancels inheritance), the WebSocket upgrade block with `limit_conn`/`limit_req` per address, blanking `X-Forwarded-For`, `X-Real-IP`, `CF-Connecting-IP`, `True-Client-IP` and the Cloudflare geo headers so the server never holds an address beside a routing key, and `/healthz` ungated with `no-store`. - Deliberately absent: a superuser connection, running as root, a read-write source mount, a public 5432, access logs. ### 5.4 Tests 1. **Without Postgres** (always run): the registry and dispatcher rules, the pre-authentication surface, malformed and oversize frames, the token bucket, the pre-authentication timeout, origin refusal, every field cap, the handshake cross-implementation test (§3.1) — real sockets on localhost, `pool: null`. 2. **"The server cannot decrypt"** (always run): the package's runtime dependencies are exactly `ws` and `pg`; no `@microtoll/*` package is imported at runtime; a scan of `src/` finds no decrypt, decipher, unwrap, derive-key, HKDF, AES or private-key identifier; and, with Postgres, every sealed fixture from `crypto-core/test/fixtures/frozen-v1.json` stored through the server reads back byte-identical. 3. **With Postgres** (`BLIND_STORE_TEST_DB` set, or the throwaway container `scripts/test-db.mjs up` starts; CI runs a `postgres:17` service): the whole protocol driven by the real client packages — `@microtoll/identity` and `@microtoll/access` as devDependencies — covering every core message: register, lookup and unlock, the blob's compare-and-swap, methods, rotation of the recovery code, sign out everywhere, objects, members, pointers, epoch guards, rotation completeness, links made, counted, redeemed, exhausted, revoked, the mailbox, live watches, the daily caps, deletion, the sweep. 4. **Schema conformance** (§4.1) and **role conformance** (the tests run as `blind_store_app`). 5. **The example's integration test**: the notes app's own client module driven in Node against the running server. ### 5.5 The example: `examples/notes-app` End-to-end-encrypted notes with sharing and revocation, small enough to read in ten minutes, on the reference server unchanged: - `docker compose up` in `examples/notes-app` starts Postgres, blind-store (collection `notes`, a 2-character selector, no window) and nginx serving the page and proxying `/ws`; open `http://localhost:8088`. - Sign up with a recovery code (works in every browser; a passkey is offered when the browser has PRF), write a note, share it by link, open the link in a private window as a second person, react to become a member, remove them as the owner and watch the old key fail to read the next edit. - **The selector is a random "shelf"** (one of 256), chosen when a note is created and kept in the note's pointer (the access package's pointer extension): the app asks the server for every note on the shelves it uses and decrypts only its own. The README states plainly what the server learns (which shelves this routing key reads) and that cover is only as deep as the crowd on a shelf — the same honesty the threat model asks of any coarse selector. - The page uses the workspace packages through an import map — the packages as they will be published, no build step — since the publish gate is closed (D-01). ## 6. What stays out (D-14, confirmed) Reporting and moderation, the public layer, operator disclosure keys, the repeat grant, live signals (the transaction-local `blind_store.quiet_push` flag a host's trigger may read is kept, one line), Web Push, and terms and age-declaration columns (the registration hook replaces them). ## 7. Threat model §6, to be completed with the package The accepted trades, each listed: the collection, selector and window per object; which selectors and windows a connection queried and watched; one object id per `fetch-event` and per link redemption; routing key × action × day in the rate counters; pointer counts and unlock-method counts per account; the `Origin` and the request sizes and timing; what a database copy holds until the sweep runs. The database role model; the client obligations for cover traffic; availability limits. # @microtoll/mcp Microtoll Engine inside your coding tool. A Model Context Protocol server (over standard input and output) that Claude Code, Cursor and any MCP host can call for the engine's documentation and for a project scaffold, so an agent can wire the engine in without leaving the editor — and without leaving the machine: the pages are inside the package, nothing is fetched, nothing runs but what is in `src/`. **Status:** published as `@microtoll/mcp` (0.1.1) and listed in the MCP registry as `io.github.microtoll/mcp`. Apache-2.0. Zero dependencies (D-39). Node 24 or later. ## Add it to a host ```sh claude mcp add microtoll -- npx -y @microtoll/mcp ``` Any other host takes the same command (`npx -y @microtoll/mcp`) as a stdio server. ## The three tools | Tool | What it does | |---|---| | `microtoll_search_docs({ query, limit? })` | Full-text search over the documentation — the same pages as microtoll.dev, shipped in `generated/docs.json` by the repository's docs build so they never drift. Returns page, section and excerpt. | | `microtoll_read_doc({ path })` | One page as Markdown, by the `path` a search result gives (`packages/access.html`) or from `llms.txt`. | | `microtoll_scaffold({ directory, namespace?, origin?, name? })` | Writes the notes starter into an **empty** directory: `notes.js`, `page.js`, `index.html`, `docker-compose.yml`, `nginx.conf`, `package.json`, `README.md`, with the namespace and origin filled in. Writes files and nothing else; refuses a directory that is not empty; never overwrites. The next steps (`npm install`, `docker compose up`) come back as text for the person to run. | ## What it speaks JSON-RPC 2.0, one message per line: `initialize` (protocol version `2025-06-18`, tools capability), `ping`, `tools/list`, `tools/call`; notifications are acknowledged by silence; a tool's failure is a tool result with `isError`, a protocol failure a JSON-RPC error. About 120 lines in `src/protocol.js`, tested against a transcript of what a host sends. ## Tests `npm test`: the protocol over a real child process, the search and the page reader over the shipped snapshot, the scaffold into a temporary directory (placeholders filled, a non-empty directory refused). # Notes — the first example End-to-end-encrypted notes with sharing and revocation, on the unchanged blind-store reference server. Small enough to read in ten minutes: `notes.js` is the whole model (about 200 lines), `page.js` wires it to buttons, and nothing else is the app's. ## Run it ```sh cd examples/notes-app docker compose up ``` Then open . The first start builds the server image and pulls Postgres and nginx (a few minutes on a clean machine); the notes appear the moment nginx answers. 1. **Boot**, then **Sign up with a recovery code** (works in every browser; the passkey button needs a browser with PRF support). Keep the code: it is the only way back in. 2. **New note**, open it, write, **Save**. 3. **Share**: copy the link. Open a private window as a second person, paste the link's address into the browser, boot, sign up, press **Open link**. The second person reads the note. 4. As the second person, **join as member**. As the owner, **members** shows them (verified by their signature); **remove** rotates every key. 5. As the owner, edit and save. The second person's copy is now stale: **heal** finds no new key for them — they were removed, and the old key opens nothing written since. `docker compose down -v` throws the database away. ## What the server learns, honestly - **The shelf of every note** (one of 256, chosen at random when the note is made), and **which shelves each routing key asks for** — that is the cover-traffic trade: the server answers with every note on those shelves and cannot tell which are yours. With few users the crowd on a shelf is thin, and the server can guess well; with many it cannot. A real app picks a selector that is meaningful and coarse (a region and a day, say). - **One note id per link opened** (`fetch-event`), because redeeming a link needs the note's current key epoch. - **Sizes and timing**, the number of pointers an account holds, and that a routing key made a note today (the daily counter). It does not learn a title, a body, a name, who owns what, who is a member of what, or a link's secret. `THREATMODEL.md` §6 in the repository root is the full list. ## Where things are - `notes.js` — the model: list (the shelf query), create, update, share, open, join, members, remove (rotation), heal, delete, watch. - `page.js` — the page, the identity session (`@microtoll/identity`) with prompts for the code and confirmations. - `docker-compose.yml`, `nginx.conf` — the three containers: Postgres with the engine schema, the reference server with the `notes` collection (`{"notes":{"selectorLength":2}}`), nginx serving this page and the packages by import map and forwarding `/ws`. - `test/notes.test.mjs` — the same model driven from Node against a real server and database (`node scripts/test-db.mjs up`, then `npm test`). The packages load from `/packages/*/src` through an import map: no build step, and exactly the files that will be published. # Invites — the second example What a share link cannot do: inviting a known person **directly**, with no link and nothing to forward, through a mailbox only the two of you can compute. Built on the notes example (`../notes-app/notes.js`) plus `@microtoll/mailbox`; `invites.js` is everything this example adds (about 150 lines). ## Run it ```sh cd examples/invite-app docker compose up ``` Then open , in two browsers or a normal and a private window, as Ada and Bea: 1. Both: **Boot**, **Sign up**, type a name. 2. Ada: **New note**, **share by link**; Bea pastes the link, **Open link**, then **join as member**. 3. Both: **remember people** on that note. Each now holds the other's keys from the note's own roster — nothing was exchanged with the server. 4. Ada: **New note**, then **invite Bea directly**. Bea's page is woken by the live watch and collects the invitation; the note appears with read access. Nobody made a link. 5. Bea: **open** it — her app tells Ada it was seen. Ada: **invitations** shows "collected, seen". 6. Ada: another note, **invite Bea directly**, then **take back** before Bea's page collects it. Bea's next collection finds nothing. `docker compose down -v` throws the database away. ## What the server learns, honestly - **Mailbox labels**: a 32-byte value per pair per month, written under by one connection and polled by another. It cannot compute a label (that needs one of the two private keys), attribute one, or pair the writer and the reader without both keys. - **Which labels a routing key polls and watches** — one per contact per month, two months deep — so it learns how many people an account knows, not who. - **That a routing key sent a drop today** (the daily counter), and each drop's expiry. - Everything the notes example lists, since the notes are the same. It does not learn a name, a note's key, who invited whom, or that an invitation was seen (the acknowledgement is a drop like any other). ## Where things are - `invites.js` — contacts from a roster, a direct invitation, collection, the acknowledgement on opening, withdrawal, the live watch. - `page.js`, `index.html` — the page. - `test/invites.test.mjs` — the story above, driven from Node against a real server and database (`node scripts/test-db.mjs up`, then `npm test`). # Deploying blind-store The reference deployment: one Compose file, one Dockerfile, one Nginx sample. The server never connects as the database superuser, never runs as root, and never keeps an access log. ## Pieces | File | What it is | |---|---| | `docker-compose.yml` | Postgres 17 on the internal network only, with the engine schema and the service role's login applied at first start; the server built from this repository, running as `node`, read-only, no capabilities, with passwords from secret files. | | `Dockerfile` | `node:24-alpine`, the workspace's pinned `ws` and `pg`, the package, nothing else. | | `../packages/blind-store/schema/900_app_login.sh` | Gives `blind_store_app` its login from the secret at first start. | | `nginx.sample.conf` | TLS with the hybrid post-quantum group, the security headers, the WebSocket location with per-address limits and the client's address blanked, `/healthz`. | | `.env.example` | The namespace, the allowed origins, the collections. | ## First start ```sh cd deploy cp .env.example .env # edit: namespace, origins, collections mkdir -p secrets openssl rand -base64 32 > secrets/postgres.password openssl rand -base64 32 > secrets/blind_store_app.password docker compose up -d curl -s http://127.0.0.1:8020/healthz # ok ``` Put the reverse proxy in front (`nginx.sample.conf`, with your certificate), serving the app's static files and forwarding `/ws` to `127.0.0.1:8020`. ## What is deliberately not here - No hosted service, no telemetry, no metrics endpoint: `/healthz` says `ok` or `unhealthy` and nothing else. - No access log at the proxy, no request log at the server. The server logs counts and reasons (a sweep total, a lost LISTEN connection) and never a message body or a routing key. - No client address reaches the server: the proxy blanks it, and the per-address limits live at the proxy. - No schema migration tooling. The schema runs once, on an empty data directory; a change to a table is a new init file for a new deployment or a migration you write, and the schema-conformance test (`packages/blind-store/test/schema.test.mjs`) is there to run against it. ## Backups A database copy holds what THREATMODEL.md §6 lists: sealed blobs, hashed capabilities, the selectors and windows, the counters for the last two days, and links and drops until the sweep removes them. It holds no key. Copy it with the same care as the running database; there is nothing in it to redact. # THREATMODEL.md — Microtoll Engine **Status:** each package's section was completed in its milestone, before that package's API was reviewed; the formats each claim rests on are in the packages' `FORMATS.md` and `DESIGN.md`. A limit is stated as plainly as a protection. ## 1. Adversaries | ID | Adversary | Can do | |---|---|---| | A1 | **Server operator or database copy** | Reads every row, log and ciphertext; sees traffic timing and sizes; can drop, replay, reorder or roll back what it serves. It does not run the client. | | A2 | **Network observer** | Sees TLS metadata and timing (TLS itself is assumed). | | A3 | **Co-member** | Legitimately holds an object's key; sees other members' public keys and rows. | | A4 | **Removed member** | Holds the *old* keys and everything they saw before removal. | | A5 | **Link holder** | Holds a share link (and therefore the object key) without being a member. | | A6 | **Stranger with an account** | Can authenticate and send any well-formed message. | | A7 | **Harvest-now, decrypt-later** | A1's copy plus a future large quantum computer. | | A8 | **Compromised device or page** | Malicious script in the origin, malware, or someone holding the unlocked device or a copy of the browser profile. | ## 2. Global limits (true of every package) - **A8 wins.** Script injection into the page, or a compromised device, is total compromise. A trusted-device session is exactly as safe as the unlocked device it sits on. - **Traffic shape is visible to A1 and A2.** This includes who connects and when, how many rows an account owns, ciphertext sizes (unpadded, in bands), and the plaintext coarse selector (area and date range) of every item. While few people use a deployment, one active account is easy to pick out. Hiding it would need mix-network routing, which is out of scope. - **A link is as private as the channel it is sent through.** The secret is in the URL fragment, which servers and link previewers do not receive. The messaging app it travels through can read it unless that chat is end-to-end encrypted. - **Nothing can be moderated in advance.** The server cannot read content, so abuse handling starts with a report from someone who can. - **No server-side recovery.** Lose every unlock method and the data is lost to everyone, including the operator. - **Quantum (A7).** Every public-key seal is classical (P-256) unless hybrid mode is on for every recipient. A copy of a database taken today could be opened by a large enough quantum computer, which does not yet exist. Hybrid mode protects only what is sealed after it is switched on, and an object is only as protected as its weakest member's copy of the key. Symmetric encryption (AES-256-GCM, HKDF, SHA-256) is not materially weakened. - **Cooperative, not cryptographic:** session-generation "sign out everywhere" and link withdrawal are honoured by honest clients. Neither can take back a key that has already left. ## 3. `@microtoll/crypto-core` - **Protects:** - Confidentiality and integrity of sealed bytes against A1, A2 and A6: AES-256-GCM with a leading version byte; recipient-bound, version-authenticated ECIES v3 (P-256) and v2 (X-Wing hybrid). - Domain separation of every derived key by label. - **From whom:** A1, A2, A6. Against A7, only the v2 hybrid seal protects. - **Does not protect:** - Anything once the caller mishandles keys. - Metadata such as sizes. - Anything against A8. - Recovery-code secrecy beyond its 128 bits of entropy. PBKDF2 at 310,000 iterations is not memory-hard; the entropy carries the security. - Hybrid mode is not "quantum-safe" until every recipient uses it (§2). - **What each format refuses** (every row is a test in `packages/crypto-core/test/`): | Format | Refused, and how | |---|---| | AEAD v1 `[0x01][IV][ct‖tag]` | wrong key; any flipped bit in IV, ciphertext or tag; truncation; unknown version byte; additional data that differs or is missing — all fail at the GCM tag, before any plaintext is returned | | ECIES v3 `[0x03][ephemeral][AEAD]` | wrong recipient pair; the right private key with a swapped public half (the recipient is bound into the key); a relabelled version byte (authenticated as AAD); an ephemeral point off the curve; a blob from another namespace (the label is in the key); the retired v1 format, by name | | ECIES v2 `[0x02][KEM ct][AEAD]` | as v3, plus any flipped bit in the 1120-byte KEM ciphertext (the shared secret changes, then the tag fails); truncation; a v3 blob given to the v2 opener and the reverse | | Recovery code v3 (D-46) | wrong length, an invalid character, a failed check character, non-zero padding bits — each with an error `code`; **every** single wrong character and **every** swap of two different characters fails the check; a random typo passes it with probability 1/32 and then fails lookup, never opens another account. A version-2 code is read only when asked for by name | - **Misuse the API stops:** a bare private key where the pair is needed (`openWithPrivateKey`), a 32-byte "recipient key" (the retired X25519 length), a seed or key of the wrong length, a namespace missing or malformed, a retired label in any namespace, `hybridSealing` left off on a capable runtime (it stays classical; a capable browser never switches itself on). - **Key material in memory:** - AES-GCM keys derived by HKDF or PBKDF2, the imported sealing private key and the session key are **non-extractable** `CryptoKey`s. - Ed25519: crypto-core's `importEd25519PrivateKeyFromSeed` returns an **extractable** key, because Web Crypto offers no other way to read the public half. The identity package, which owns the root key's lifetime, reads the public half once and holds its working routing and signing keys **non-extractable** (identity `FORMATS.md` §2.6). The raw root secret and the HKDF seeds still exist as bytes in JavaScript. - The hybrid KEM private key object is extractable as `raw-seed` (its seed is the HKDF output the identity package already holds). - **Stability as a security property:** frozen fixtures (`test/fixtures/frozen-v1.json`, written once and never regenerated) must open and reproduce with every later version. A change that broke them would surface as a red test, not as silently unreadable data. ## 4. `@microtoll/identity` - **Protects:** - The root key at rest against A1: wrapped only under a passkey PRF or a 128-bit recovery code; never stored in plaintext. - Unlinkability between the plaintext routing key and the person's identity keys against A1. - Independent unlock methods: removing one never locks out another, and the server refuses to remove the last. - Session key non-extractable by page script where the platform allows. - Step-up before sensitive changes. - **From whom:** A1, A2, A6. A3 learns only public identity keys. - **Does not protect:** - Against A8: the unlocked device, the browser profile, the root key and the HKDF seeds in page memory (the working Ed25519 keys are non-extractable, but the seed bytes they came from were in JavaScript). - The unauthenticated lookup, which returns the wrapped root key to anyone holding a credential id. That is safe only because the PRF output is secret. - The link between routing key and wrapped root key, which A1 can see (a locker number). - The existence and approximate age of accounts, and how often unlock methods changed (`session_generation`). - Step-up is client-side only. - **What each stored item is bound to (formats version 2, D-28, and the label in version 3, D-47; every row is a test in `packages/identity/test/`):** | Item | Bound to | So that | |---|---|---| | Wrapped root key | method type + SHA-256(credential id) or the recovery lookup hash | a blob cannot be presented under another row or on the other unlock path | | Identity blob | the routing public key; carries a `revision` | it cannot be moved between accounts; a rollback is refused on a device that saw a later revision (cooperative) | | Unlock-method label | the method: its type, and SHA-256(credential id) for a passkey (version 3, D-47) | it cannot be shown against another method, even another passkey of the same account, so a person removing a method is not misled about which one; nor moved between accounts (another `K_master_symm`) | | Trusted-device session | routing key, expiry, session generation | an edited expiry or generation in a copied profile fails to open; a stored routing key that disagrees with the derived one is refused | | Handshake signature | `"/auth/v2"`, SHA-256(origin), the nonce (D-29) | the signature is useless for another purpose, another deployment or another connection | - **The recovery code: loss versus theft.** Loss of every unlock method loses the account for everyone; there is no server-side recovery, by design. Theft of the code opens the account from anywhere: it is 128 bits of entropy behind PBKDF2 (310,000 iterations, not memory-hard), so the entropy carries the security and the code must be kept like a key. Rotating it (a fresh proof first) cancels every old code in one server transaction and stales every other device's session. - **Passkeys.** Used only as a PRF oracle; the server never sees or verifies an assertion, so a passkey's signature algorithm is irrelevant to the account's security. The "synced" flags an authenticator reports describe the kind of credential, not the live sync setting (measured on iOS), so the package reports `passkeyBackedUp` and the app must not claim more than "the phone reports a synced kind". A credential whose PRF is refused is disowned and never registered. - **Step-up** proves the person, not the device, before adding or removing a method, rotating the code or deleting the account; a five-minute grace after a proof is bound to the account it proved, so switching to another account in the same tab inherits nothing. It is client-side: the server cannot ask for more than the connection already proves. - **Deletion order and partial failure.** Confirm → prove → forget the session → the app's sweep of its own rows (while the capability secrets in its pointers still exist) → `delete-account` → the device's records. The sweep is the app's, best-effort step by step: a row it cannot reach is left, and the Privacy Notice must say what deletion does not reach (rows the account never held a secret for, sealed drops already in others' mailboxes, other members' decrypted copies). A failure before `delete-account` leaves the account intact and unlockable to finish the job. - **Tested behaviours that close known failure modes:** an unreadable identity blob stops the unlock and nothing is written over it (a corrupt blob can never be silently replaced by a fresh one); the step-up grace is bound to the account it proved. - **Still cooperative, stated plainly:** "sign out everywhere" (a generation counter honoured by honest clients), the blob revision (a device with no memory of a later revision accepts an older one), and the trusted session's expiry (enforced by the client that reads it, now with the expiry authenticated). ## 5. `@microtoll/access` - **Protects:** - Object content against A1 and A6. - Second-tier payloads (two-tier disclosure) against anyone without a grant, including A3 and A5. - Authorship: a row whose signature fails is shown as unverified and never carries a name or identity. - A removed member cannot read writes made after rotation under the new key. - A revoked link cannot be redeemed again. - **From whom:** A1, A5, A6, and A4 after rotation. - **Does not protect:** - What A4 already saw, and anything written under the old key. Rotation protects the future only. - A5 holding the object key can read content and, by default, list named members through the derived read capability. **Where a link is posted is the real access control.** - Revoking a link does not remove access from anyone who already redeemed it; only removal (rotation) does. - Grant labels do not hide room membership from a link holder. - A1 can serve an older version of the same thing under the same key and epoch (an earlier edit): the additional authenticated data binds where a blob belongs, not which version it is. - **Tested behaviours that close known failure modes:** the admin capability is replaced on every rotation and carried in padded seats, so a removed co-owner keeps no admin power; one grant rule serves both the sweep and rotation, so a rotation cannot hand the second tier to members the sweep would not; the server refuses a rotation at the wrong epoch or one that does not name every active row, and refuses content and row writes at the wrong epoch; a row that cannot have come from an honest client is set aside and never sealed to. Re-sealed invitations and withdrawn drops belong to the mailbox package (M3b). - **What each format binds (version 2, D-31; every row is a test in `packages/access/test/`):** | Item | Bound to | So that | |---|---|---| | Member-row signature | `"/sig/member-row/v2"`, object id, row id | a row cannot be lifted into another row or object and still verify | | Member-row seal | object id, row id (AAD) | a row's ciphertext cannot be presented under another row id, quiet and unsigned rows included | | Content, second tier | object id, epoch (AAD) | content cannot be moved between objects; a reader can assert the epoch it was told | | Pointer | the account's routing key (AAD; D-37) | a pointer cannot be moved to another account or confused with another blob under `K_master_symm`; the object it names is inside it, since the server returns pointers without an id | | Share-link payload | the token hash (AAD) and, when signed, `"/sig/share-link/v2"` + the token hash | a payload cannot be served under another link's hash; a signed payload cannot be re-wrapped in a fresh link as its creator's | | Admin box | object id, epoch (AAD) | a box from another object or epoch does not open | | Sealed copy of `K_object` per member | the recipient key and version (ECIES v3/v2) — **no additional data** | a server that moves it to another of the same member's rows gains nothing the member could not do; binding it would need an ECIES v4 | - **The adversarial suite, mapped to the build plan's four requirements:** - *A removed member's old key cannot read post-rotation writes:* the new content, second tier and every re-sealed row and key are opened by remaining members and refused to the removed one; the server refuses the removed member's row write (`unauthorized`), a remaining member's write at the old epoch (`stale`), the old admin secret (`unauthorized`), a plan on the old epoch or missing a row (`stale`); a removed member's old signed row substituted under a remaining member's id is set aside, never sealed to. - *A revoked link cannot be redeemed:* `not-found` after revoke; `expired`; `exhausted` after N uses; stats only with the management secret; a recipient cannot revoke. - *An already-redeemed link is unaffected by revoke:* the holder still reads and lists the roster; and is cut off by a later rotation. - *A forged signature is flagged unverified:* a bit-flipped signature, a row lifted to another row id, a member signing with their own key while claiming another's; the display rule shows no name, status or keys for such a row; a quiet row carrying identity claims stays quiet, never verified. - *Holding the object never yields the second tier:* a `K_object`-only holder and a link holder get the preview only; the sweep grants "going" and admins, not "interested"; after a rotation only the owner, admins, `grantDue` rows and earlier grant holders open the new second tier; grant labels are object-scoped; a device holding only the preview cannot rotate a two-tier object. - **Stated limits:** a link holder can list named members through the derived read capability unless the app's server enforces a responders-only roster; where a link is posted is the real access control. Rotation protects the future only. A quiet member's per-object key lives in their pointer; losing the pointer loses the object. ## 6. `@microtoll/blind-store` - **Protects:** - The server holds only what it cannot read: opaque blobs, pointer rows with no object id, hashed capabilities, and hashed link tokens. - No identity columns, no creator columns, and no timestamps unless needed for expiry. - Random UUID keys. - Authorisation by capability, so writes do not reveal who made them. - **From whom:** A1 (content), A6 (unauthorised writes). - **Does not protect:** - The accepted trades, each to be listed exactly: - the coarse selector and window per item; - which selectors a connection queried; - the routing key × action × day in rate limits; - pointer counts per account; - timing and sizes; - push endpoints joining the subscriptions of one browser (if push is enabled). - Availability against A1. - Denial of service beyond the transport limits below. - **Completed in M4** (`packages/blind-store/DESIGN.md`, D-33 to D-36): - **What the server learns, exactly** (the trades above, spelled out). Per object: its collection, its selector and its window, its epoch, whether its roster is members-only, and the sizes of its sealed parts. Per connection: the routing key; the `Origin`; which selectors and windows it queried and watched, and when; one object id per `fetch-event` (a link redeemed, a pointer healed); which link hashes it redeemed, revoked or asked stats for; which mailbox labels it polled or watched. Per account: how many pointers and unlock methods it holds; its session generation and blob token (what changed, never when); and, in `rate_limit_counters`, that it made an object, a link or a drop today — the one table with a routing key beside an action, deleted after two days. Per link: uses, expiry, and its management hash. Everything else is ciphertext or a hash. - **What a database copy holds:** the above, plus sealed link payloads and drops until the sweep empties or deletes them (a used-up or expired link's payload at once; the link a week after expiry; a drop at expiry). Objects are never swept: what to keep is the app's decision. No key of any kind is in the database. - **The client obligations for cover traffic:** decrypt only what its pointers hold keys for; query the whole area it shows at a precision it fixes; never query by a list of ids; keep `fetch-event` for redeeming and healing. The engine cannot check these; the access package's `queryObjects` and the example app follow them. - **The schema rules as a test:** `test/schema.test.mjs` fails on a non-random primary key outside the whitelist, a sequence, a timestamp other than the two expiries, an identity-named column, a reference from objects, members, links or drops to `users`, or a hash column without a 32-byte check. A host runs it against its own tables. - **The database role:** the server connects as `blind_store_app`, which can read and write the eight tables and run the sweep, and can do no DDL, make no role and reach no other schema — so a compromise of the server process is a compromise of what the server can already read, and nothing more. - **The bound handshake (server half):** a signature made for another origin, another namespace or another nonce is refused; a non-browser client is verified against each allowed origin in turn; a browser's `Origin` is checked at upgrade and used for the verification. - **Transport limits:** frame 4 MiB, sign-in within 120 s, 300 messages per socket refilled 60 a second, 2,000 sockets, three unlock lookups per socket, per-field caps, a 5,000-row query answer. Each fails closed for one connection or request only. The per-address limits and the blanking of the client's address are the reverse proxy's (`deploy/nginx.sample.conf`): the server never holds an address beside a routing key. - **Live watches leak nothing new:** routing is by the selector a connection already sent; a member-row change is pushed content-free and debounced; the imminent watch carries no parameter at all. - **Availability** is not protected against A1 or against a determined flood: the limits are backstops, the daily counters fail open, and a lost LISTEN connection costs live updates (loudly logged, retried), not the service. ## 7. `@microtoll/mailbox` (M3b, built inside M5) - **Protects:** - A1 cannot compute a label (that needs one of the two private keys), attribute a row to an account, or read a bundle (sealed to the recipient, hybrid when the recipient's key is). Labels change monthly, so a stable polling fingerprint lasts at most two months. - A6 cannot forge an invitation from a named person: the bundle is signed, and (version 2, `packages/mailbox/FORMATS.md` §2) the signature binds the mailbox it is for and the recipient it is sealed to, so a bundle re-sealed into another mailbox or for another recipient verifies for nobody. The collection-side check — the signer must be the contact whose mailbox it arrived in — stays as a second lock. - Withdrawal: a consumed or withdrawn drop is served without its bundle and emptied in the row, so a client that ignores the flag still finds nothing to open. - **From whom:** A1 (content and attribution), A6 (forgery), A3 (the mailbox needs the address, and the address needs a key only a co-member holds). - **Does not protect:** - The routing graph: A1 sees that one anonymous label was written under and then read; with the connection's routing key, that this account polls these labels. It cannot pair the two ends without both private keys. - Linkage is classical (P-256 ECDH). Under A7, a former co-member could recover who invites whom. There is no standard post-quantum non-interactive key exchange; the bundle itself is hybrid where the recipient's key is. - Withdrawal reaches only a drop not yet collected; a collected key has left. - An unsigned drop is usable: the key in it either works or it does not. The engine attributes it to nobody; what the app does with it (a tray, a hold) is the app's. - Who is a contact, who is a favourite, who is blocked: the app's, in its identity blob. - **Trades:** the labels a connection polls and watches (one per contact per month, two months deep); the daily count of drops per routing key; the row's expiry. ## 8. Review triggers Update this document when any of these happen: - a new format or label; - a new plaintext column; - a new pre-authentication message; - a change to a default (selector precision, session length, iteration count); - hybrid mode switched on; - a browser shipping or withdrawing a primitive the engine relies on. # The honest limits What nothing here protects against, on one page, in plain words. Every package's page repeats its own part; the [threat model](threat-model.html) has the adversaries and the reasoning. ## A compromised device or page wins If a script runs inside your page — an injected one, a bad dependency, a browser extension with the wrong permissions — or the device itself is compromised, everything the page holds is gone: the root key, the keys to every object, the session. A trusted-device session is exactly as safe as the unlocked device it sits on. This is why the examples ship with a strict content security policy and no third-party script, and why the engine has no dependencies to carry one in. ## Traffic shape is visible The server, and anyone who can watch the wire, sees who connects and when, how many rows an account holds, the sizes of ciphertexts, and the coarse selector of every object (a map cell and a day in an events app; a shelf in the notes example). While few people use a deployment, one active account is easy to pick out. Hiding this would need mix-network routing, which is out of scope. ## A link is as private as the channel it travels through The secret is in the URL fragment, which servers and link previewers do not receive. The messaging app the link is sent through can read it unless that chat is end-to-end encrypted. Where a link is posted is the real access control. ## Nothing can be moderated in advance The server cannot read content, so abuse handling starts with a report from someone who can. The engine ships no reporting; an app that needs it builds its own. ## No server-side recovery Lose every unlock method — the passkey and the recovery code — and the data is lost to everyone, including the operator. There is no reset link because there is nothing for one to reset. ## Quantum computers, honestly Every public-key seal is classical (P-256) unless hybrid mode is on for every recipient. A copy of a database taken today could be opened by a large enough quantum computer, which does not yet exist. Hybrid mode protects only what is sealed after it is switched on, and an object is only as protected as its weakest member's copy of the key. Symmetric encryption (AES-256-GCM, HKDF, SHA-256) is not materially weakened. The mailbox label is classical by necessity: there is no standard post-quantum non-interactive key exchange. ## Cooperative, not cryptographic "Sign out everywhere" and withdrawing a link are honoured by honest clients. Neither can take back a key that has already left. Removal re-keys the future; it cannot un-read the past. ## What the server does learn Per object: its collection, selector and window, its epoch, whether its roster is members-only, the sizes of its parts. Per connection: the routing key, the origin, which selectors it queried and watched, one object id per link redeemed, which link hashes and mailbox labels it touched. Per account: how many pointers and unlock methods it holds, and that it made an object, a link or a drop today (deleted after two days). Everything else is ciphertext or a hash. The full list, and what a database copy holds, is in the [threat model](threat-model.html#6-microtollblind-store). # Formats and stability Two promises, stated separately, because they are different things. ## The bytes never change meaning From the first published version: no version byte, label, key-derivation parameter or signed-byte layout ever changes meaning, and readers for every published format stay supported. **Anything you encrypt with any published version will decrypt with every later one.** A format is a versioned thing. The AEAD blob starts with `0x01`; the classical seal with `0x03`, the hybrid seal with `0x02`; a signed member row is `{ v: 3 }`; a mailbox bundle `{ v: 2 }`. A new format is a new version byte beside the old one, never a change to the old one. Every label the engine derives a key or a context from is `//v`, and three labels of retired formats are refused in every namespace so they can never be reused by accident. How a format is allowed to change: as a **pending decision** in the repository's `DECISIONS.md`, with the reasoning, decided by the maintainer before any code, and recorded with the date. The record of every format decision so far is on the [decisions](decisions.html) page; the formats themselves are in each package's formats page ([identity](packages/identity-formats.html), [access](packages/access-formats.html), [mailbox](packages/mailbox-formats.html)). ## The API may change until 1.0 Function names and options may change in any 0.x minor release. Every such change is listed in the package's `CHANGELOG.md` with a migration note; patch releases never break. The packages move in lockstep: one engine version to pin, one to name in a bug report. ## What a fixture is A fixture is a set of bytes an earlier version wrote — a sealed blob, a signature, a derived key — frozen in the repository and never regenerated. Every later version must open and reproduce it, so a changed label, version byte, parameter or layout turns a test red instead of making stored data silently unreadable. A new format gets new fixtures beside the old ones. Published test vectors (RFC 5869, RFC 8032, RFC 5903, RFC 7914, NIST's AES-GCM, the X-Wing drafts) run through the public API, never through a re-implementation in a test file. # For AI coding agents (and the people using them) The engine exists for two audiences, and the second one writes most new code now. Three things make it usable from inside a coding tool without a browser tab open. ## llms.txt is the index: one line per page with what it covers, following the llms.txt convention. is every page concatenated, as Markdown, for a tool that wants the whole thing in context (about the size of a long article). Both are generated from the same sources as this site, so they never drift from it. ## The MCP server `@microtoll/mcp` speaks the Model Context Protocol over standard input and output, so Claude Code, Cursor and any MCP host can call it. Zero dependencies; no network; nothing runs but what you see in `src/`. Add it to a host (Claude Code shown; the others take the same command): ```sh claude mcp add microtoll -- npx -y @microtoll/mcp ``` Three tools: - `microtoll_search_docs({ query })` — full-text search over these pages, returning the page, the section and an excerpt. - `microtoll_read_doc({ path })` — one page as Markdown (the `path` from a search result, or from llms.txt). - `microtoll_scaffold({ directory, namespace, origin })` — writes the notes starter into an **empty** directory: the client (`notes.js`, `page.js`, `index.html`), a Compose file that runs Postgres, the server and nginx from the published packages, and a README of next steps. It writes files and nothing else: no commands run, no network, and it refuses a directory that is not empty. ## What an agent should know before writing security code with this - **Do not add cryptography.** Every construction needed is here, with its threat model. If a task seems to need a new primitive, mode or key derivation, the answer is a question to a person, not code. - **The server stores only what it cannot read.** A column that could name a person, a plaintext timestamp, a sequential id: the schema test will fail, and it should. - **The namespace is one string, used twice**: in `createCryptoCore` on the client and in `BLIND_STORE_NAMESPACE` on the server. Sign-in is bound to it and to the page's origin. - **Say what is not protected.** Copy the [honest limits](honest-limits.html) into the app's own about page. That is not optional. # Contributing Microtoll Engine will face security review and a funded audit, so the rules below exist to keep it readable and its cryptography unchanged. They apply to every contribution, the maintainer's included. ## The rules 1. **No new cryptography.** The constructions are fixed, verified by published test vectors and pinned by frozen fixtures. A change to a primitive, a mode, a label, a version byte, a key-derivation parameter or a signed-byte layout is not a pull request: it is a **pending decision** in `DECISIONS.md`, with the reasoning and a recommendation, for the maintainer to decide first. Adding a test vector or a fixture is fine. 2. **Zero runtime dependencies** in the browser packages. The server package has exactly `ws` and `pg`, pinned; a new dependency anywhere needs a justification in the pull request and the maintainer's decision. 3. **The server stores only what it cannot read.** No identity column, no plaintext content, random UUID keys, no timestamp unless something functionally needs it. `packages/blind-store/test/schema.test.mjs` enforces the rules against a live database; a change that fails it is a design question, not a test to loosen. 4. **Nothing financial, no telemetry, no hosted-service code**, anywhere. 5. **Honest claims.** Post-quantum protection is "hybrid mode"; every package's README and `THREATMODEL.md` say what is *not* protected. A change to the attack surface updates the threat model in the same pull request. 6. **Written to be read.** Every non-obvious choice gets a comment naming the test vector, RFC or threat it addresses. Plain language, UK English. ## Sign-off (DCO) Every commit carries a `Signed-off-by:` line with your real name and email (`git commit -s`), certifying the [Developer Certificate of Origin](https://developercertificate.org/): that you wrote the change or have the right to submit it under the file's licence (Apache-2.0 for the browser packages and examples, AGPL-3.0-only for `blind-store`, CC-BY-4.0 for documentation). ## Working on it ``` npm install # TypeScript for the declaration check; ws and pg for blind-store npm run check # typecheck + every suite npm run db:up # a throwaway Postgres 17 in Docker for the database-backed suites npm run docs # builds the docs site and llms.txt into docs/site/ ``` - Tests use Node's built-in `node:test`, no framework, and drive each package through its **public API** — never a re-implementation in the test file. A frozen fixture pins every format. - Each package keeps a `CHANGELOG.md`. - The repository is self-contained: it names no other product. ## What a pull request needs Tests; docs updated (README, `CHANGELOG.md`, `FORMATS.md` where a format is touched); the threat model updated if the attack surface changed; the deviations section updated; `npm run check` green with the database-backed suites running. A pull request that changes a format without a recorded decision will be closed with a pointer to `DECISIONS.md`, kindly. ## Conduct Be direct and be kind. Security findings are welcome and are handled through `SECURITY.md`, never through a public issue. # Security policy Microtoll Engine is security software: sign-in, key handling, access control and revocation for end-to-end-encrypted apps. If you have found a weakness in it, thank you for reading this first. ## Reporting - Write to **security@microtoll.dev**. Never open a public issue for a security problem. - Say which package and version, what you did, what happened, and what you expected. A proof of concept is welcome; user data is not — please do not include anyone's real content or keys. - If you want your report encrypted, ask for the current key in a first, content-free message; the key's fingerprint is published in this file once the project is public. ## What to expect - An acknowledgement within **seven days**. - A fix, or a written statement of why there will not be one, within **ninety days** of the report, sooner where the fix is simple. Where a fix changes a stored format, the format rules (DECISIONS.md D-04) still apply: readers for every published format stay supported. - Credit in the release notes, if you want it. There is no bounty: nothing in this project is financial, by rule. - Coordinated disclosure: we ask that you give us the ninety days before publishing, and we will tell you the release date in advance. ## Scope **In scope:** the packages under `packages/` (`crypto-core`, `identity`, `access`, `mailbox`, `blind-store`, `mcp`), the reference deployment under `deploy/`, the examples, and the documents that describe what they protect (`THREATMODEL.md` and each package's `FORMATS.md`). A gap between what the threat model claims and what the code does is in scope even if nothing is "exploited". **Out of scope:** applications built on the engine (report to their owners), the docs site's hosting, and the limits the threat model already states (`THREATMODEL.md` §2: traffic shape, a compromised device, script injection into a page that holds keys). ## Verifying a release Release tags are signed with the maintainer's key; the public key is published here when the first release is made, and every published package carries npm provenance linking it to the tagged commit and the workflow that built it. # DECISIONS.md — founder decisions log **Append-only.** A decision is recorded by adding a dated entry under "Recorded"; it is never edited afterwards, only superseded by a later entry that names it. Open questions sit under "Pending" with a recommendation until the founder decides. Claude Code never resolves a crypto, licensing or IP question silently (build plan §4). Standing rules are in `microtoll-build-plan.md` §0 (the non-negotiables) and are not repeated here. **2026-09-27: this log was restated so that it names no other product** (the founder's direction, recorded below). Each decision keeps its number, its date and its substance; the earlier wording is in the git history. Two entries, D-10 and D-22, were not engine decisions and were withdrawn from this log; their numbers are not reused. --- ## Recorded **2026-09-27 — Launch item 8 done: every package on npm with provenance.** The signed tag `v0.1.1` (re-created on the fixed commit) ran the release workflow: the full check against a database, then each package staged with provenance through GitHub's OIDC, and the founder approved the six on npm. Now on the registry: `crypto-core`, `identity`, `access`, `mailbox` and `blind-store` at 0.1.1 and `mcp` at 0.1.2, each with a signed provenance statement in the public transparency log. Verified by a clean install of all six followed by `npm audit signatures`: "6 packages have verified attestations". The published MCP server now reports its real version. From here a release is: bump, push, signed tag, approve on npm. The launch checklist is complete apart from item 12 (Show HN, Sponsors), which is the founder's. The MCP registry entry still names 0.1.1; moving it to 0.1.2 needs a fresh registry sign-in with a `read:org` token. **2026-09-27 — The tag `v0.1.1` was moved once, on the founder's instruction (an exception to the no-rewrite rule, recorded).** The first release through the workflow (0.1.1 for the five lockstep packages, mcp 0.1.2) failed before anything was staged: npm's provenance check requires `repository.url` in each package.json to name the repository the build ran in, and the five original packages had no `repository` field. The founder chose to delete the tag and re-create it on the fixed commit (`engine` `713da65` and the correction after it) rather than issue 0.1.2 for a metadata field. No commit was rewritten; nothing had been published under the tag; the private record is untouched. The rule stands for everything else. **2026-09-27 — `io.github.microtoll/mcp` is listed in the official MCP registry (launch item 11).** `@microtoll/mcp` 0.1.1 published by hand; the listing made with `mcp-publisher` 1.8.1 from `packages/mcp/server.json`; verified from the registry (status active, package `@microtoll/mcp@0.1.1`, stdio) and by running the published package as a host would: it answers `initialize` and lists its three tools. What it took, for the record: the registry grants an organisation namespace only to an organisation Owner, and only when the sign-in token can read organisation membership. The device-flow sign-in cannot, so the founder made a classic personal access token with the single scope `read:org` (seven-day expiry, to be deleted) and signed in with `login github --token`. The organisation membership was also made public and the profile un-hidden along the way; neither turned out to be the cause. Found meanwhile: the published server reports its version as 0.0.0 (a hard-coded constant); fixed in the repository to read `package.json`, to go out with the next version. **2026-09-27 — D-48 decided: `@microtoll/mcp` versions on its own; the other five stay in lockstep (amends D-41).** The founder's choice, on the question of how to ship the one metadata line the MCP registry requires (`mcpName` in the published package). Lockstep versions remain good practice for the five packages that share frozen byte formats (`crypto-core`, `identity`, `access`, `mailbox`, `blind-store`): one number names a set tested together. `@microtoll/mcp` ships documentation and a scaffold, none of the five imports it, and it changes whenever the documentation does, so it takes its own version from here. First use: `@microtoll/mcp` 0.1.1, metadata only (`mcpName` `io.github.microtoll/mcp`, `repository`, and `server.json` beside it; `engine` `f9e6f00`), for the listing in the official MCP registry (launch item 11). The release workflow still publishes all six on a `v*` tag, skipping any version already on the registry. **2026-09-27 — Microtoll Engine 0.1.0 is published: six packages on npm, the signed tag, the site.** The founder published each package by hand from `D:\PROJECTS\engine-public` in dependency order (crypto-core, identity, access, mailbox, blind-store, mcp), each with a passkey approval; verified from the registry by a clean install of all six into an empty folder, and each one loads. Shasums: crypto-core `3d82d75f…`, identity `0fdfb9cb…`, access `7e169792…`, mailbox `04b3c188…`, blind-store `a5041461…`, mcp `66593e8e…`. The signed tag `v0.1.0` (`d84c7d4`) is on `microtoll/engine` and GitHub verifies it. The release workflow's first run failed, as expected, on "cannot publish over a previously published version"; it now skips versions already on the registry (`engine` `67716d9`); provenance starts with 0.1.1, once trusted publishing is set on each package. `microtoll.dev` answers over plain http from GitHub Pages; the certificate for https is being issued. Email routing for `security@microtoll.dev` is set up on Cloudflare with a strict DMARC policy (`p=reject`). Launch items done: 1 to 7 and 10; 8 waits for the next version; 9 for the certificate; 11 (the MCP listing) and 12 (Show HN, Sponsors) remain. The public repository's CI needs `npm run docs` before any commit that touches a document the docs snapshot includes; the first run failed on a stale `packages/mcp/generated/docs.json`. **2026-09-27 — The engine is public: `microtoll/engine` from a snapshot; 0.1.0 prepared; Pages deployed.** Launch items 5, 6 and 7 (the repository half): every package is at 0.1.0 without `"private"`, in lockstep (D-41), 274 tests green with the database (`engine-record` `ebd99be`); the variable `PUBLISH_GATE_OPEN` is `true` on both repositories; the founder created the public repository `microtoll/engine` from the snapshot at `engine-record` `2237d37`, one commit `c951b2f` that says where the dated history is kept, nothing rewritten (D-01). GitHub Pages is enabled on it from `.github/workflows/pages.yml` with the custom domain `microtoll.dev`, and the first deployment succeeded; the domain answers once its DNS record points at `microtoll.github.io` (item 9, the founder's Cloudflare step). Still to do: the six packages' first publish by hand (item 7's tag, item 8), `security@microtoll.dev` (item 10), the MCP listing (item 11). From this entry, development continues in the public repository; this private one is the dated record up to the snapshot and this entry. **2026-09-27 — `pqc-scan` v0.1.0 tagged and signed; trusted publishing set.** The founder made the release signing key (`~/.ssh/microtoll-release`, Ed25519, passphrase-protected; launch item 3), registered its public half on GitHub as a signing key, and pushed the signed tag `v0.1.0` (`3e7c05c`), which GitHub verifies as valid. On npm, the package trusts `release.yml` for **staged** publishing only, npm's recommended setting: a tag stages a version with provenance and the founder approves it by hand, so a compromised build run can never publish alone (`pqc-scan` `5414d9d`). Publishing access requires two-factor authentication with no bypass tokens. Provenance starts with the next version; 0.1.0 was published by hand. **2026-09-27 — `@microtoll/pqc-scan` 0.1.0 is on the npm registry.** The first public release of anything from this project. Published by the founder from their own machine (`npm publish --access public`, two-factor authentication by passkey), from `pqc-scan` `5955777`; shasum `41000bdca199ed0b115b40e5c483201538741b68`; 22 files, 73.5 kB. Verified from the registry: `npx @microtoll/pqc-scan --version` prints 0.1.0 and a scan writes its two reports. The first publish attempt showed that npm removes a `bin` path written with a leading `./`, which would have left the package with no command; fixed before publishing. The npm organisation `microtoll` was already owned by the founder's account (`npm org ls`). Still to do for this release: trusted publishing on npmjs.com for `release.yml`, the signing key, and the signed tag `v0.1.0` (the workflow skips a version already published). Provenance therefore starts with the next version. **2026-09-27 — `pqc-scan` is public.** `github.com/microtoll/pqc-scan` was made public at `19eec51` (version 0.1.0, no longer private, a release workflow that publishes `@microtoll/pqc-scan` with provenance on a `v*` tag). Its history names no other product. Waiting on the founder: the signing key (launch item 3), the `@microtoll` npm organisation with trusted publishing for this workflow (item 4), and then the signed tag `v0.1.0`, which publishes. The engine stays private until its own launch items. **2026-09-27 — D-01 passed: the publish gate is OPEN. M6 accepted.** The founder confirmed, through the question tool, that both checks of D-01 have passed: who owns the code the engine is built from, and what the founder's employment terms require. Recorded on the founder's word; the evidence and any approval that was needed stay in the founder's private notes, outside this repository. From this entry, a public repository, a registry and a public listing are allowed for both the engine and `pqc-scan`; the launch checklist (`docs/LAUNCH.md`) governs the order. The history is still never rewritten. **M6 is accepted** on the same day: the founder ran the scanner on their own application from a fresh clone on a Windows PC and read the report, which completes the last acceptance item (the founder's reading); that run also led to the terminal default, `--test-files` and the Windows launcher (`pqc-scan` `6fac933`). The founder's choice for the first public step: `pqc-scan` goes public and to npm together, as `@microtoll/pqc-scan` 0.1.0. **2026-09-27 — M6: the three public repositories reviewed by hand; the scanner corrected.** The founder asked for three to be proposed and run. Chosen for three shapes, each a shallow clone read once: a JSON Web Token library where every finding should be genuine (`panva/jose` at `55c959f`), a large browser application with almost no cryptography (`excalidraw/excalidraw` at `84e3f5a`), and a server application with a typical sign-in stack (`requarks/wiki` at `712a3a5`). Every reported finding was a genuine cryptographic use, and a hand search of jose's source and of Excalidraw found nothing missed. Seven faults around the findings were found, each fixed in `pqc-scan` with a fixture and a test (its `DESIGN.md` §8.12): dependency versions printed as `\1.0.6` (an invalid Markdown escape, in every report with a lockfile); a package listed as a transitive dependency of itself (jose; the engine's own report had likewise listed `@microtoll/crypto-core`); `test-d` type tests not marked as test code; two "could not be read" pointers that led only to token decoding and header extraction; the RSA key pair that signs Wiki.js's tokens reported High under the comment "Generate certificates" (a certificate is a signing artefact: Medium); Wiki.js's SAML sign-in, encrypted assertions and two-factor codes unreported because their libraries were not catalogued (eight packages added, 57 in all); and the SHA-1 note, which told both applications to replace a plain content identifier as if it protected something. 50 tests pass. The engine's own report is unchanged apart from its dependency line (its own package is not a dependency). The reports, before and after, are in the founder's private acceptance notes. Still open for M6: the founder's reading of the reports (the engine's, the second application's and these three), which is the acceptance. **2026-09-27 — M6: the second application scanned; the public repository will start from a snapshot.** Two founder's choices. - **The second real application** (DESIGN §6, second item) was chosen by the founder and scanned read-only; the report and the hand review are in the founder's private notes, because they describe that application. Every expectation of the item was met. The review found two scanner faults, fixed in `pqc-scan` `ab5332f` with a fixture and a test each: a WebAuthn key reported High (it only verifies signatures: Medium), and a source file skipped as binary because of a raw control character far down (binary now means a NUL in the first 8,000 bytes). The first item still passes on this repository. Still open for M6: three public repositories for the false-positive review, and the founder's reading of both reports. - **At the publish gate**, this repository stays private as the dated record, and the public repository starts from a snapshot of the tree, with a first commit that says where the private history is kept. Nothing is rewritten (D-01's evidence rule). `docs/LAUNCH.md` item 7 says so. **2026-09-27 — D-46 and D-47 decided: option (a) of each, version 3.** The founder's choice, and built the same day. - **D-46:** new recovery codes are version 3. The check character is Σ aⁱ⁺¹·sᵢ over the 26 data characters in GF(32) = GF(2)[x]/(x⁵ + x² + 1), a = x, and the parser refuses a code whose two unused bits are not zero or that has more than 26 data characters. Tests prove every single wrong character and every swap of two different characters is refused. - **D-47:** an unlock method's sealed label is bound to that method: `frameContext("/aad/unlock-label/v3", methodType, SHA-256(credentialId))` for a passkey, the method type alone for the recovery code. A test against the real server swaps two passkeys' labels in the database and both read as null. - **How version 2 stays readable** (the reading of both decisions, written down here so that it is not silent): a version-2 code has the same shape as a version-3 one, and a label carries no version byte, so neither can be recognised by looking at it. Trying version 2 whenever version 3 fails would give back exactly what version 3 closes (a mistyped code accepted as version 2 one time in 32; a label moved between methods opening as version 2). Version 2 is therefore read only when a caller names it: `{ version: 2 }` in crypto-core, `{ recoveryCodeVersion: 2 }` and `openMethodLabelV2` in identity. Nothing writes version 2. No version-2 code or label existed outside tests. - Every earlier frozen fixture still opens; the version-3 bytes are frozen beside them (`crypto-core/test/fixtures/frozen-recovery-v3.json`, `identity/test/fixtures/frozen-v3.json`). **2026-09-27 — M6 built; its acceptance waits on the founder.** `pqc-scan` is built in its own repository (`D:\PROJECTS\pqc-scan`, commit `3680f12`, no remote): the detectors, the JSON report (schema version 1) and the Markdown report, the command line and the composite GitHub Action; 47 tests pass on Node 20 and 24. The details settled while building are that repository's `DESIGN.md` §8.9–§8.11. The first acceptance item passes: on this repository it finds AES-256-GCM, HKDF-SHA-256, PBKDF2 at 310,000 iterations, SHA-256, Ed25519 (Medium), the P-256 ECDH seal (High), the hybrid `MLKEM768-X25519` (post-quantum), the server's `node:crypto` verify and hash, and `deploy/nginx.sample.conf` as hybrid-enabled. The other items need the founder's choices: a second real application, and three public repositories for the false-positive review. Also since the entries below: `identity`, `access` and `mailbox` carry frozen version-2 fixtures (commit `e7a4d59`, 265 tests); no format changed. **2026-09-27 — D-42, D-43, D-44, D-45 decided: `pqc-scan` as designed (`docs/DESIGN-M6-pqc-scan.md`).** The founder directed that the build be completed as far as possible without further questions, which takes each recommended option: D-42 (a) its own repository `D:\PROJECTS\pqc-scan`, npm `@microtoll/pqc-scan`, binary `pqc-scan`, Apache-2.0, Node 20 or later, zero runtime dependencies; D-43 (a) a tokenizer with call-site patterns; D-44 (a) JSON schema v1 and NCSC-shaped Markdown; D-45 (a) a composite GitHub Action, the JSON schema as the only seam for a hosted report, nothing paid built. Crypto decisions are not covered by that direction and stay pending (D-46, D-47). **2026-09-27 — The repository is self-contained; a private GitHub remote is allowed.** The founder's direction: nothing in the engine's code, tests or documentation names another product, its files, documents or versions. Notes that need them are kept outside the repository. The git history is not rewritten (it is part of the ownership record, D-01). The repository is pushed to a **private** GitHub repository; the publish gate (D-01) still closes every public repository, registry and listing. The frozen fixtures are now written by the engine itself under the test namespace `example` (`packages/crypto-core/test/fixtures/frozen-v1.json`); no format changed. **2026-09-25 — M5 approved; M6 starts.** The distribution kit accepted at commit `216574f`: `@microtoll/mailbox` (M3b) and `examples/invite-app`, the docs site source with `llms.txt`, `@microtoll/mcp`, `SECURITY.md`, `CONTRIBUTING.md`, the inert release workflow and `docs/LAUNCH.md`; 242 tests green. Nothing pushed, published or listed (D-01). M6 (`pqc-scan`, a separate repository) begins with a written design for decision. **2026-09-25 — D-38, D-39, D-40, D-41 decided: the distribution kit as designed (`docs/DESIGN-M5.md`).** D-38: the docs site from Markdown in `docs/` with a zero-dependency renderer, `llms.txt` from the same build, GitHub Pages for microtoll.dev once public, no analytics. D-39: `@microtoll/mcp` with the stdio JSON-RPC subset written in (zero dependencies), docs search, read-doc and an empty-directory scaffold that runs nothing and fetches nothing. D-40: M3b (`@microtoll/mailbox`: the pairwise labels, bundles version 2 with the recipient and mailbox bound into the signature, withdrawal as the server does it) built inside M5, and `examples/invite-app` as the direct-invite demo. D-41: lockstep versions from `0.1.0`; a release workflow on a signed tag with npm provenance, inert until the founder sets `PUBLISH_GATE_OPEN` after D-01; signed tags; `SECURITY.md` with coordinated disclosure to `security@microtoll.dev` and no bounty; `CONTRIBUTING.md` with DCO. D-06, D-07, D-08, D-09, D-11, D-12 and D-18 are closed as adopted in M0–M2. **2026-09-25 — M4 approved; M5 starts.** `@microtoll/blind-store` accepted at commit `1efc156`: the library, schema, reference server, deployment kit and `examples/notes-app`; 221 tests green including the database-backed suites; the example built and ran end to end through Docker Compose on the founder's machine. Carried forward: M3b (the mailbox client, on the server half M4 ships); the founder's own browser run of the example; D-01 still gates publishing. M5 (the distribution kit) begins. **2026-09-25 — D-37 decided: the pointer's binding is the account, not the object id (amends D-31).** The server returns an account's pointers without an id, by design, so the M3 binding could never be opened on a fresh device (found by the notes example). The pointer's additional authenticated data is now `frameContext("/aad/pointer/v2", routingPublicKey)`; the object id is read from inside the sealed pointer. Still AES-GCM with additional data; no primitive, label or KDF change; no pointer had been written outside tests. `packages/access/FORMATS.md` §3.2 and THREATMODEL §5 updated. **2026-09-25 — D-33, D-34, D-35, D-36 decided: the server as designed (`packages/blind-store/DESIGN.md`).** D-33: an events-and-members model becomes `objects` and `object_members` with a `collection` column, a fixed-length selector and an optional date window; the query answers everything matching with no identity filter, refuses past `maxQueryRows` (5,000), and carries a host's extra fields; live watches route by the same selector, the imminent watch by a per-collection server-owned window; the message names are fixed (D-27), the selector fields generic, and `adminCapabilityHash` leaves the query and fetch replies (it would give everyone who receives cover traffic a stable per-object token); the mailbox's server half is included. This settles the server half of D-13 and confirms D-14 (what stays out). D-34: the handshake's server half verifies `"/auth/v2" ‖ 0x00 ‖ SHA-256(origin) ‖ nonce` with `node:crypto` against the connection's `Origin`, or each allowed origin when none was sent; the server imports no `@microtoll` package and a cross-implementation test proves the framing against identity's `authMessage`; the transport limits plus the query-rows backstop; the three daily counters fail open. D-20's lookup limit (3 per socket) is in it. D-35: the M0 schema rules run as a test against a live database; `blind_store_sweep()` runs hourly from the library and never sweeps objects (resolves D-19); the schema creates the least-privilege role `blind_store_app` that the server and the tests connect as, so a server compromise reaches no more than the server can already read. D-17 is resolved by the registration hook. D-36: `createBlindStore` (with `createCore` as an alias); the thin `bin/blind-store.mjs`; dependencies exactly `ws` and `pg`, pinned, with the LISTEN client written in and `pg-listen` dropped; `deploy/` with the hardened Compose file and the Nginx sample; `examples/notes-app` on the unchanged reference server with a random-shelf selector and import-map loading; Postgres for tests from a throwaway container locally and a service container in CI. **2026-09-25 — M3 approved; M4 starts.** `@microtoll/access` accepted at commit `0ba99bf`: formats v2 (D-31), the adversarial suite green with every case mapped in THREATMODEL §5, 160 tests across the three packages. M4 begins with a written design of the collection and coarse-selector abstraction and the server-side hardening for decision, then the server core as a library with a thin reference server (D-23), and `examples/notes-app`. **2026-09-25 — D-30, D-31, D-32 decided: the access layer as designed.** D-30: an events-and-members model is generalised by renaming and widening (event → object, `K_event` → `K_object`, participation row → member row, organiser → owner); the content split and the grant rule are supplied by the app; the pointer has an extension table; wire message names are fixed (D-27). D-31: formats version 2 as in `packages/access/FORMATS.md` §3 — purpose labels on the member-row and share-link signatures (and, in M3b, the recipient inside the invite signature), additional authenticated data on content, second tier, member rows, pointers and link payloads; the per-member sealed `K_object` stays an ECIES seal without extra data, stated in the threat model. D-32: M3 ships objects, members, pointers, admin seats, rotation, two-tier disclosure, share links, the display rule, the wire builders and the adversarial suite; direct invites wait for M3b. **2026-09-25 — M2 approved; M3 starts.** `@microtoll/identity` accepted at commit `cf78168`: formats v2 (D-28, D-29), 53 tests including every gap the audit listed, the acceptance demo page, THREATMODEL §4 complete. Carried forward: the server half of the bound handshake (M4); the founder's own run of the demo on a real authenticator. M3 begins with the written design of the access-layer hardening and the object model (D-13) for decision. **2026-09-25 — D-28 decided: additional authenticated data on all four identity-layer seals.** As designed in `packages/identity/FORMATS.md` §2.1–2.4: the wrapped root key is bound to its method type and identifier (SHA-256 of the credential id, or the recovery lookup hash); the identity blob and the unlock-method label are bound to the routing public key; the trusted-device session record (version 2) is bound to the routing public key, its expiry and its session generation, and a restored record whose stored routing key differs from the derived one is refused. The blob gains a cooperative `revision` counter so a rolled-back blob is refused on a device that saw a later one. Contexts are `frameContext("/aad//v2", …)`; no primitive, mode or KDF changes. **2026-09-25 — D-29 decided: the handshake signature covers purpose, origin and nonce.** The routing key signs `frameContext("/auth/v2", SHA-256(UTF-8(origin)), nonce)`. The client half ships in M2; the server half in M4 (`blind-store`), verifying against its allowed origins, with a cross-implementation test. **2026-09-25 — M1 approved; M2 starts.** `@microtoll/crypto-core` accepted at commit `095824c`: vectors green through the public API, fixtures proved in both directions, zero runtime dependencies, README quickstart and threat model complete. Carried forward: the browser cross-check of the hybrid seal (release gate, D-07) and the non-extractable signing key (identity, D-24). M2 begins with a written design of the identity-layer format hardening for the founder's decision (D-25). **2026-09-25 — D-26 decided: recovery-code check character, version 2.** The check character is the Crockford digit of the low five bits of the first byte of SHA-256(secret bytes). Entropy (128 bits), length (27 characters) and grouping are unchanged from version 1; only the check rule changes, so a random transcription error of any kind is caught with probability 31/32. The engine writes version 2 only; no version-1 data exists to read. `formatRecoveryCode` and `parseRecoveryCode` become asynchronous (SHA-256 is asynchronous in Web Crypto). Implemented in crypto-core `src/recovery.js`. (Revisited by pending D-46.) **2026-09-25 — D-27 decided: crypto-core keeps its v0 function names.** `sealToRecipient`, `openWithPrivateKey`, `pqSealAvailable` and the rest keep their names for v0, as do the wire message names. Any renames for outside users come with aliases. **2026-09-25 — D-24 decided (amends D-21): format hardening happens in the engine, package by package.** The signature purpose labels, recipient binding, additional authenticated data, non-extractable keys, recovery checksum and handshake binding are designed and built as each package is built (M1 to M4). Each change is written up before code and recorded here; the engine freezes its own fixtures. The crypto-core formats (AEAD v1, ECIES v3 and v2) are unchanged by the pass; the recovery-code checksum is the one M1 format decision. **2026-09-25 — D-25 decided: identity and access take their screens as callbacks.** `@microtoll/identity` and `@microtoll/access` hold no DOM and no page state; the app supplies its UI through callbacks and hooks, as D-11 recommends. **2026-09-25 — M0 signed off.** The founder accepted the audit, `THREATMODEL.md` and the decisions list. M1 groundwork starts: the repository, the monorepo layout, licences, CI, the standard RFC and NIST vectors, and the crypto-core API design. D-01 still gates publishing. **2026-09-25 — D-04 decided: the v0.x stability promise, as drafted.** Two promises, stated separately. Formats are frozen from the first publish: no version byte, label, KDF parameter or signed-byte layout ever changes meaning, and readers for every published format stay supported. The API may change in any 0.x minor release, always listed in the package `CHANGELOG.md` with a migration note; patch releases never break. Public wording: *"Microtoll Engine is pre-1.0. Function names and options may change between minor versions; the bytes it writes never will. Anything you encrypt with any published version will decrypt with every later one."* **2026-09-25 — D-23 decided: `blind-store` is a library first, with a thin reference server.** `blind-store` exports its core handlers, dispatcher and schema. The Docker reference server is a thin wrapper around them. A host application mounts the library and registers its own handlers beside it, so there is one server core for every consumer. Item and membership handlers stay the host's until the generic collection model (D-13) is settled (it was, by D-30 and D-33). **Licence consequence:** a host server that embeds AGPL-3.0 `blind-store` must be distributed under AGPL-compatible terms. **2026-09-24 — D-21 decided: fix format-level weaknesses before any format is frozen.** Nothing is published, so no format is frozen yet. The format-level weaknesses are fixed in one deliberate pass before the first publish: - purpose (domain-separation) labels on signatures; - recipient and mailbox binding in invitation and acknowledgement signatures; - additional authenticated data (AAD) on object-layer and identity-layer seals; - non-extractable Ed25519 private keys (this absorbs D-15); - a stronger recovery-code checksum; - binding for the handshake signature. This adds binding to existing constructions; no primitive, mode or KDF is added or substituted. Each change and its reason is recorded here. (Where the pass happens: D-24.) **2026-09-24 — D-05 decided: label namespace profile.** The constructions are fixed and only the label prefix varies: `createProfile({ namespace })`. A namespace is required, with no silent default. Retired labels stay reserved in every namespace. **2026-09-24 — D-02 decided: licensing as recommended.** - Apache-2.0: `crypto-core`, `identity`, `access`, `mailbox` and the examples. - AGPL-3.0-only: `blind-store`. - CC-BY-4.0: the docs. - Outside contributions: DCO sign-off. This decision does not open the publish gate; D-01 still governs that. **2026-09-24 — D-03 decided: the product name is "Microtoll Engine".** Packages are named by function under the `@microtoll` scope. **State of the publish gate (non-negotiable 8): OPEN since 2026-09-27.** The entry of that date records that both checks of D-01 passed. Publishing follows `docs/LAUNCH.md`, in order. --- ## Pending Each entry gives the question, the options, a recommendation, and the milestone it blocks. ### Blocks the first publish (Nothing: D-01 passed on 2026-09-27, see Recorded. The entry is kept below for the reasoning.) **D-01 — IP and employment clearance (the publish gate).** *(Passed 2026-09-27 — see Recorded.)* Two checks must both pass before anything is published: who owns the code the engine is built from, and what the founder's employment terms require. The details, the evidence being kept and the options are in the founder's private notes, outside this repository. Rules that follow from it here: - Never rewrite git history (rebase, amend, force-push or date changes) on this repository: the dated history is part of the evidence. - Local and private work may continue meanwhile. **Record here when done:** both checks passed, the date, who confirmed, and any approval that was needed (with its date). ### Closed questions (kept for the reasoning) **D-46 — Recovery-code check character, version 3: a weighted check over GF(32).** *(Decided 2026-09-27, option (a) — see Recorded.)* Version 2 (D-26) takes the check character from SHA-256 of the secret. A random error is caught with probability 31/32, but no class of error is caught for certain: one mistyped character, or two swapped characters, slips through one time in 32 and is then refused by lookup as "no such account", a confusing failure (never a wrong account). Options, all keeping 128 bits of entropy, 27 characters and the grouping: - (a) **Version 3:** the check is Σ aⁱ⁺¹·sᵢ over the 26 data characters in GF(32), with a = x under x⁵ + x² + 1 (the arithmetic bech32 uses). The 26 weights are distinct and non-zero, so **every** single wrong character and **every** swap of two characters, adjacent or not, is caught; random errors are still caught 31/32. Synchronous again (no hash). The parser also refuses a code whose two unused final bits are not zero (26 characters carry 130 bits for 128), so one string names one secret; today four strings parse to the same bytes. **Recommended.** - (b) Keep version 2. - (c) Crockford's mod-37 check symbol (catches single errors and adjacent swaps; the check position may show `*~$=U`). Error detection, not cryptography: the code's 128 random bits protect the account either way. No version-2 code exists outside tests. **Needed before:** the first publish. **D-47 — Bind an unlock method's name to the method, not only the account.** *(Decided 2026-09-27, option (a) — see Recorded.)* Version 2 (D-28) binds the sealed name of an unlock method ("Alice's phone") to the account's routing key. That stops nothing the account's own key does not already stop (another account's name would not open), and it does **not** stop the server showing one passkey's name against another of the same account's passkeys — the case that misleads a person removing a method. Options: - (a) **Version 3 of the label context:** `frameContext("/aad/unlock-label/v3", methodType, SHA-256(credentialId))` for a passkey, the method type alone for the recovery code, matching the wrapped root key's binding (D-28). **Recommended.** - (b) Keep version 2. - (c) Bind both (routing key and method): no gain over (a), since the key is per account. **Needed before:** the first publish. **D-02 — Licensing.** *(Decided 2026-09-24 — see Recorded.)* Apache-2.0 client packages can be used inside an AGPL application; AGPL on `blind-store` means anyone running a modified server as a service must publish their changes. **Recommendation:** Apache-2.0 for `crypto-core`, `identity`, `access` and `mailbox`; AGPL-3.0-only for `blind-store`; Apache-2.0 for the examples and CC-BY-4.0 for the docs; a DCO sign-off (not a CLA) for outside contributions. **D-03 — Engine product name under the Microtoll brand.** *(Decided 2026-09-24 — see Recorded.)* - (a) "Microtoll Engine", descriptive, with packages named by function (`@microtoll/identity`, …). - (b) A distinct product name, which needs a trade-mark search. **Recommendation:** (a) for v0. It can be revisited at the eight-week review. **D-04 — API stability promise for v0.x.** *(Decided 2026-09-25 — see Recorded.)* **D-05 — KDF label namespace.** *(Decided 2026-09-24 — see Recorded.)* A public library hard-wired to one product's label prefix is confusing, and changing a label counts as substituting one (non-negotiable 1). - (a) One fixed label set for everyone. - (b) Labels become a *profile*: the construction is fixed, and only the namespace prefix varies; apps pass their own namespace, for example `"myapp"` → `"myapp/routing/v1"`. - (c) A new fixed `microtoll/...` label set. **Recommendation:** (b). Nothing in any construction changes. A namespace is required (no silent default), so two apps never share a derivation by accident. Retired labels stay reserved in every namespace. **D-06 — Correct the build plan's primitive list.** *(Adopted in M1 (2026-09-25): P-256, RFC 5903 §8.1, RFC 7914 §11, X-Wing vectors — closed by the M5 design.)* The plan listed "Ed25519/X25519 seed derivation" and RFC 7748 and RFC 6070 vectors. In fact the sealing curve is **P-256** (X25519 was retired because Safari lacks it) and PBKDF2 is **SHA-256** (RFC 6070 is SHA-1 only). **Recommendation:** P-256; RFC 5903 §8.1 plus a known-answer test of the v3 seal key derivation instead of RFC 7748; RFC 7914 §11 (PBKDF2-HMAC-SHA-256) instead of RFC 6070; X25519 only inside the X-Wing hybrid, tested through the X-Wing vectors. Adding test vectors is not new cryptography. **D-07 — Post-quantum hybrid in crypto-core.** *(Adopted in M1: hybrid off by default; the Chrome cross-check stays a release gate — closed by the M5 design.)* The X-Wing seal (ECIES v2, `MLKEM768-X25519`) needs native browser support (Chrome 154 has it), Node has no native X-Wing, and no cross-check on a real browser has been done. **Recommendation:** off by default, behind an explicit opt-in; documented as "hybrid mode (experimental; requires a browser with native MLKEM768-X25519)"; tested through a test-only shim, never in a published runtime path; the Chrome seal/open cross-check a release gate before the opt-in is documented as usable. **D-08 — Source language.** *(Adopted in M1: JavaScript with hand-written declarations, no bundler — closed by the M5 design.)* JavaScript source with JSDoc; hand-written `.d.ts` declarations checked by `tsc --noEmit` in CI (TypeScript as a development dependency only); no bundler; zero runtime dependencies in the client packages. **D-09 — Supported runtimes.** *(Adopted in M1: Node ≥ 24; current Chrome, Firefox, Safari, Edge; the post-quantum path as stated — closed by the M5 design.)* Node ≥ 24; current Chrome, Firefox, Safari and Edge for the classical path; the post-quantum path needs Node ≥ 24.7 on OpenSSL ≥ 3.5, or Chrome ≥ 154. **D-10 — Withdrawn from this log** (2026-09-27): not an engine decision. **D-11 — Identity package boundary.** *(Adopted in M2 as built (createIdentitySession with the UI as callbacks) — closed by the M5 design.)* The package ends at "authenticated connection, `auth-ok` fields, identity blob opened, sealing key adopted". All UI (asking for a code, showing a code, confirming a deletion) comes in through injected callbacks. A deterministic avatar and handle stay out of v0; the passkey's user name is a caller-supplied string. **D-12 — Where the sealing key lives.** *(Adopted in M2 as built (operations in crypto-core, storage and adoption in identity) — closed by the M5 design.)* **D-13 — Generic collection model for access and blind-store.** *(Decided 2026-09-25 by D-30 and D-33 — see Recorded.)* Generalise an events-and-members model, taking the stronger mechanics of a seat-based model: an `expectedEpoch` refusal and a rotation completeness check; role-scoped capability replacement on rotation; hash-length and byte-cap `CHECK`s; `timingSafeEqual` comparisons; then the coarse selector and the cover-traffic query. **D-14 — What stays out of v0.** *(Confirmed 2026-09-25 by D-33 — see Recorded.)* Reporting and moderation; a public layer; operator disclosure keys; repeat grants; live signals; Web Push. Guest (ephemeral) identities and live watches over the selector stay in. Push can follow as an optional package once its documented join is written into THREATMODEL.md. **D-15 — Non-extractable Ed25519 private keys.** *(Absorbed into D-21, 2026-09-24; built in identity, M2.)* Derive the public key once with an extractable import, then re-import the private key non-extractable: byte-identical outputs. **D-16 — Milestone numbering.** *(Adopted: the mailbox is M3b.)* `pqc-scan` stays M6; `mailbox` is M3b, built inside M5 (D-40). **D-17 — Terms and 18+ columns.** *(Decided 2026-09-25 by D-35 — see Recorded.)* The core schema carries no policy columns; a registration hook lets an app enforce its own policy and store its own flag. **D-18 — Repository location and version control.** *(Adopted at M0: `D:\PROJECTS\microtoll`; a private GitHub remote from 2026-09-27; public only when the gate opens.)* `pqc-scan` has its own repository (D-42). **D-19 — Retention sweeps.** *(Decided 2026-09-25 by D-35 — see Recorded.)* `blind-store` sweeps expired and fully used link tokens, consumed and expired mailbox rows, and rate counters older than two days — all of which would otherwise keep ciphertext that carries keys, or activity records, indefinitely. Its effect on what a database copy reveals is in THREATMODEL.md. **D-20 — Keep the passkey-as-PRF-only model and the open unlock lookup.** *(Decided 2026-09-25 by D-34 — see Recorded.)* The server never verifies a WebAuthn assertion; the PRF output is the secret; the unauthenticated lookup returns only wrapped material; the lookup is rate-limited in `blind-store`. **D-22 — Withdrawn from this log** (2026-09-27): not an engine decision. **D-26 — The recovery-code check character.** *(Decided 2026-09-25 — see Recorded; revisited by D-46.)* Version 1 was the sum of the 16 bytes mod 32, which misses a mistyped character whose error falls only in a byte's top three bits, and misses swapped neighbours. - (a) Keep version 1. - (b) Crockford's mod-37 check symbol. - (c) One character from SHA-256 of the 16 bytes (chosen). **D-30 — The object model (resolves D-13).** *(Decided 2026-09-25 — see Recorded.)* - (a) As designed. **Recommended.** - (b) Keep an event vocabulary in the package API (no renames). - (c) A wider redesign around a seat model for members. **D-31 — Access-layer hardening, version 2 (FORMATS.md §3).** *(Decided 2026-09-25 — see Recorded.)* - (a) All of it. **Recommended.** Every context is known before the open, no schema change, no new primitive. - (b) Signature labels only. - (c) Keep version 1. **D-32 — What M3 ships (FORMATS.md §4).** *(Decided 2026-09-25 — see Recorded.)* **D-33 — The collection model on the server (DESIGN.md §2).** *(Decided 2026-09-25 — see Recorded.)* - (a) As in DESIGN.md §2. **Recommended.** - (b) Keep domain-specific field names (`geoBucket`, `dateStart`, `dateEnd`) and the admin hash on the wire. - (c) One table per configured collection instead of a `collection` column. **D-34 — The bound handshake, server half, and the transport limits (DESIGN.md §3).** *(Decided 2026-09-25 — see Recorded.)* - (a) As in DESIGN.md §3. **Recommended.** - (b) Verify against the `Origin` header only, refusing a connection that sends none (breaks non-browser clients and every test client). - (c) Import `@microtoll/crypto-core` on the server for the framing (one implementation, but the server package then contains code that can decrypt). **D-35 — Schema rules as tests, the sweep, the database role (DESIGN.md §4).** *(Decided 2026-09-25 — see Recorded.)* - (a) As in DESIGN.md §4. **Recommended.** - (b) Also sweep objects a configurable time after their window ends. **D-36 — Library, reference server, deployment kit, example (DESIGN.md §5).** *(Decided 2026-09-25 — see Recorded.)* - (a) As in DESIGN.md §5. **Recommended.** - (b) Keep `pg-listen` as a third dependency. - (c) Make the example a calendar rather than notes. **D-37 — Correct the pointer's binding (amends D-31).** *(Decided 2026-09-25 — see Recorded.)* - (a) Bind the routing public key; the id inside. **Recommended; done.** - (b) Keep the object-id binding and add a plaintext object-id column to the pointer table (breaks the M0 rule: the server would hold every account's object list). - (c) No binding beyond the label. **D-38, D-39, D-40, D-41** *(Decided 2026-09-25 — see Recorded; the options are in `docs/DESIGN-M5.md`.)* **D-42, D-43, D-44, D-45** *(Decided 2026-09-27 — see Recorded; the options are in `docs/DESIGN-M6-pqc-scan.md`.)* **D-28 — Additional authenticated data on the four identity-layer seals (FORMATS.md §2.1–2.4).** *(Decided 2026-09-25 — see Recorded; the label binding revisited by D-47.)* - (a) All four, as designed. **Recommended.** - (b) Only the session record and the wrapped root key. - (c) None. **D-29 — The handshake signature is bound to its purpose and origin (FORMATS.md §2.5).** *(Decided 2026-09-25 — see Recorded.)* - (a) Label and origin, as designed. **Recommended.** - (b) Label only. - (c) Keep the bare nonce.