stable

session-tiers

The three-tier session-authentication ladder — secret, multi-slot, quick-resume — and the policy-gate DSL guarding sensitive vault operations.

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
Factorynone — always on for every createNoydb() call
Clustercollaboration-and-auth
Cross-cutsevery @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

TierNameRoleTypical lifetimeWhere the factor lives
1Secret (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).
2AuthenticateRoutine 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.
3UnlockQuick-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 roaming on-webauthn supply 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-threat layers 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.

ModesecretModeWhat the user suppliesPick it when
standard (default)'standard'one phrase, typed into one fieldThe 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 secretYou 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.

RuleDefaultStrict policy
Character pattern/^[a-z]+( [a-z]+)*$/ (lowercase letters and single spaces only)same
No punctuation, symbols, digitsrequiredrequired
No leading/trailing or doubled whitespacerequiredrequired
Minimum word count6 (~77 bits)8 (~103 bits)
Minimum word length3 characters3 characters
Repeated adjacent wordsrejectedrejected
✅ "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) and grant() validate the phrase only when the caller opts in — pass validateSecret: true to createNoydb, or { validateSecret: true } in GrantOptions.
  • db.rotateSecret and db.recoverSecret validate 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

MethodPackageFactorNotes
webauthn@noy-db/on-webauthndevice (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-oidcpossession (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-passwordknowledge (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:

GatePurposePERSONAL_POLICY (default)STRICT_POLICY
rotate-secretUser remembers old, sets newminTier: 1, any one of totp / email-otp / recovery / webauthn / password / pinminTier: 1, 2 distinct of totp / email-otp / recovery
recover-secretUser forgot, supplies a recovery proofminTier: 1, enabledsame
enroll-authenticatorAdd a tier-2 slotminTier: 1minTier: 1 + totp/email-otp
remove-authenticatorRemove a tier-2 slotminTier: 1minTier: 1 + totp/email-otp
rotate-unlockChange the tier-3 methodminTier: 2minTier: 1
enroll-user / revoke-userGrant or revoke a keyringminTier: 1minTier: 1 + totp/email-otp
export-bundleEncrypted .noydb bundle exportminTier: 1minTier: 1 + totp/email-otp, blocks on a shared device
export-plaintextCSV/XLSX/JSON exportminTier: 1 + totp/email-otpminTier: 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.

ProfileMechanismStatus
paperSingle-use codes; a matching code unwraps the cached DEKs, then rewraps them under a fresh KEK.Implemented end to end.
shamirK-of-N shares reconstruct the DEK set; requires a NoydbShamir (from @noy-db/on-shamir) at createNoydb time.Implemented end to end.
multi-channelReserved for a 2-of-3 email/PIN/paper recovery combination.No API yet — calling it throws RecoveryProfileNotImplementedError.
admin-mediatedReserved 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 kindWhere the echo livesEnrolled by
portableencrypted in the keyring under a key derived from the prompt — travels with the filethe default for live keyrings and for pod recipients
sealedsealed by a device-local key store; the keyring holds opaque bytes plus the sealing provider's idpassing deviceSeal at createNoydb (or echoOptions.deviceSeal on rotation)
nonenowhere — the ceremony asks the user to type the echo, and verifies itopting 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".

Contextreveal kindWhat the user doesWhat it still buys
Enrolled device, deviceSeal wiredsealedrecognizes the revealed echoFull 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)portablerecognizes the revealed echoFull 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)sealedtypes the echoThe three-step structure and a verified echo; no reveal, so no mutual authentication.
Pod recipient the owner enrolled as reveal: 'none'nonetypes the echoSame as above.
A player that only offers single-field secret entryanynothing — it cannot proceedEchoCeremonyRequiredError. 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 TextEncoder can 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 WrongPromptError indefinitely; lockout, backoff, and duress handling belong to the app (see on-threat).
  • A player SHOULD mask the echo on screen when ceremony.maskHint is 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:

CheckRuleDefault
Each of the three partsnon-empty after trimmingrequired
Promptminimum word count3 words (DEFAULT_ECHO_PROMPT_MIN_WORDS)
Prompt + echo + key, joined by single spacesminimum word count6 words — the standard whole-secret floor
Every word, in both checksminimum length1 character (DEFAULT_ECHO_MIN_WORD_LENGTH), relaxed from the standard 3
Every partcharacter patternlowercase 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 always portable (there is no echoOptions here: the device running a recovery is not necessarily the device that will unlock later — seal it afterwards with an explicit rotateSecret).
  • 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 own rotateSecret on 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

ErrorCodeThrown when
EchoCeremonyRequiredErrorECHO_CEREMONY_REQUIREDA 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.
WrongPromptErrorWRONG_PROMPTThe typed prompt failed its verifier, or could not open a portable reveal blob. Nothing is revealed.
WrongEchoErrorWRONG_ECHODegraded 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-secret is unavailable in managed mode — the secret can only be replaced by recovering it under a fresh sealing key.
  • Concrete NoydbSealer implementations ship as separate packages outside the hub — the at-* family: at-aws-kms, at-azure-keyvault, at-gcp-kms, at-macos-keychain and at-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)

ExportReturnsPurpose
validateSecret(phrase, opts?)SecretValidationResultChecks 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 by assertStrongSecret and by every hub ingress point where validation is on and the phrase fails.

Tier 2 — authenticator slots

MethodReturnsPurpose
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

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

MethodReturnsPurpose
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 by checkGate; carries .gate, .required, and .reason.
  • PERSONAL_POLICY / STRICT_POLICY — the two shipped policy presets (@noy-db/hub).

Echo-secret mode (tier 1)

ExportReturnsPurpose
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.revealstring | nullThe echo to display for recognition; null means the degraded typed-echo path.
ceremony.maskHintstring | undefinedThe 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?)SecretValidationResultCheck 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.
MemoryDeviceSealclassThe 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; secret must then be an EchoSecretParts struct.
  • deviceSeal?: NoydbDeviceSeal — present ⇒ the keyring enrolls reveal: 'sealed'; absent ⇒ portable.
  • echoMaskHint?: string — stored on the keyring, surfaced later as ceremony.maskHint.
  • echoSecretPolicy?: EchoSecretPolicy — per-vault prompt / combined floors, applied when validateSecret is 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 when newSecret is 3-part.
  • RotateSecretInput.echoSecretPolicy? / RecoverSecretInput.echoSecretPolicy? — the parts-path counterpart of secretPolicy.

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, optional mask_hint).
  • NoydbDeviceSeal — { id, seal(bytes), unseal(bytes) }; seals only the echo reveal blob.
  • EchoCeremonyRequiredError / WrongPromptError / WrongEchoError — see Errors.

Managed-secret mode (methods and types)

ExportReturnsPurpose
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.
MemorySealerclassThe only NoydbSealer implementation shipped in @noy-db/hub today (in-memory, test-only).

Types:

Threat model summary

AttackStandard modeEcho modeManaged mode
Brute-force the secretPBKDF2 600k iterations + phrase format (≥77 bits)Same 600k, over all three parts jointlyRandom 256 bits — infeasible
Phishing page imitating the unlock screenNothing structural — one typed field looks like any otherDefeated: the page cannot produce the echo, and no single-field entry exists for it to imitateNothing to phish — the user types no secret
Stolen copy of the keyring fileCiphertext plus the PBKDF2 floorSame, plus offline guessing oracles on the prompt and the echo; a sealed reveal keeps the echo unreadable, a portable one does notSame, 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 factorSameSame; secret rotation is disabled entirely
Stolen device with a cached tier-3 PINIdle timeout wipes the cacheSameSame
Coerced useron-threat duress-secret wipe (plausible deniability)SameUser has no secret to reveal
Lost passkey onlyUnlock via another tier-2 slot, or recoverySameSame
Forgotten secretPaper or Shamir recoverySame, and the revealed echo is itself a cue for the keySame, and recovery is mandatory
Compromised adminCannot read user data (zero-knowledge); cannot rotate a user's secretSameSame
Forged keyring fileAuthenticated AES-GCM rejects modified envelopesSameSame

Limits and gotchas

  • Tier-2 slots don't survive rotation unless you supply a slotCeremonies ceremony per slot — see Rotate and recover the secret.
  • Recovery profiles multi-channel and admin-mediated have no implementation yet; only paper and shamir work.
  • 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); on rotateSecret/recoverSecret it 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 a portable reveal, 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 gets EchoCeremonyRequiredError; 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

  • on family — 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.