session-tiers
Session tiers, not record tiers. This page covers session authentication tiers (how far a user has unlocked). For per-record data tiers —
elevate/demote, tier DEKs, tiered blobs — see tiers.
| Subpath | @noy-db/hub (core) — session tiers, policy gates, and secret lifecycle ship in @noy-db/hub's main entry point; tier-2/3 factor packages are the @noy-db/on-* family |
| Factory | none — always on for every createNoydb() call |
| Cluster | collaboration-and-auth |
| Cross-cuts | every @noy-db/on-* unlock package, plus @noy-db/hub's KEK derivation, keyring layout, and session module. |
What it is
Every noy-db vault is unlocked with a secret — the one secret the hub never sees in plaintext outside a short-lived in-memory derivation. (The hub is the trust-boundary module — @noy-db/hub — that owns encryption and the keyring; storage backends only ever see ciphertext.) Typing that secret on every read is not realistic, so noy-db layers two lighter-weight unlock methods on top of it and calls the resulting stack the three-tier privilege ladder. Sensitive actions (rotating the secret, enrolling a new device, exporting plaintext, granting a teammate access, …) are guarded by a policy gate: a small declarative rule that says which tier — and which extra proof — an action requires.
Two terms recur throughout this page:
- KEK (key-encryption key) — the key derived from the secret. It never touches disk; its only job is to unwrap the DEKs.
- DEK (data-encryption key) — one per collection. DEKs are what actually encrypt your records with AES-256-GCM; the KEK just wraps and unwraps them.
The ladder exists to satisfy three constraints at once: the vault owner controls the root secret and nobody else can read it (zero-knowledge); routine use shouldn't require retyping a secret; and losing a device, a passkey, or the secret itself each has a recovery path that doesn't collapse the security model.
Opt in
Session tiers require no opt-in. Every createNoydb() call gets the three-tier ladder and a default policy (PERSONAL_POLICY) automatically — there is no with*() strategy to wire in for tier 1.
import { createNoydb } from '@noy-db/hub'
// Tier 1 (secret) and the default policy are already active — nothing to opt into.
const db = await createNoydb({ store, user })
Tier 2 and tier 3 are different: each method lives in its own @noy-db/on-* package, and enrolling a slot means importing that package's enrollment function and calling the matching db.enroll* method (see Tier 2 and Tier 3 below). For example, tier 3 via @noy-db/on-pin:
import { enrollPin } from '@noy-db/on-pin'
const pinState = await enrollPin(unlockedKeyring, { pin: '1234', ttlMs: 15 * 60 * 1000 })
await db.team.enrollUnlock('acme', pinState)
The three tiers
| Tier | Name | Role | Typical lifetime | Where the factor lives |
|---|---|---|---|---|
| 1 | Secret (root) | Derives the KEK via PBKDF2. Built into @noy-db/hub core — there is no on-secret package. | Touched only at enrollment, recovery, and tier-1-gated actions. | The user's memory (standard and echo modes) or a developer-supplied NoydbSealer (managed mode). |
| 2 | Authenticate | Routine login — replaces retyping the secret. Multi-slot: any one enrolled method unlocks. | Days to weeks, app-defined. | on-webauthn / on-oidc / on-password slots inside the keyring file. |
| 3 | Unlock | Quick-resume after idle. | Minutes (e.g. 15 min), app-defined. | on-pin — the canonical tier-3 primitive. |
Tier 1 outranks tier 2, which outranks tier 3: only a higher tier can reset a lower one, and a policy gate's minTier is a floor — a tier-1 session can always do what a tier-2 gate allows, never the reverse.
Two auxiliary mechanisms sit alongside the ladder rather than inside it:
- 2FA factors —
on-totp,on-email-otp,on-recovery,on-shamir, and roamingon-webauthnsupply fresh, off-device proofs that policy gates can additionally demand for sensitive actions. They are not session tiers; a fresh proof is required every time a gate asks for one. - Threat overlay —
on-threatlayers lockout, duress-secret wipe, and honeypot routing on top of tier-1/tier-2 attempts, independently of the policy gates.
Usage
Each tier below is a self-contained flow: enroll a factor, then unlock or act with it. Policy gates, secret rotation/recovery, and managed-secret mode build on top of the three tiers and are covered in their own subsections.
Tier 1 — Secret
The secret is the only secret the user genuinely owns. The hub derives the KEK from it via PBKDF2-SHA256 with 600,000 iterations. The KDF, wrap algorithm (AES-KW), envelope format, and salt/IV sizes are fixed inside the hub — there are no options to change them.
Tier 1 ships in three modes. You pick one per vault with secretMode; the choice is recorded in the keyring, so the data itself decides how it may be opened — not the app that happens to be reading it.
| Mode | secretMode | What the user supplies | Pick it when |
|---|---|---|---|
| standard (default) | 'standard' | one phrase, typed into one field | The ordinary case: a phrase the user remembers, typed rarely. |
| echo | 'echo' | one sentence enrolled as three parts (prompt / echo / key) | You want the rare tier-1 prompt to be phishing-resistant, and a built-in memory cue for a secret the user types twice a year. |
| managed | 'managed' | nothing — the hub generates and seals the secret | You want coercion resistance: the user has no secret to reveal. |
The rest of this subsection describes standard mode; echo and managed have their own subsections below. All three derive the same KEK and feed the same tier-2/tier-3 ladder — the mode changes only how tier 1 is presented and proven.
Phrase format
Secrets are phrases: several simple lowercase words, easy to remember, constrained enough that a weak choice can't silently collapse the security floor.
| Rule | Default | Strict policy |
|---|---|---|
| Character pattern | /^[a-z]+( [a-z]+)*$/ (lowercase letters and single spaces only) | same |
| No punctuation, symbols, digits | required | required |
| No leading/trailing or doubled whitespace | required | required |
| Minimum word count | 6 (~77 bits) | 8 (~103 bits) |
| Minimum word length | 3 characters | 3 characters |
| Repeated adjacent words | rejected | rejected |
✅ "correct horse battery staple printer toaster"
❌ "abc" — too few words
❌ "Correct horse battery staple printer toaster" — uppercase
❌ "correct horse battery staple printer toaster" — double space
❌ "correct horse battery staple printer the the" — repeated adjacent
Word count is enforced separately from the character pattern — a phrase must both match the pattern and meet minWords. Every rule in the table is overridable per vault via SecretPolicy, including a full customValidator escape hatch for non-space-delimited scripts or organization-specific rules (see API reference).
Strength validation
import { validateSecret, assertStrongSecret, WeakSecretError } from '@noy-db/hub'
validateSecret('correct horse battery staple printer toaster')
// → { ok: true, words: 6 }
validateSecret('abc')
// → { ok: false, reason: 'too-few-words', minimum: 6, got: 1 }
assertStrongSecret('abc')
// → throws WeakSecretError { reason: 'too-few-words', suggestion: '...' }
Enforcement differs by ingress point:
- First-time vault creation (
createNoydb) andgrant()validate the phrase only when the caller opts in — passvalidateSecret: truetocreateNoydb, or{ validateSecret: true }inGrantOptions. db.rotateSecretanddb.recoverSecretvalidate the new phrase by default; pass{ allowWeakSecret: true }to bypass (test fixtures, CLI scripts).
const db = await createNoydb({ store, validateSecret: true })
// → new vaults now throw WeakSecretError on a weak initial secret
Secret handling (developer responsibility)
The cryptographic risk is in the hub; the handling discipline is in your code:
- Never write the secret to logs, telemetry, error reports, or analytics.
- Never persist it in
localStorage, cookies, or any cache that outlives the unlock call. - Use
<input type="password" autocomplete="new-password">for enrollment forms — don't pre-fill from clipboard or autofill.
Tier 2 — Authenticate (multi-slot)
A user enrolls one or more authenticator slots. Each slot independently wraps the same KEK (or, for on-password, the DEK set directly) under a method-specific key — any one enrolled slot unlocks the vault. Adding or removing a slot is a constant-time keyring write; it never re-keys the DEKs.
interface KeyringAuthenticatorBase {
id: string // caller-chosen — e.g. 'webauthn-yubikey-blue'
method: 'webauthn' | 'oidc' | 'password'
enrolled_at: string // ISO 8601
enrolled_via_tier: 1 | 2 // tier that enrolled this slot
meta: Record<string, unknown> // method-specific — cred id, issuer/sub, salt, ...
}
// wrap-KEK slots (webauthn, oidc) — wrap the SAME KEK under a method-derived key
type KeyringAuthenticatorWrappingKEK = KeyringAuthenticatorBase & {
wrapKind?: 'kek'
wrapped_kek: string
}
// wrap-DEKs slots (password) — wrap the DEK set directly, sidestepping the non-extractable KEK
type KeyringAuthenticatorWrappingDEKs = KeyringAuthenticatorBase & {
wrapKind: 'deks'
wrapped_deks: string
iv: string
}
type KeyringAuthenticator = KeyringAuthenticatorWrappingKEK | KeyringAuthenticatorWrappingDEKs
Available tier-2 methods
| Method | Package | Factor | Notes |
|---|---|---|---|
webauthn | @noy-db/on-webauthn | device (platform passkey) or possession (roaming key) | Prefers the PRF extension for deterministic key derivation; falls back to a rawId-derived key. requireSingleDevice rejects credentials whose backup-eligible (BE) flag is set. |
oidc | @noy-db/on-oidc | possession (provider session) + knowledge (split-key secret half) | Bitwarden-style split key: half the KEK lives on the device, half comes from the OIDC provider's key connector on successful login. |
password | @noy-db/on-password | knowledge (a second secret, distinct from the tier-1 phrase) | PBKDF2-derived wrap-DEKs slot. A separate credential from the tier-1 phrase — pair with on-totp or on-email-otp for a typical SaaS UX. |
Enroll, remove, update, unlock
enrollAuthenticator / removeAuthenticator / updateAuthenticator all take the vault name, not a keyring — the hub checks the relevant policy gate and fetches the active keyring internally.
// Enroll a new slot. Checks the `enroll-authenticator` gate, then persists.
await db.team.enrollAuthenticator('acme', {
id: 'webauthn-yubikey-blue',
method: 'webauthn',
wrapped_kek: enrollment.wrappedKek, // produced by @noy-db/on-webauthn
meta: { credentialId: enrollment.credentialId },
})
// Remove a slot. Idempotent — removing a missing slot is a no-op. Gated by `remove-authenticator`.
await db.team.removeAuthenticator('acme', 'webauthn-yubikey-blue')
// Rename or re-label a slot's meta (id/method/wrap material are immutable). Gated by `update-authenticator`.
await db.team.updateAuthenticator('acme', 'webauthn-yubikey-blue', { meta: { label: 'Work laptop' } })
// Unlock via an enrolled slot — `verify` receives the stored slot and
// returns the unlocked keyring (typically delegating to the matching on-* package).
const unlocked = await db.team.unlockViaAuthenticator('acme', 'webauthn-yubikey-blue', async (slot) => {
return unlockWebAuthn(slot, /* on-webauthn assertion options */)
})
// → unlocked keyring; subsequent checkGate() calls now see a tier-2 session
Tier 3 — Unlock (quick-resume)
The canonical tier-3 primitive is @noy-db/on-pin. Its own words: "PIN never replaces the secret; only resumes an already-unlocked session." A PIN derives a short-lived wrapping key (PBKDF2, 100,000 iterations) over the DEKs already cached from a tier-1 or tier-2 unlock; it cannot open a cold vault. On idle expiry, the cache is wiped and tier 2 must be re-presented.
import { enrollPin, resumePin } from '@noy-db/on-pin'
// After a full unlock — mint a resumable state, keep it in memory only.
const pinState = await enrollPin(unlockedKeyring, { pin: '1234', ttlMs: 15 * 60 * 1000 })
await db.team.enrollUnlock('acme', pinState)
// Later, on session resume:
const resumed = await db.team.unlockViaPin('acme', (state) => resumePin(state, { pin: '1234' }))
// → resumed is `undefined` if no tier-3 state is registered for the vault;
// resumePin() throws PinExpiredError / PinAttemptsExceededError past the TTL/attempt limit
Tier 3 is never safe on a shared workstation — the cached DEKs live on the device for the whole idle window. The idle timeout (ttlMs) is an application choice: a shared kiosk might use minutes, a single-user tablet longer.
Policy gates
Sensitive operations are gated by a typed VaultPolicy. You supply one at vault creation (or accept the default); the hub persists it once at _meta/policy and every later createNoydb() call for that vault reads the on-disk copy — db.updatePolicy() is the sanctioned way to change it afterward.
interface VaultPolicy {
secret?: SecretPolicy
gates: Partial<Record<GateName, GatePolicy>>
}
interface GatePolicy {
minTier: 1 | 2 | 3 // session tier required
factors?: ReadonlyArray<FactorRequirement> // extra fresh proofs at action time
warn?: WarningRules // soft signals, never block on their own
enabled?: boolean // false disables the action entirely
}
interface FactorRequirement {
anyOf: ReadonlyArray<FactorKind> // 'totp' | 'email-otp' | 'recovery' | 'shamir' |
// 'webauthn-roaming' | 'webauthn-platform' | 'password' | 'pin'
count?: number // distinct factors required — default 1
freshnessMs?: number // default 5 minutes
}
App-defined gates use the app:<name> namespace (e.g. app:approve-large-payment) and run through the same engine; an app:* gate with no configured policy is treated as an unenforced label rather than failing closed.
Built-in gates (session-tier subset)
Selected built-in gates and their defaults under the two shipped presets — the engine also enforces built-in gates for team/withdrawal/custody operations outside the scope of this page:
| Gate | Purpose | PERSONAL_POLICY (default) | STRICT_POLICY |
|---|---|---|---|
rotate-secret | User remembers old, sets new | minTier: 1, any one of totp / email-otp / recovery / webauthn / password / pin | minTier: 1, 2 distinct of totp / email-otp / recovery |
recover-secret | User forgot, supplies a recovery proof | minTier: 1, enabled | same |
enroll-authenticator | Add a tier-2 slot | minTier: 1 | minTier: 1 + totp/email-otp |
remove-authenticator | Remove a tier-2 slot | minTier: 1 | minTier: 1 + totp/email-otp |
rotate-unlock | Change the tier-3 method | minTier: 2 | minTier: 1 |
enroll-user / revoke-user | Grant or revoke a keyring | minTier: 1 | minTier: 1 + totp/email-otp |
export-bundle | Encrypted .noydb bundle export | minTier: 1 | minTier: 1 + totp/email-otp, blocks on a shared device |
export-plaintext | CSV/XLSX/JSON export | minTier: 1 + totp/email-otp | minTier: 1, 2 distinct of totp/email-otp, blocks on a shared device |
Unspecified gates inherit from the preset; overriding one gate never wipes out the rest.
Presets and overrides
import { createNoydb, PERSONAL_POLICY, STRICT_POLICY } from '@noy-db/hub'
// Default — single-user / SMB
const db = await createNoydb({ store, policy: PERSONAL_POLICY })
// Strict — regulated / shared-workstation
const db = await createNoydb({ store, policy: STRICT_POLICY })
// Custom override on top of a preset — unspecified gates keep the preset's values
const db = await createNoydb({
store,
policy: {
...PERSONAL_POLICY,
gates: {
...PERSONAL_POLICY.gates,
'app:approve-large-payment': { minTier: 2, factors: [{ anyOf: ['totp'] }] },
},
},
})
checkGate()
import { PolicyDeniedError } from '@noy-db/hub'
try {
await db.checkGate('acme', 'app:approve-large-payment', {
factors: [{ kind: 'totp', payload: { code: '123456' }, mintedAt: new Date().toISOString() }],
})
await invoices.put('inv-002', payment)
} catch (err) {
if (err instanceof PolicyDeniedError) {
// err.gate, err.required, and err.reason:
// 'insufficient-tier' | 'missing-factor' | 'stale-proof' | 'disabled' | 'shared-device-blocked'
}
}
Per-keyring policy override — reserved
The on-disk keyring format has a policy?: VaultPolicy field reserved for a future per-keyring override (a keyring-level policy that can only strengthen, never weaken, the vault-level floor). Today that field is parsed and round-tripped but not enforced — the vault's _meta/policy document is the sole source of truth. No API exists yet for the merge behavior; adding it later will not require a vault-format migration.
Rotate and recover the secret
Rotate (user remembers the old secret)
await db.team.rotateSecret('acme', {
oldSecret: 'correct horse battery staple printer toaster',
newSecret: 'glasses cabinet bicycle umbrella thunder velvet',
}, {
factors: [{ kind: 'totp', payload: { code: '123456' } }],
})
The hub re-derives the old KEK from oldSecret (this also validates it — a wrong secret throws InvalidKeyError), validates the new secret, generates a fresh salt and KEK, rewraps every DEK, and persists.
Tier-2 slots are dropped by default on rotation — each slot wraps material derived under the old KEK, and the hub has no way to re-derive that without the method-specific ceremony. To preserve a slot across rotation, supply a slotCeremonies entry keyed by slot id; the ceremony re-derives the slot's wrapping under the new keyring:
await db.team.rotateSecret('acme', {
oldSecret, newSecret,
slotCeremonies: {
'webauthn-yubikey-blue': async (ctx) => webAuthnSlotRewrapCeremony(/* ... */)(ctx),
},
})
// → slots omitted from `slotCeremonies` are dropped; re-enroll them via db.enrollAuthenticator
Recover (user forgot the secret)
The vault needs at least one recovery profile enrolled; without one, recover-secret is disabled and recovery is impossible.
| Profile | Mechanism | Status |
|---|---|---|
| paper | Single-use codes; a matching code unwraps the cached DEKs, then rewraps them under a fresh KEK. | Implemented end to end. |
| shamir | K-of-N shares reconstruct the DEK set; requires a NoydbShamir (from @noy-db/on-shamir) at createNoydb time. | Implemented end to end. |
| multi-channel | Reserved for a 2-of-3 email/PIN/paper recovery combination. | No API yet — calling it throws RecoveryProfileNotImplementedError. |
| admin-mediated | Reserved for an admin-issued reset that preserves zero-knowledge. | No API yet — calling it throws RecoveryProfileNotImplementedError. |
await db.team.recoverSecret('acme', {
newSecret: 'glasses cabinet bicycle umbrella thunder velvet',
recoveryProof: { profile: 'paper', payload: { code: 'ABCD-EFGH-IJKL-MNOP-QRST-UVWX-YZ12' } },
})
// → { newCodes: [...] } — by default the remaining paper codes are burned and reissued;
// pass { rotateRemainingCodes: false } to keep the unused codes valid
Enroll recovery at vault creation so recover-secret is never left disabled by accident:
await createNoydb({
store,
recovery: [
{ profile: 'paper', codes: 10 },
// or: { profile: 'shamir', k: 2, n: 3, trustees: ['alice@acme.com', 'bob@acme.com'] },
],
})
If recovery is omitted and the recover-secret gate is not explicitly disabled, createNoydb throws RecoveryNotEnrolledError.
Tier 1 — Echo secret
Available since 0.6.0.
Selected at vault creation with secretMode: 'echo', this mode makes the tier-1 prompt resistant to phishing. The secret is one memorable sentence enrolled as three parts:
prompt (the user types) → echo (the vault reveals) → key (the user types)
"mi chiamo vicio" → "ma da piccolo al santanna mi chiamavano" → "ciccio"
At unlock the user types only the prompt. The vault answers by revealing the echo — the middle of the user's own sentence, which nothing but a keyring holding that secret can produce. The user recognizes it and finishes with the key. This is mutual authentication: the vault proves it holds the secret before the user hands over the rest of theirs. A phishing page that replays a convincing-looking unlock screen has nothing to show at the reveal step, and there is no single-field secret entry it could imitate instead — an echo keyring cannot be opened by any single typed string.
The echo doubles as a memory aid: tier 1 is typed so rarely that forgetting it is the dominant real-world failure, and the revealed middle of your own sentence is a retrieval cue for its ending.
Echo is orthogonal to the other two modes: it is still tier 1, still derives the KEK, and tier-2 slots and tier-3 PINs enroll from an echo session exactly as from a standard one.
Enroll
import { createNoydb } from '@noy-db/hub'
const db = await createNoydb({
store,
user: 'vicio',
secretMode: 'echo',
secret: {
prompt: 'mi chiamo vicio',
echo: 'ma da piccolo al santanna mi chiamavano',
key: 'ciccio',
},
echoMaskHint: 'first-letters', // optional: how the player should mask the echo on screen
validateSecret: true,
})
const vault = await db.openVault('acme') // the keyring — and its echo block — is written here
secret takes a { prompt, echo, key } struct instead of a string, and the two must agree: a string under secretMode: 'echo', or a struct without it, throws ValidationError before any store call. secretMode: 'echo' is mutually exclusive with sealingKey (that is managed mode), and deviceSeal / echoMaskHint / echoSecretPolicy are rejected outside echo mode.
Reopening a vault when the app already holds all three parts needs no ceremony — pass the same struct again:
const db = await createNoydb({ store, user: 'vicio', secretMode: 'echo', secret: parts })
That is the unattended path (tests, servers, a headless job). Interactive apps use the ceremony below, because the point of echo mode is the reveal.
The reveal blob: portable, sealed, or none
The echo has to come from somewhere at unlock time. Each keyring records one reveal policy:
reveal kind | Where the echo lives | Enrolled by |
|---|---|---|
portable | encrypted in the keyring under a key derived from the prompt — travels with the file | the default for live keyrings and for pod recipients |
sealed | sealed by a device-local key store; the keyring holds opaque bytes plus the sealing provider's id | passing deviceSeal at createNoydb (or echoOptions.deviceSeal on rotation) |
none | nowhere — the ceremony asks the user to type the echo, and verifies it | opting a pod recipient into reveal: 'none' |
import { createNoydb, MemoryDeviceSeal } from '@noy-db/hub'
const db = await createNoydb({
store, user: 'vicio', secretMode: 'echo', secret: parts,
deviceSeal: new MemoryDeviceSeal({ id: 'device-1' }),
})
// → the owner keyring enrolls reveal: 'sealed', pinned to provider id 'device-1'
NoydbDeviceSeal is { id, seal(bytes), unseal(bytes) } — the same shape as NoydbSealer, deliberately a distinct name because it seals only the echo reveal blob, never the secret. MemoryDeviceSeal is the only implementation shipped in @noy-db/hub (in-memory, test-only); real ones belong in the app or an in-* binding, over IndexedDB, a file, or an OS keychain — the hub stays portable and owns no storage.
The unlock ceremony
import { beginEchoUnlock, WrongPromptError, WrongEchoError } from '@noy-db/hub'
const ceremony = await beginEchoUnlock(store, 'acme', {
userId: 'vicio',
prompt: typedPrompt, // step 1 — the only thing typed so far
deviceSeal, // optional; needed to open a `sealed` reveal
})
// ceremony.reveal → 'ma da piccolo al santanna mi chiamavano' (null ⇒ degraded, see below)
// ceremony.maskHint → 'first-letters' | undefined
beginEchoUnlock reads the _keyring/{userId} row itself, refuses an expired slot before verifying anything, throws ValidationError if the keyring is not an echo keyring, and throws WrongPromptError if the prompt fails its verifier. Only then does it resolve the echo.
Step 2 is the user's: show ceremony.reveal and let them recognize it. Step 3 completes:
const unlocked = ceremony.reveal !== null
? await ceremony.complete({ key: typedKey }) // recognized
: await ceremony.complete({ echo: typedEcho, key: typedKey }) // degraded: echo typed and verified
const db = await createNoydb({ store, user: 'vicio', getKeyring: async () => unlocked })
complete() is stateless — a wrong key can simply be retried on the same handle. When reveal is null, echo is required (omitting it throws ValidationError) and is checked against the keyring's echo verifier (WrongEchoError on mismatch). When reveal is not null, passing an echo that disagrees with it also throws WrongEchoError rather than unlocking on a mismatched pair.
Degradation matrix
The ceremony never falls back to a weaker shape; it falls back only from "the vault reveals the echo" to "the user types the echo, verified".
| Context | reveal kind | What the user does | What it still buys |
|---|---|---|---|
Enrolled device, deviceSeal wired | sealed | recognizes the revealed echo | Full mutual authentication; someone holding a copy of the keyring file cannot read the echo out of it. |
Live keyring enrolled without deviceSeal, or a pod recipient (default) | portable | recognizes the revealed echo | Full mutual authentication against anyone who does not hold the file. A file-holder who also knows the prompt can decrypt the blob. |
| Sealed keyring opened on a foreign device (no provider, or a different provider id) | sealed | types the echo | The three-step structure and a verified echo; no reveal, so no mutual authentication. |
Pod recipient the owner enrolled as reveal: 'none' | none | types the echo | Same as above. |
| A player that only offers single-field secret entry | any | nothing — it cannot proceed | EchoCeremonyRequiredError. There is no downgrade to a plain phrase. |
The conforming-player contract
Anything that opens an echo keyring — your app, a generic viewer, a future universal player — is a player, and the ceremony is a property of the data it opens, not of the app. The kernel enforces what an API can enforce; the rest is normative and stated here so that "conforming" has a meaning:
- A player MUST NOT present a single-field secret entry for an echo keyring, and MUST NOT offer a "paste your full secret" escape hatch. None can work: the KDF input is a length-prefixed encoding of the three parts that begins with a byte
TextEncodercan never emit, so no string's encoding can equal it, and the key-derivation dispatch additionally refuses a string outright on an echo keyring. - When the user does not recognize the revealed echo, the player MUST treat the ceremony as dead and show a hard warning: stop, do not continue, open the official app, rotate the secret now. It MUST NOT offer a "skip verification" or "verification unavailable" affordance, and MUST abandon the ceremony without calling
complete(). The kernel cannot enforce this terminal state — it is the player's obligation. - Rate limiting is also the player's obligation: the kernel keeps no attempt counter on the prompt. Wrong prompts return
WrongPromptErrorindefinitely; lockout, backoff, and duress handling belong to the app (seeon-threat). - A player SHOULD mask the echo on screen when
ceremony.maskHintis present, and MAY mask it by its own rules when it is absent — the reveal is on-screen material, so it is shoulder-surfable.
Validation — per-part floors
Echo secrets are natural-language sentences, so their floors differ from the standard phrase's:
| Check | Rule | Default |
|---|---|---|
| Each of the three parts | non-empty after trimming | required |
| Prompt | minimum word count | 3 words (DEFAULT_ECHO_PROMPT_MIN_WORDS) |
| Prompt + echo + key, joined by single spaces | minimum word count | 6 words — the standard whole-secret floor |
| Every word, in both checks | minimum length | 1 character (DEFAULT_ECHO_MIN_WORD_LENGTH), relaxed from the standard 3 |
| Every part | character pattern | lowercase letters and single spaces, as in Phrase format |
The relaxed word-length floor is what lets natural sentences validate: mi chiamo vicio is three real words, two of them shorter than the standard 3-character floor. The character pattern is unchanged, so punctuation still has to go — write santanna, not sant.anna.
import { validateEchoSecret, assertStrongEchoSecret } from '@noy-db/hub'
validateEchoSecret({
prompt: 'mi chiamo vicio',
echo: 'ma da piccolo al santanna mi chiamavano',
key: 'ciccio',
})
// → { ok: true, words: 11 }
validateEchoSecret({ prompt: 'ciao', echo: 'come stai oggi amico', key: 'bene grazie' })
// → { ok: false, reason: 'too-few-words', minimum: 3, got: 1 } — the PROMPT floor
The prompt gets its own floor because it is the part an attacker can attack in isolation: a guessed prompt yields the echo, which is exactly what a convincing phish would need. The echo has no dedicated floor — it is free-form and never typed on the normal path — but it is not free of consequence either: it is grindable offline by anyone holding the keyring file, so longer is better. See the threat model for what that means, and the echo-secret enrollment recipe for how to choose one.
Override the floors per vault with echoSecretPolicy: { prompt?: SecretPolicy, combined?: SecretPolicy } — the parts-path counterpart of secretPolicy:
await createNoydb({
store, user: 'vicio', secretMode: 'echo', secret: parts,
validateSecret: true,
echoSecretPolicy: { prompt: { minWords: 4 } },
})
Enforcement follows the same rule as standard mode: opt-in at vault creation (validateSecret: true), on by default for rotateSecret / recoverSecret (bypass with allowWeakSecret: true).
Rotation and migration
The shape of newSecret decides the mode after rotation — a struct enrolls (or re-mints) an echo block, a string removes it. That makes rotateSecret the migration path in both directions:
// standard → echo (the upgrade)
await db.team.rotateSecret('acme', {
oldSecret: 'correct horse battery staple printer toaster',
newSecret: { prompt: 'mi chiamo vicio', echo: 'ma da piccolo al santanna mi chiamavano', key: 'ciccio' },
echoOptions: { deviceSeal, maskHint: 'first-letters' }, // omit deviceSeal ⇒ reveal: 'portable'
})
// echo → echo (re-mint: fresh verifiers, fresh reveal blob)
await db.team.rotateSecret('acme', { oldSecret: oldParts, newSecret: newParts })
// echo → standard (the downgrade) — refused unless you say so
await db.team.rotateSecret('acme', {
oldSecret: oldParts,
newSecret: 'glasses cabinet bicycle umbrella thunder velvet',
allowModeDowngrade: true,
})
Without allowModeDowngrade: true, an echo keyring plus a string newSecret throws ValidationError before anything is written — losing the anti-phishing ceremony should never be the accidental consequence of an argument shape. oldSecret must match the stored mode: a string presented to an echo keyring throws EchoCeremonyRequiredError, a struct presented to a standard one throws ValidationError. Tier-2 slots survive an echo rotation exactly as elsewhere — via slotCeremonies.
Two reset paths convert echo → standard without the flag, by design, because both are already proof-gated ceremonies rather than a routine change:
db.team.recoverSecret— the caller has presented a recovery code or Shamir shares. It accepts either shape; a struct enrolls a fresh echo block that is alwaysportable(there is noechoOptionshere: the device running a recovery is not necessarily the device that will unlock later — seal it afterwards with an explicitrotateSecret).db.team.recoverUser(peer recovery) — an issuer re-wraps someone else's keyring under a temporary string secret, so the target always lands on a standard keyring and its echo block is dropped. Peer recovery is a single-use bridge; the recipient picks the echo shape back up with their ownrotateSecreton first session.
Pod recipients — the ceremony travels with the data
A recipient slot inside a .noydb pod is a keyring, so an echo recipient carries the ceremony to whoever opens the pod — including a pod that has outlived the app that wrote it:
import { writePod } from '@noy-db/hub/pod'
const bytes = await writePod(vault, {
recipients: [
{ id: 'auditor', role: 'viewer', secret: { prompt, echo, key } }, // reveal: 'portable'
{ id: 'notary', role: 'viewer', secret: { prompt, echo, key, reveal: 'none' } }, // verified typed echo
],
})
PodRecipient.secret widens to string | EchoRecipientSecret, where EchoRecipientSecret is { prompt, echo, key, reveal?: 'portable' | 'none' }. 'sealed' is not reachable from writePod — sealing needs a device to seal against, and a pod being written has none.
On the other end, a player that restores the pod's keyrings runs the same ceremony against the restored row:
const ceremony = await beginEchoUnlock(restoredStore, compartment, { userId: 'auditor', prompt })
const unlocked = await ceremony.complete({ key })
Errors
| Error | Code | Thrown when |
|---|---|---|
EchoCeremonyRequiredError | ECHO_CEREMONY_REQUIRED | A single string was presented to an echo keyring — createNoydb({ secret: '…' }), or a string oldSecret on rotateSecret. The message names the ceremony; there is no fallback. |
WrongPromptError | WRONG_PROMPT | The typed prompt failed its verifier, or could not open a portable reveal blob. Nothing is revealed. |
WrongEchoError | WRONG_ECHO | Degraded path: the typed echo failed its verifier — or a typed echo was supplied that disagrees with an already-resolved reveal. |
ValidationError | — | Mode/shape disagreements at createNoydb; beginEchoUnlock on a keyring with no echo block; complete() without an echo on a degraded ceremony; an echo → standard rotation without allowModeDowngrade. |
WeakSecretError | — | A part fails its floor when validation is on; reason and suggestion say which. |
Threat model
Summarized in Threat model — a phishing page imitating the unlock screen, including what echo mode does not defend: the keyring's prompt and echo verifiers are offline brute-force oracles for anyone holding the file, and a sealed reveal blob discloses the echo's byte length. Nothing protects a user who types a secret into genuinely hostile code — echo mode targets remote phishing pages and honest-but-lazy players, not a malicious player the user hands the pod to.
Managed-secret mode
Selected at vault creation, this mode makes the vault resistant to coercion: the hub — not the user — generates the secret, so there is nothing for the user to reveal.
import { createNoydb, MemorySealer } from '@noy-db/hub'
const db = await createNoydb({
store,
secretMode: 'managed',
sealingKey: new MemorySealer({ id: 'dev-test' }), // swap for a real per-platform provider in production
})
// Managed mode requires at least one strong (Shamir) recovery profile —
// paper alone has no user-known secret to fall back on.
const { vault, recoveryEnrollments } = await db.team.openVaultAndEnrollRecovery('acme', {
recovery: [{ profile: 'shamir', k: 2, n: 3 }],
})
// → recoveryEnrollments[0].shares — show once, the hub does not retain them
Mechanics
- The hub generates a random 256-bit secret at first open and seals it under the supplied
NoydbSealer, persisting the sealed form at_meta/sealed-secret. - The user never sees or types this secret; it never enters any UI surface.
rotate-secretis unavailable in managed mode — the secret can only be replaced by recovering it under a fresh sealing key.- Concrete
NoydbSealerimplementations ship as separate packages outside the hub — theat-*family:at-aws-kms,at-azure-keyvault,at-gcp-kms,at-macos-keychainandat-env. The hub itself carries only the in-memory test provider (MemorySealer, shown above).
NoydbSealer interface
interface NoydbSealer {
readonly id: string // non-secret identifier, safe to log
seal(secret: Uint8Array): Promise<Uint8Array> // called once, at enrollment
unseal(sealed: Uint8Array): Promise<Uint8Array> // called on every unlock; must throw on tamper
}
Threat model
Coercing a user to reveal their secret (the "rubber-hose" attack) collapses, because the user has no secret to give up. The attack surface moves to compromising the sealing-key store itself (the OS keyring or KMS), or to compromising enough recovery shares to reach the Shamir threshold — neither of which is coercive against the user. The trade-off is a hard dependency on the sealing-key store: if it is wiped, recovery is the only way back in.
API reference
Secret (tier 1)
| Export | Returns | Purpose |
|---|---|---|
validateSecret(phrase, opts?) | SecretValidationResult | Checks a phrase against the format rules without throwing. opts overrides SecretPolicy fields. |
assertStrongSecret(phrase, opts?) | void (throws) | Same check as validateSecret; throws WeakSecretError on failure. |
db.team.rotateSecret(vault, { oldSecret, newSecret, slotCeremonies? }, factors?) | Promise<void> | Re-derives the old KEK, validates and derives the new one, rewraps every DEK. Gated by rotate-secret. |
db.team.recoverSecret(vault, { newSecret, recoveryProof, rotateRemainingCodes? }, factors?) | Promise<RecoverSecretResult> | Resets the secret from a recovery proof instead of the old one. Gated by recover-secret. |
Types:
SecretPolicy—{ minWords?, minWordLength?, rejectRepeatedAdjacent?, pattern?, customValidator? }— per-vault secret rule overrides; see Phrase format.SecretValidationResult—{ ok: true, words: number } | { ok: false, reason: WeakSecretReason, minimum?, got? }.WeakSecretError— thrown byassertStrongSecretand by every hub ingress point where validation is on and the phrase fails.
Tier 2 — authenticator slots
| Method | Returns | Purpose |
|---|---|---|
db.team.enrollAuthenticator(vault, options, factors?) | Promise<void> | Add a slot. Gated by enroll-authenticator. |
db.team.removeAuthenticator(vault, slotId, factors?) | Promise<void> | Remove a slot; idempotent. Gated by remove-authenticator. |
db.team.updateAuthenticator(vault, slotId, options, factors?) | Promise<void> | Rename/relabel a slot's meta; id/method/wrap material are immutable. Gated by update-authenticator. |
db.team.unlockViaAuthenticator(vault, slotId, verify) | Promise<UnlockedKeyring> | Unlock using an enrolled slot; verify typically delegates to the matching on-* package. |
Types:
KeyringAuthenticator— a discriminated union of wrap-KEK slots (webauthn,oidc) and wrap-DEKs slots (password); see Tier 2 — Authenticate for the full shape.
Tier 3 — quick unlock
| Export | Returns | Purpose |
|---|---|---|
db.team.enrollUnlock(vault, state, factors?) | Promise<void> | Register a tier-3 quick-unlock state produced by enrollPin (or another on-* tier-3 primitive). |
db.team.unlockViaPin(vault, resume) | Promise<UnlockedKeyring | undefined> | Resume a session via the registered tier-3 state; undefined if none is registered. |
enrollPin(unlockedKeyring, { pin, ttlMs }) (@noy-db/on-pin) | Promise<QuickUnlockState> | Mint a resumable tier-3 state from an already-unlocked keyring. |
resumePin(state, { pin }) (@noy-db/on-pin) | Promise<UnlockedKeyring> | Verify the PIN and return the cached keyring; throws PinExpiredError / PinAttemptsExceededError. |
Policy gates (methods and types)
| Method | Returns | Purpose |
|---|---|---|
db.checkGate(vault, gate, factors?) | Promise<void> (throws) | Evaluate a gate against the active session tier and presented factors; throws PolicyDeniedError on denial. |
db.updatePolicy(vault, override) | Promise<VaultPolicy> | Replace the policy document at _meta/policy; unspecified gates keep their previous values. Gated by enroll-user. |
Types:
VaultPolicy—{ secret?: SecretPolicy, gates: Partial<Record<GateName, GatePolicy>> }.GatePolicy—{ minTier: 1 | 2 | 3, factors?, warn?, enabled? }.FactorRequirement—{ anyOf: FactorKind[], count?, freshnessMs? }.PolicyDeniedError— thrown bycheckGate; carries.gate,.required, and.reason.PERSONAL_POLICY/STRICT_POLICY— the two shipped policy presets (@noy-db/hub).
Echo-secret mode (tier 1)
| Export | Returns | Purpose |
|---|---|---|
beginEchoUnlock(store, vault, { userId, prompt, deviceSeal? }) | Promise<EchoCeremony> | Start the interactive ceremony: read the keyring, refuse an expired slot, verify the prompt, resolve the echo. Throws NoAccessError / KeyringExpiredError / ValidationError / WrongPromptError. |
ceremony.reveal | string | null | The echo to display for recognition; null means the degraded typed-echo path. |
ceremony.maskHint | string | undefined | The keyring's display-masking hint, if the owner set one. |
ceremony.complete({ echo?, key }) | Promise<UnlockedKeyring> | Derive the KEK from all three parts and unlock. echo is required when reveal is null. Stateless — retryable. |
validateEchoSecret(parts, opts?) | SecretValidationResult | Check a { prompt, echo, key } struct against the per-part floors without throwing. |
assertStrongEchoSecret(parts, opts?) | void (throws) | Same check; throws WeakSecretError. opts.allowWeakSecret skips it. |
MemoryDeviceSeal | class | The only NoydbDeviceSeal shipped in @noy-db/hub (in-memory, test-only). |
Options on createNoydb (echo mode only — each throws ValidationError outside it):
secretMode: 'echo'— selects the mode;secretmust then be anEchoSecretPartsstruct.deviceSeal?: NoydbDeviceSeal— present ⇒ the keyring enrollsreveal: 'sealed'; absent ⇒portable.echoMaskHint?: string— stored on the keyring, surfaced later asceremony.maskHint.echoSecretPolicy?: EchoSecretPolicy— per-vault prompt / combined floors, applied whenvalidateSecretis on.
Options on rotation and recovery:
RotateSecretInput.newSecret: string | EchoSecretParts— the shape selects the mode after rotation.RotateSecretInput.allowModeDowngrade?: boolean— required to rotate an echo keyring to a string secret.RotateSecretInput.echoOptions?: { deviceSeal?, maskHint? }— enrollment knobs for the block minted whennewSecretis 3-part.RotateSecretInput.echoSecretPolicy?/RecoverSecretInput.echoSecretPolicy?— the parts-path counterpart ofsecretPolicy.
Types:
EchoSecretParts—{ prompt: string, echo: string, key: string }; the secret shape for echo mode.EchoCeremony—{ reveal, maskHint, complete() }; see The unlock ceremony.EchoSecretPolicy—{ prompt?: SecretPolicy, combined?: SecretPolicy }.EchoRecipientSecret—EchoSecretParts & { reveal?: 'portable' | 'none' }; a pod recipient's echo secret.KeyringEchoBlock— the on-keyring declaration (verifiers, reveal policy, optionalmask_hint).NoydbDeviceSeal—{ id, seal(bytes), unseal(bytes) }; seals only the echo reveal blob.EchoCeremonyRequiredError/WrongPromptError/WrongEchoError— see Errors.
Managed-secret mode (methods and types)
| Export | Returns | Purpose |
|---|---|---|
db.team.openVaultAndEnrollRecovery(vault, { recovery, locale? }) | Promise<{ vault, recoveryEnrollments }> | Atomic create-and-enroll for managed-mode vaults — required because recover-secret cannot be left unenrolled. |
MemorySealer | class | The only NoydbSealer implementation shipped in @noy-db/hub today (in-memory, test-only). |
Types:
NoydbSealer—{ id, seal(secret), unseal(sealed) }; seeNoydbSealerinterface.
Threat model summary
| Attack | Standard mode | Echo mode | Managed mode |
|---|---|---|---|
| Brute-force the secret | PBKDF2 600k iterations + phrase format (≥77 bits) | Same 600k, over all three parts jointly | Random 256 bits — infeasible |
| Phishing page imitating the unlock screen | Nothing structural — one typed field looks like any other | Defeated: the page cannot produce the echo, and no single-field entry exists for it to imitate | Nothing to phish — the user types no secret |
| Stolen copy of the keyring file | Ciphertext plus the PBKDF2 floor | Same, plus offline guessing oracles on the prompt and the echo; a sealed reveal keeps the echo unreadable, a portable one does not | Same, plus a sealed secret only the sealing-key store can open |
| Stolen device with a platform passkey (tier 2) | Tier-1 changes require an off-device 2FA factor | Same | Same; secret rotation is disabled entirely |
| Stolen device with a cached tier-3 PIN | Idle timeout wipes the cache | Same | Same |
| Coerced user | on-threat duress-secret wipe (plausible deniability) | Same | User has no secret to reveal |
| Lost passkey only | Unlock via another tier-2 slot, or recovery | Same | Same |
| Forgotten secret | Paper or Shamir recovery | Same, and the revealed echo is itself a cue for the key | Same, and recovery is mandatory |
| Compromised admin | Cannot read user data (zero-knowledge); cannot rotate a user's secret | Same | Same |
| Forged keyring file | Authenticated AES-GCM rejects modified envelopes | Same | Same |
Limits and gotchas
- Tier-2 slots don't survive rotation unless you supply a
slotCeremoniesceremony per slot — see Rotate and recover the secret. - Recovery profiles
multi-channelandadmin-mediatedhave no implementation yet; onlypaperandshamirwork. - Per-keyring policy overrides are stored but not enforced — the vault-level policy is always the effective one.
- Secret validation on first creation and on
grant()is opt-in (validateSecret: true); onrotateSecret/recoverSecretit is on by default. - Concrete
NoydbSealers for real platforms are not published yet — only the in-memory test provider ships in@noy-db/hub. - Concrete
NoydbDeviceSeals are equally unshipped — without one, echo keyrings enroll aportablereveal, which a file-holder who knows the prompt can decrypt. - An echo keyring cannot be opened by any single string — including a
#-joined form. Tooling that assumes a one-field secret getsEchoCeremonyRequiredError; there is no compatibility path, by design. - The anti-phishing terminal state is the player's job. The kernel exposes the reveal and keeps no attempt counter; showing the warning, refusing a skip affordance, and rate-limiting wrong prompts are all app-side obligations.
- Anti-autobiography guidance is documentation, never a validator — the hub cannot tell a private memory from a fact on someone's public profile. See the echo-secret enrollment recipe.
See also
onfamily — the full catalog of unlock/auth packages (on-webauthn,on-oidc,on-password,on-pin,on-totp,on-email-otp,on-recovery,on-shamir,on-threat).cover— the plaintext, unauthenticated vault metadata surface (separate from anything covered by session tiers).- Composing an echo secret — how to choose the three parts, and what makes an echo unguessable.
- Threat model — the attacker positions behind every claim on this page, including the echo ceremony's honest limits.
- Unlock ceremonies — the full tier-1/2/3 lifecycle end to end.