/** * Tier-1 change flows — `rotatePassphrase` (user remembers old) and * `recoverPassphrase` (user supplies a recovery proof). * * The two flows share the post-verification half — fresh salt, fresh * KEK, rewrap every DEK — and differ only in how they re-derive the * old KEK: * * - **Rotate**: derive from the supplied `oldPassphrase`. * - **Recover (paper)**: unwrap from a `RecoveryCodeEntry` using a * user-supplied recovery code. The entry is burned on success. * * The non-paper recovery profiles (Shamir, multi-channel, * admin-mediated) are not yet wired — calling them throws * {@link RecoveryProfileNotImplementedError} with a tracking link. * * @module */ import type { NoydbStore } from '../../kernel/types.js'; import { type EnclaveKey } from '../../kernel/enclave/index.js'; import { type PaperRecoveryEntry } from './recovery.js'; import type { ShamirRecoveryProvider } from './shamir-recovery-provider.js'; import { type PassphrasePolicy } from '../../kernel/validation.js'; import type { UnlockedKeyring } from './keyring.js'; import type { KeyringAuthenticator } from '../../kernel/types.js'; import type { EnrollAuthenticatorOptions } from './authenticators.js'; /** * Context handed to a {@link SlotRewrapCeremony} when `rotatePassphrase` * preserves a tier-2 slot. The ceremony's job is to re-derive its * method-specific wrapping material (PRF assertion, PBKDF2 of the * password, etc.) and wrap the freshly rewrapped DEK set under * the new wrapping key. * * Two surfaces are exposed: * * - `newDeks` — the rewrapped (extractable) DEK set the slot will * wrap. This is what `mintPaperRecoveryEntry` / `enrollPassword- * Authenticator` / `wrapKeyringSummary` (in `@noy-db/on-webauthn`) * all consume; effectively the canonical input for every * post-Path C tier-2 ceremony. * * - `newKek` — the freshly-derived KEK (extractable for the * ceremony scope only). Only relevant for forward-compatibility * with a hypothetical future on-* package that wants to wrap the * KEK itself under a method-derived key. None of the shipped * on-* packages need this; they all operate on `newDeks`. * * The ceremony MUST preserve `oldSlot.id` and `oldSlot.method` in the * returned `EnrollAuthenticatorOptions`. Hub validates these — a * mismatch throws `ValidationError` (prevents slot-type swap mid- * rotation, e.g. converting a webauthn slot to a password slot under * cover of preservation). */ export interface SlotRewrapContext { readonly newKek: EnclaveKey; readonly newDeks: Map; readonly oldSlot: KeyringAuthenticator; } /** * Callback that re-enrolls one tier-2 slot during `rotatePassphrase`. * Returns the new slot's `EnrollAuthenticatorOptions` — same shape * the consumer would pass to `db.enrollAuthenticator` for a fresh * enrollment. Hub persists the result atomically with the rotation. */ export type SlotRewrapCeremony = (ctx: SlotRewrapContext) => Promise; /** Caller payload for {@link rotatePassphrase}. */ export interface RotatePassphraseInput { readonly oldPassphrase: string; readonly newPassphrase: string; readonly passphrasePolicy?: PassphrasePolicy; readonly allowWeakPassphrase?: boolean; /** * Map of slot id → re-enrolment ceremony. Slots whose id appears * here are PRESERVED across rotation (the ceremony re-derives the * method-specific wrapping under the new keyring); slots whose id * is absent are DROPPED (the pre-slot-ceremony behavior). * * Without this map, `rotatePassphrase` wipes every tier-2 slot. Consumers building a * "rotate without losing my biometric" flow supply ceremonies for * each slot they want to keep. * * If a ceremony throws, the entire rotation throws — no partial * state. Callers wrap individual ceremonies in try/catch + return * a sentinel if they want graceful degradation per slot. * * Added when slot-ceremony rewrapping landed. */ readonly slotCeremonies?: { readonly [slotId: string]: SlotRewrapCeremony; }; } /** * Re-derive the user's KEK from `oldPassphrase`, rewrap every DEK * under a freshly-derived KEK from `newPassphrase`, and persist. * * Tier-2 authenticator slots are dropped UNLESS the caller supplies * a `slotCeremonies` map — each ceremony re-derives its * method-specific wrapping under the new keyring, and hub persists * the rewrapped slots atomically with the rotation. Slots whose id * isn't in the map are still dropped. * * @throws `InvalidKeyError` if `oldPassphrase` does not unwrap the keyring. * @throws `WeakPassphraseError` if `newPassphrase` fails the strength rule. * @throws `ValidationError` if a ceremony's result mismatches the * slot's id or method (anti-slot-swap guard). */ export declare function rotatePassphrase(store: NoydbStore, vault: string, userId: string, input: RotatePassphraseInput): Promise; /** * Caller payload for {@link recoverPassphrase}. * * `paper` and `shamir` are wired end-to-end. * The remaining two profiles (`multi-channel`, `admin-mediated`) * stay outside the union and throw * {@link RecoveryProfileNotImplementedError} at the runtime guard * when bypassed via `as unknown as RecoveryProof`. */ export type RecoveryProof = { readonly profile: 'paper'; readonly payload: { readonly code: string; }; } | { readonly profile: 'shamir'; readonly payload: { /** Optional disambiguator when multiple Shamir entries are enrolled. * When omitted, hub tries each entry until one combines. */ readonly entryId?: string; /** K or more opaque share strings, as returned by `ShamirRecoveryProvider.splitToShares`. */ readonly shares: ReadonlyArray; }; }; export interface RecoverPassphraseInput { readonly newPassphrase: string; readonly recoveryProof: RecoveryProof; readonly passphrasePolicy?: PassphrasePolicy; readonly allowWeakPassphrase?: boolean; /** * After a successful paper-recovery, replace ALL remaining recovery * entries with freshly-minted ones. Defaults to `true` (defensive). * * Rationale: the user just demonstrated they had access * to AT LEAST one code. The remaining codes from the same printed * sheet may also be compromised — photographed, leaked via a * screen-share slip, or in the hands of whoever stole the sheet. * Auto-rotation closes the window without requiring consumer action. * * Set to `false` to preserve the original behavior (only the matched * code is burned; the rest stay valid). * * Hub-side orchestration is non-atomic with the recovery itself: * if the rotation step fails after a successful burn, the user * falls back to the pre-rotation state (remaining codes still * valid). Strictly safer than the previous default — a failed * rotation degrades gracefully rather than leaving the vault * locked or codes dual-existing. */ readonly rotateRemainingCodes?: boolean; /** * Number of fresh codes to mint when `rotateRemainingCodes` is on. * Defaults to the count of remaining entries POST-burn (e.g. if * the user enrolled 8 originally and just consumed 1, defaults to * 7). Pass an explicit number to mint a different count — useful * when the consumer wants to refresh to a target N regardless of * how many were left. */ readonly newCodeCount?: number; /** * Override the default raw-code generator. The default is hub's * {@link generateULID} — uppercase Crockford-Base32, 26 chars, * passes through `normalizePaperCode` untouched. * * Pass `() => generateRawCode()` from `@noy-db/on-recovery` when * the consumer prefers the Base32 + checksum format with hyphenated * display. The `mintPaperRecoveryEntry` helper accepts any string — * the generator just needs to produce a high-entropy unique value. */ readonly codeGenerator?: () => string; } /** * Return shape of `db.recoverPassphrase`. `newCodes` is populated when * `rotateRemainingCodes` was enabled and at least one entry was * rotated; an empty array means no rotation happened (rotation * disabled, or no remaining codes after burn). Show the codes to the * user once — they are the canonical credential for future recovery * and CANNOT be retrieved again. */ export interface RecoverPassphraseResult { readonly newCodes: readonly string[]; } /** * Input for {@link Noydb.rotateRecovery} — deliberate * recovery-credential regeneration when the user knows their * passphrase but wants a fresh sheet (paper) or fresh shares * (shamir). Symmetric to {@link RotatePassphraseInput}. */ export type RotateRecoveryOptions = { readonly profile: 'paper'; /** How many fresh codes to mint. Default: existing sheet size. */ readonly count?: number; /** Optional code generator — see {@link RecoverPassphraseInput.codeGenerator}. */ readonly codeGenerator?: () => string; } | { readonly profile: 'shamir'; /** New threshold. */ readonly k: number; /** New total share count. */ readonly n: number; /** Disambiguator when multiple Shamir entries exist; required if there are 2+. */ readonly entryId?: string; /** Optional updated label. */ readonly label?: string; }; /** * Result of {@link Noydb.rotateRecovery}. Shape varies by profile: * * - `paper` → `{ newCodes: string[] }` (and `entryId === 'paper-batch'`) * - `shamir` → `{ newShares: string[], entryId }` * * `newCodes` is populated for paper rotations; `newShares` for * Shamir rotations. Both are show-once — the hub does not * retain them. */ export interface RotateRecoveryResult { readonly newCodes?: readonly string[]; readonly newShares?: readonly string[]; readonly entryId?: string; } /** * Result of {@link Noydb.enrollRecovery}. Shape varies by profile: * * - `paper` → `{ entryId: 'paper-batch' }` (caller minted the * entries; this is a sentinel since paper enrollments are batch-shaped). * - `shamir` → `{ entryId, shares: string[] }` — shares are * show-once; the hub does not retain them. */ export interface EnrollRecoveryResult { readonly entryId: string; readonly shares?: readonly string[]; } /** * Input shape for {@link Noydb.enrollRecovery} and * {@link Noydb.openVaultAndEnrollRecovery}. Discriminated * union over recovery profiles. * * - `paper`: caller pre-mints entries (typically via * `mintPaperRecoveryEntry` or `@noy-db/on-recovery`'s * `generateRecoveryCodeSet`) and passes them in. The hub stores * them and surfaces an opaque batch id. * - `shamir`: hub mints the recovery secret + the shares at * enrollment time. The shares are returned in * {@link EnrollRecoveryResult.shares} (show-once); the hub never * retains them. * * Multi-channel and admin-mediated will be added when the respective * dispatch slices ship. */ export type RecoveryEnrollmentInput = { readonly profile: 'paper'; readonly entries: ReadonlyArray; } | { readonly profile: 'shamir'; readonly k: number; readonly n: number; readonly label?: string; readonly entryId?: string; }; /** * Reset the user's passphrase using a recovery proof. * Supports `'paper'` and `'shamir'` profiles. The other profiles throw * {@link RecoveryProfileNotImplementedError}. * * On success, the used recovery entry is burned (deleted from the * stored set). */ export declare function recoverPassphrase(provider: ShamirRecoveryProvider | undefined, store: NoydbStore, vault: string, userId: string, input: RecoverPassphraseInput): Promise;