/** * Noydb-side auth / recovery / enrollment facade. * * Holds the tier-2 authenticator enrollment/unlock wrappers, WebAuthn * enrollment, auth-config introspection, the tier-1 passphrase * rotate/recover flows, the paper/Shamir recovery rotate/enroll flows, * managed-passphrase recovery, peer-recovery, tier-3 PIN unlock, and the * public `getKeyring` accessor. * * The near-parallel rotate/recover variants (managed vs user vs paper vs * Shamir) are deliberately NOT consolidated. The * keyring/active-tier/quick-unlock/policy caches stay `Noydb`-resident * (shared by the kernel's unlock path) and arrive **by reference** through * {@link TeamFacadeDeps}; the keyring-unlock path (`getKeyringInternal`), * the policy gate (`checkGate`), the managed-recovery enrolment check * (`assertRecoveryEnrolled`), `openVault`, and the one-shot * managed-recovery skip flag stay kernel-resident and arrive as callbacks. * * Internal service — reached through `noydb.rotatePassphrase(...)` etc. */ import type { NoydbOptions, NoydbStore, KeyringAuthenticator } from '../../kernel/types.js'; import { type RotatePassphraseInput, type RecoverPassphraseInput, type RecoverPassphraseResult, type RotateRecoveryOptions, type RotateRecoveryResult, type EnrollRecoveryResult, type RecoveryEnrollmentInput, type RecoveryProof } from './rotate-recover.js'; import { type RecoverUserOptions } from './peer-recover.js'; import { type PaperRecoveryEntry, type ShamirRecoveryEntry } from './recovery.js'; import type { Vault } from '../../kernel/vault.js'; import type { UnlockedKeyring } from './keyring.js'; import { type EnrollAuthenticatorOptions, type UpdateAuthenticatorOptions } from './authenticators.js'; import type { QuickUnlockStore, QuickUnlockState } from '../session/unlock-state.js'; import type { PassphrasePolicy } from '../../kernel/validation.js'; import type { ActiveTier, FactorProofBundle, GateName, GrantOptions, ReAuthOperation, RevokeOptions, VaultPolicy } from '../../kernel/types.js'; /** NoydbOptions with the store resolved to a non-optional value (internal use only). */ type ResolvedNoydbOptions = NoydbOptions & { readonly store: NoydbStore; }; /** Everything the moving auth/recovery/enrollment methods touched on the Noydb instance's `this.*`. */ export interface TeamFacadeDeps { /** Resolved Noydb options (store, user, passphraseMode, sealingKey, shamirRecovery, …). */ readonly options: ResolvedNoydbOptions; /** Live unlocked-keyring cache (Noydb-resident; read/written by reference). */ readonly keyringCache: Map; /** Per-vault active session tier (Noydb-resident; read/written by reference). */ readonly activeTier: Map; /** Per-vault tier-3 quick-unlock state (Noydb-resident; read/written by reference). */ readonly quickUnlock: QuickUnlockStore; /** Per-vault loaded policy cache (Noydb-resident; read/written by reference). */ readonly policyCache: Map; /** Evaluate the policy gate for an operation (kernel-resident). */ checkGate(vault: string, gate: GateName, factors?: FactorProofBundle): Promise; /** Legacy `requireReAuthFor` session-policy check (kernel-resident). */ checkPolicyOperation(vault: string, op: ReAuthOperation): void; /** Live-reference keyring unlock path (kernel-resident). */ getKeyringInternal(vault: string, opts?: { create: boolean; }): Promise; /** Managed-recovery enrolment check (kernel-resident). */ assertRecoveryEnrolled(vault: string, policy: VaultPolicy, opts?: { skipManagedCheck?: boolean; }): Promise; /** Open (or create) a vault (kernel-resident). */ openVault(vault: string, opts?: { locale?: string; }): Promise; /** Toggle the one-shot managed-mode strong-recovery bypass flag (kernel-resident). */ setSkipNextManagedRecoveryCheck(value: boolean): void; } export declare class TeamFacade { private readonly deps; constructor(deps: TeamFacadeDeps); private requireShamirProvider; /** Gate + run a `grant` engine. See `Noydb.grant` for the public contract. */ runGrant(engine: (adapter: NoydbStore, vault: string, callerKeyring: UnlockedKeyring, options: GrantOptions) => Promise, vault: string, options: GrantOptions, factors?: FactorProofBundle): Promise; /** Gate + run a `revoke` engine. See `Noydb.revoke` for the public contract. */ runRevoke(engine: (adapter: NoydbStore, vault: string, callerKeyring: UnlockedKeyring, options: RevokeOptions) => Promise, vault: string, options: RevokeOptions, factors?: FactorProofBundle): Promise; /** Gate + run a `rotateKeys` engine. See `Noydb.rotate` for the public contract. */ runRotate(engine: (adapter: NoydbStore, vault: string, callerKeyring: UnlockedKeyring, collections: string[]) => Promise, vault: string, collections: string[]): Promise; /** * Add a tier-2 authenticator slot to the calling user's keyring. * Each slot independently wraps the SAME KEK under a method-specific * key — adding a slot is a constant-time keyring write. * * The wrapping ciphertext is produced by the corresponding * `@noy-db/on-*` package (e.g. `enrollPasswordAuthenticator` from * `@noy-db/on-password`); the hub persists the result. * * Gated by `enroll-authenticator`; `presented` carries any factor * proofs the active policy demands. */ enrollAuthenticator(vault: string, options: EnrollAuthenticatorOptions, factors?: FactorProofBundle): Promise; /** * Remove a tier-2 authenticator slot. Idempotent — removing a * non-existent slot is a successful no-op. Gated by * `remove-authenticator`. */ removeAuthenticator(vault: string, slotId: string, factors?: FactorProofBundle): Promise; /** Read the slot list for a vault. Internal — `describeAuthConfig` consumes this. */ listAuthenticators(vault: string): Promise>; /** * Mutate the `meta` blob on an existing authenticator slot — slot * rename, label change, attachment of UI hints. The slot's `id`, * `method`, and wrap material (`wrapped_kek` / `wrapped_deks` + `iv`) * are immutable through this method. Anti-slot-swap is structural, * not gate-driven. * * `meta` patch semantics (top-level merge): * - Top-level merge — absent keys preserved * - `null` value — delete that meta key * - Other values — replace verbatim * * Use case: per-slot nickname for "iPhone Touch ID" vs "MacBook * Touch ID" disambiguation in admin UIs. The slot id (auto-derived * from credentialId prefix) is not human-friendly; `meta.nickname` * is. * * Gated by `update-authenticator`. PERSONAL_POLICY: tier-1 unlock * alone (matches enroll/remove). STRICT_POLICY: tier-1 + * TOTP/email-OTP factor proof — a malicious rename on a shared * workstation could mislead the user about which device a slot * corresponds to, so STRICT requires fresh factor binding. * * @throws `NoAccessError` when no slot with the given id exists. * @throws `ValidationError` when no patch field is provided. */ updateAuthenticator(vault: string, slotId: string, options: UpdateAuthenticatorOptions, factors?: FactorProofBundle): Promise; /** * Native WebAuthn enrollment using the **real** internal keyring. * * Why this exists: when a consumer is using `createNoydb({ secret })`, * they cannot reach the live `UnlockedKeyring` to feed it to * `enrollWebAuthn(keyring, vault, opts)` from `@noy-db/on-webauthn`. * Constructing a synthetic keyring (the previous workaround) produces * a slot whose `wrapped_kek` references the synthetic payload, not * the live session — so `unlockViaAuthenticator()` later replaces the * live DEK map with stale wrapped DEKs and every decrypt fails. * * This method runs `ceremony` with the REAL keyring (still in * `keyringCache`). The ceremony performs the WebAuthn enrollment and * returns the slot options that hub then persists via the standard * tier-2 enrollAuthenticator path. * * Layering note: hub does not import `@noy-db/on-webauthn` (that * would invert the dep graph). The consumer wires it in: * * ```ts * import { enrollWebAuthn } from '@noy-db/on-webauthn' * * await db.enrollWebAuthn('demo', async (keyring) => { * const e = await enrollWebAuthn(keyring, 'demo', { rp: {...} }) * return { * id: `webauthn-${e.credentialId.slice(0, 8)}`, * method: 'webauthn', * wrapped_kek: e.wrappedPayload, * meta: { * credentialId: e.credentialId, * wrapIv: e.wrapIv, * prfUsed: e.prfUsed, * beFlag: e.beFlag, * requireSingleDevice: e.requireSingleDevice, * }, * } * }) * ``` * * Returns the WebAuthn `credentialId` (extracted from `meta.credentialId`) * for the caller's lookup index (a bootstrap vault, a PublicEnvelope, * a server-side allowlist). * * Gated by `enroll-authenticator` like `enrollAuthenticator()` itself. */ enrollWebAuthn(vault: string, ceremony: (keyring: UnlockedKeyring) => Promise, factors?: FactorProofBundle): Promise<{ credentialId: string; }>; /** * Filter the slot list to webauthn-method slots only. Useful for * "you have N WebAuthn credentials enrolled" UI surfaces and for * deciding when a new device prompt should appear. Identity is * `id` + `enrolled_at`; the `meta.credentialId` (base64) is used by * `allowCredentials` at unlock time. */ listWebAuthnSlots(vault: string): Promise>; /** * Resolve a slot by id, then hand the wrapped-KEK ciphertext + meta * to the caller-supplied verifier. The verifier is the * `unlockWith*` function from the corresponding `@noy-db/on-*` * package, e.g. `unlockWithPassword(slot, password)`. * * On success, mark the active session tier as 2 — subsequent * `checkGate` calls see a tier-2 unlock. */ unlockViaAuthenticator(vault: string, slotId: string, verify: (slot: KeyringAuthenticator) => Promise): Promise; /** English summary of the configured auth model. */ describeAuthConfig(vault: string): Promise; /** Mermaid `flowchart TB` source for the auth graph. */ diagramAuthConfig(vault: string): Promise; /** * Per-user enrollment summary. Gated by `view-user-auth` (default: * disabled). Sanitization is allowlist-based — never renders cred * ids, password hashes, secrets, or any field outside the allowlist. */ describeUserAuth(vault: string, userId: string, factors?: FactorProofBundle): Promise; /** Bulk variant for owner dashboards. Gated by `view-user-auth`. */ describeAllUsersAuth(vault: string, factors?: FactorProofBundle): Promise>; /** * Rotate the user's passphrase (user remembers old). Validates the * new phrase against the configured `passphrase` policy, runs the * `rotate-passphrase` gate, then re-derives + re-wraps every DEK. * * Tier-2 authenticator slots are dropped — each slot wraps the old * KEK and would need its derivation key to be re-presented. Re-enrol * via `db.enrollAuthenticator` after rotation. * * @throws `WeakPassphraseError` on a weak new phrase. * @throws `PolicyDeniedError` when the gate denies (missing factor, …). * @throws `InvalidKeyError` when `oldPassphrase` is wrong. */ rotatePassphrase(vault: string, input: RotatePassphraseInput, factors?: FactorProofBundle): Promise; /** * Reset the passphrase using a recovery proof (user forgot the old). * Currently supports the `'paper'` profile end-to-end; the * other profiles throw {@link RecoveryProfileNotImplementedError}. * * Burns the used recovery entry on success. */ recoverPassphrase(vault: string, input: RecoverPassphraseInput, factors?: FactorProofBundle): Promise; /** * Deliberate paper-recovery-code regeneration. User knows their * passphrase but wants a fresh sheet — they lost the printout or * suspect compromise of the off-site copy. * * Symmetric to {@link rotatePassphrase} for the recovery profile: * gated, audit-trackable, ergonomic. Replaces (not appends) the * paper sheet under `_meta/recovery-paper` in a single envelope `put`. * * Gated by the `rotate-recovery` policy gate: * - PERSONAL_POLICY: `{ minTier: 1 }` — knowing the passphrase * suffices, matching the lower-level flow's bar. * - STRICT_POLICY: `{ minTier: 1, factors: [{ anyOf: ['totp', * 'email-otp', 'webauthn-roaming'] }] }` — rotation is an * off-site-trust event; require an off-device factor so a * stolen unlocked laptop cannot silently mint a sheet for the * attacker. * * Defaults `count` to the existing sheet size so consumers aren't * surprised by a different code count. Explicit `count` overrides. * * @throws {@link RecoveryProfileNotImplementedError} when `profile` * is anything other than `'paper'` (v1 dispatch limit). * @throws {@link PolicyDeniedError} when the gate denies (missing * factor, tier mismatch, ...). * @throws on missing paper sheet — "nothing to rotate" surfaces as * an error rather than silently minting an entire new sheet. * * @example Default count + show-once UI * ```ts * const { newCodes } = await db.rotateRecovery('acme', { profile: 'paper' }) * showCodesToUser(newCodes) * ``` * * @example STRICT-policy site with TOTP factor proof * ```ts * await db.rotateRecovery( * 'acme', * { profile: 'paper', count: 10 }, * { factors: [{ kind: 'totp', proof: '123456' }] }, * ) * ``` */ rotateRecovery(vault: string, options: RotateRecoveryOptions, factors?: FactorProofBundle): Promise; private rotateRecoveryPaper; private rotateRecoveryShamir; /** * **Atomic create-and-enroll for managed-mode vaults.** * * Bootstraps a managed-mode vault and enrolls strong recovery in * a single ceremony. Under `passphraseMode: 'managed'`, every * `openVault` call requires a strong recovery profile (Shamir * today) to be enrolled — otherwise it throws * {@link ManagedRecoveryNotEnrolledError}. This method bypasses * the check temporarily so the keyring can be created, enrolls * the supplied recovery profile(s), then returns the vault. * * For Shamir enrollments, the show-once share strings come back * in `recoveryEnrollments[i].shares`. The hub never retains them * — the caller MUST display them to the user (once) before any * subsequent operation. * * Paper alone is NOT a strong profile under managed mode; passing * `{ profile: 'paper', ... }` without an accompanying shamir entry * is rejected at validation time. * * ```ts * const db = await createNoydb({ * store, user: 'alice', * passphraseMode: 'managed', * sealingKey: macosKeychainSealingProvider({ ... }), * }) * * const { vault, recoveryEnrollments } = await db.openVaultAndEnrollRecovery('acme', { * recovery: [{ profile: 'shamir', k: 2, n: 3 }], * }) * for (const r of recoveryEnrollments) { * if (r.shares) showSharesToUser(r.shares) // ONCE * } * ``` * * @throws ValidationError if recovery is empty, or contains no * strong profile under managed mode. */ openVaultAndEnrollRecovery(vault: string, opts: { readonly recovery: ReadonlyArray; readonly locale?: string; }): Promise<{ readonly vault: Vault; readonly recoveryEnrollments: ReadonlyArray; }>; /** * **Recovery flow under managed-passphrase mode.** * * Replaces the sealed passphrase of a managed-mode vault with a * fresh 256-bit random, sealed under the configured * `SealingKeyProvider`. The user never sees the new passphrase. * * Internally: * 1. Verify the recovery proof (Shamir today) and unwrap the * DEK set. * 2. Mint a fresh 256-bit random as the new effective passphrase. * 3. Rewrap the DEK set under a fresh KEK derived from the new * passphrase (via the existing `recoverPassphrase` path). * 4. Seal the random bytes under the provider and overwrite * `_meta/sealed-passphrase`. * 5. Drop the keyring cache so the next operation re-derives. * * The vault's strong-recovery enrollment is preserved across * recovery (Shamir entries are not burned on use). * * @throws ValidationError if the Noydb instance is not in managed mode. */ recoverManagedPassphrase(vault: string, options: { readonly recoveryProof: RecoveryProof; readonly passphrasePolicy?: PassphrasePolicy; }): Promise; /** * Atomic peer-recovery — re-wraps an EXISTING user's keyring under * a fresh temp passphrase in a single store write. Closes the * partial-failure window (the previous compose-from-primitives * pattern was `db.revoke + db.grant`, two writes — if the issuer * cancelled between them the target was locked out entirely). * * Different from `db.revoke + db.grant`: * * - Same `userId`, role, permissions, capabilities preserved. * - DEKs unchanged → every other principal in the vault keeps * access. No key rotation. * - Allows owner→owner natively. The existing * `db.revoke` retains its block — peer-recovery is a separate, * intentionally-named operation. * - Tier-2 slots dropped (they wrap the old KEK). * * Gated by `peer-recover-user`; `STRICT_POLICY` requires a * recovery / TOTP / email-OTP factor proof at the moment of * recovery, so the issuer affirmatively re-asserts identity. * * The recipient should call `db.rotatePassphrase` on first session * to choose their own phrase — the temp acts as a single-use * bridge. * * ```ts * await db.recoverUser('acme', { * userId: 'bob', * passphrase: 'temporary-correct-horse-battery-staple-printer', * }, { factors: [{ kind: 'recovery' }] }) * // Bob opens createNoydb({ user: 'bob', secret: tempPhrase }) * // and immediately calls db.rotatePassphrase to set his own. * ``` * * @throws `NoAccessError` when no keyring exists for the target. * @throws `PermissionDeniedError` when the caller's role can't * recover the target's role (admin→owner is blocked even * under recovery). * @throws `PrivilegeEscalationError` when the caller lacks a DEK * the target previously had access to. * */ recoverUser(vault: string, options: RecoverUserOptions, factors?: FactorProofBundle): Promise; /** * Persist a recovery enrollment. Accepts the `'paper'` * profile. * * The hub wraps the user's DEK set (not the KEK) under a code-derived * AES-GCM key — see `team/recovery.ts` for the rationale. The mint * helper {@link mintPaperRecoveryEntry} is the canonical primitive; * pair it with `db.getKeyring(vault)` to obtain the live DEK set: * * ```ts * import { mintPaperRecoveryEntry } from '@noy-db/hub' * * const keyring = await db.getKeyring('acme') * const codes: string[] = ['CORRECT-HORSE-1', 'BATTERY-STAPLE-2', ...] * const entries = await Promise.all( * codes.map((code, i) => mintPaperRecoveryEntry(keyring.deks, code, `code-${i}`)), * ) * await db.enrollRecovery('acme', { profile: 'paper', entries }) * showCodesToUser(codes) * ``` * * `@noy-db/on-recovery`'s `generateRecoveryCodeSet` * delegates to `mintPaperRecoveryEntry` internally — its output is * fed directly to this API. Pick whichever fits your code-gen layer: * * ```ts * import { generateRecoveryCodeSet } from '@noy-db/on-recovery' * const { codes, entries } = await generateRecoveryCodeSet({ deks: keyring.deks, count: 8 }) * await db.enrollRecovery('acme', { profile: 'paper', entries }) * ``` */ enrollRecovery(vault: string, enrollment: RecoveryEnrollmentInput): Promise; /** Read the persisted recovery entries (paper + Shamir). Used by `describeAuthConfig`. */ listRecoveryEntries(vault: string): Promise<{ paper: ReadonlyArray; shamir: ReadonlyArray; }>; /** * Register a tier-3 quick-unlock state for the vault. The state is * an opaque blob produced by `@noy-db/on-pin/enrollPin` (or any * compatible primitive). It is held in memory only — never persisted * — and auto-clears when its `expiresAt` elapses. * * Gated by `rotate-unlock` (the same gate covers "set" and "rotate" * because tier-3 is a single-slot rolling secret). */ enrollUnlock(vault: string, state: QuickUnlockState, factors?: FactorProofBundle): Promise; /** * Resume a session via the registered tier-3 state. The verifier is * `@noy-db/on-pin/resumePin` (or compatible). On success, mark the * active session tier as 3 — every operation must re-authenticate at * tier 2 to elevate. * * Returns `undefined` (caller should fall back to tier 2) when no * tier-3 state is registered. */ unlockViaPin(vault: string, resume: (state: QuickUnlockState) => Promise): Promise; /** Drop the tier-3 state for a vault — explicit logout. */ clearQuickUnlock(vault: string): void; /** * Public accessor for the unlocked keyring of a vault. * * Returns a **defensive shallow copy** so consumers can read the DEK * map and authenticator list without the risk of mutating the hub's * internal cache. Internal hub code paths use a live reference * via `getKeyringInternal`; ceremonies and external consumers always * get a snapshot. * * The CryptoKey values inside `deks` are not cloned — Web Crypto * keys are opaque handles, and a shared handle is intentional * (encrypt / decrypt go through the same key the cache holds). * Only the container Map / authenticator array is fresh. * * Used by `@noy-db/on-*` ceremonies that need the live DEK set * (paper recovery via {@link mintPaperRecoveryEntry}, tier-3 PIN * enrolment via on-pin's `enrollPin`, custom on-* ceremonies that * don't have a hub-side wrapper). * * No new permission gate — this is an accessor over already-unlocked * state. The keyring is materialized only after the calling session * has unlocked the vault at tier 1, 2, or 3, so exposing it does not * widen access. Throws `ValidationError` when encryption is enabled * and no `secret` / `getKeyring` is configured. * * ```ts * const keyring = await db.getKeyring('acme') * // keyring.deks: Map * // keyring.kek: CryptoKey | null (null for tier-3 / wrap-DEKs sessions) * // keyring.role / .permissions / .authenticators * ``` */ getKeyring(vault: string): Promise; } export {};