import { GateName, UnlockedKeyring, VaultPolicy, ActiveTier, FactorProof } from '@noy-db/hub'; /** * **Device-trust unlock mode** — "always safe to open on this device." * * The session DEKs are wrapped under a **non-extractable, device-bound * `crypto.subtle` key** and persisted in IndexedDB, so the vault reopens * on this device with **no user factor at all**: load the CryptoKey + * blob, unwrap, done. No network, no prompt. * * Sits beside PIN quick-lock in the session-resume family and shares its * plumbing (`keyring-codec.ts`): same serialized-keyring payload, same * AES-GCM wrap, same `kek: null` resumed keyring. The differences are * the wrapping key (device-generated, not credential-derived) and the * persistence (IndexedDB structured clone, not an in-memory state * object). * * ## Threat model (read before enabling) * * - **The OS lock screen IS the factor.** Anyone holding the unlocked * device opens the vault. This is a deliberate, opt-in trade for * mobile/embedded contexts (LIFF → detached PWA, kiosk, personal * phone) where a repeated secret/PIN prompt kills the offline UX * and the device lock is accepted as the effective user factor. * - **The key material cannot be exfiltrated.** The wrapping key is * generated with `extractable: false` and persisted as a CryptoKey * OBJECT via IndexedDB's structured clone — its raw bits never exist * in JS. Malware must run *on the origin in the browser* to use it; * it cannot steal the key itself. * - **Storage eviction silently disables the mode** (browser data * clear, iOS ITP 7-day eviction). Resume then fails CLOSED with the * typed {@link DeviceTrustNotFoundError} — the caller falls back to a * real-factor unlock (secret, invite, OIDC) and may re-enroll. * Never a lockout: the real factor always remains. * - **Enrollment requires an already-unlocked session** (session-resume * family law). Device-trust is NEVER a first-unlock factor — a cold * device with no prior unlock has nothing to wrap. * - **Revocation.** Locally, {@link clearDeviceTrust} deletes the key + * blob — the mode is dead on this device. Keyring-side, the blob * caches raw DEK bytes, so standard slot revocation + DEK rotation * invalidate it: after the vault rotates its DEKs, the cached stale * DEKs fail AES-GCM authentication against any newly written record. * * ## Policy gate + tier cap * * - Enrollment is gated by the `app:device-trust` policy gate * ({@link DEVICE_TRUST_GATE}), evaluated with the hub's own * `checkGate` engine when the caller passes the vault policy. The * vault owner/admin forbids the mode by configuring * `gates: { 'app:device-trust': { enabled: false, minTier: 3 } }`, or * bounds it (e.g. `minTier: 1` = only a full-secret session may * enroll, `factors: [...]` = require a fresh proof at enrollment). * Unconfigured, the gate allows — matching the mode's opt-in design. * - A device-trust resume yields a **capped session tier** * ({@link DeviceTrustState.resumeTier}) — default tier 3, the floor, * same as a PIN resume and always below the secret tier 1. Pass * it as `activeTier` to `checkGate` so sensitive gated operations * still require a real factor. * * @module */ /** * The policy gate evaluated at enrollment. Lives in the `app:*` * namespace deliberately: enrollment is a purely client-local operation * over DEKs the session already holds, so the gate is an owner-facing * policy switch, not a cryptographic boundary — and an unconfigured * `app:*` gate allows (built-in gates fail closed), which matches the * opt-in default: the mode works until the owner forbids it. */ declare const DEVICE_TRUST_GATE: GateName; /** * Session tier a device-trust resume may claim. Tier 1 (secret) is * deliberately unrepresentable — a no-factor resume can never claim the * full-unlock tier. */ type DeviceTrustResumeTier = 2 | 3; /** * Default resume tier: 3, the floor — same tier as a PIN quick-resume * and below the secret tier (1), so policy-gated sensitive * operations still require a real factor. */ declare const DEVICE_TRUST_DEFAULT_RESUME_TIER: DeviceTrustResumeTier; /** * Thrown by `resumeDeviceTrust()` when no device-trust record exists for * the vault in browser storage. * * The device key + wrapped-DEKs blob are created at enrollment and * stored in IndexedDB on the enrolling device — they never leave it. * This error means storage was evicted (browser data clear, iOS ITP * eviction, private browsing) or the mode was never enrolled / was * cleared. Fail closed into a real-factor unlock (secret, invite, * OIDC), then re-enroll this device via `enrollDeviceTrust` — the real * factor always remains, so this is never a lockout. */ declare class DeviceTrustNotFoundError extends Error { readonly code: "DEVICE_TRUST_NOT_FOUND"; constructor(vault: string); } /** Thrown when device-trust enrollment fails (see message for cause). */ declare class DeviceTrustEnrollmentError extends Error { readonly code: "DEVICE_TRUST_ENROLLMENT_FAILED"; constructor(message?: string); } /** * Thrown by `resumeDeviceTrust()` when a record exists but cannot be * unwrapped (corrupt blob, key/blob mismatch after a partial storage * wipe). Fail closed: unlock with a real factor and re-enroll. */ declare class DeviceTrustInvalidError extends Error { readonly code: "DEVICE_TRUST_INVALID"; constructor(vault: string); } /** * Thrown when no `store` was supplied and the environment has no * `indexedDB` (Node, some workers). Pass an explicit * {@link DeviceTrustStore} implementation. */ declare class DeviceTrustStorageError extends Error { readonly code: "DEVICE_TRUST_STORAGE_UNAVAILABLE"; constructor(message?: string); } /** * Minimal async key-value storage for device-trust records. Values must * survive a structured clone (they contain a non-extractable * CryptoKey). The default is IndexedDB * ({@link indexedDbDeviceTrustStore}); tests and non-browser hosts * inject their own. */ interface DeviceTrustStore { get(key: string): Promise; set(key: string, value: unknown): Promise; delete(key: string): Promise; } /** * Metadata persisted beside the device key. Unlike `PinResumeState`, * this is NOT the caller's to hold — it lives in the store (there is * nothing secret and nothing to keep in memory across a cold start). */ interface DeviceTrustState { /** Schema marker. */ readonly _noydb_device_trust: 1; /** Vault this record resumes. */ readonly vault: string; /** Base64 AES-GCM IV for `wrappedKeyring`. */ readonly iv: string; /** Base64 AES-GCM ciphertext — serialized keyring wrapped under the device key. */ readonly wrappedKeyring: string; /** ISO-8601 enrollment timestamp. */ readonly enrolledAt: string; /** Session tier a resume from this record yields. */ readonly resumeTier: DeviceTrustResumeTier; } interface EnrollDeviceTrustOptions { /** Vault name — keys the device-trust record in storage. */ readonly vault: string; /** Storage override. Default: IndexedDB. */ readonly store?: DeviceTrustStore; /** * The vault's policy document (`db.policy.getPolicy(vault)`). When * present, the {@link DEVICE_TRUST_GATE} gate is checked and a denial * throws the hub's `PolicyDeniedError`. When absent, no gate runs. */ readonly policy?: VaultPolicy; /** Tier of the enrolling session, for the gate check. Default: 1 (fresh full unlock). */ readonly activeTier?: ActiveTier; /** Factor proofs presented to the gate, if its policy requires any. */ readonly factors?: ReadonlyArray; /** Session tier a resume will yield. Default: {@link DEVICE_TRUST_DEFAULT_RESUME_TIER}. */ readonly resumeTier?: DeviceTrustResumeTier; } interface DeviceTrustStoreOptions { /** Storage override. Default: IndexedDB. */ readonly store?: DeviceTrustStore; } /** What `resumeDeviceTrust()` yields. */ interface DeviceTrustResumeResult { /** The resumed session keyring (`kek: null`, like every session-resume). */ readonly keyring: UnlockedKeyring; /** * The capped session tier recorded at enrollment. Pass as * `activeTier` to `checkGate` so gated operations see the true * (no-factor) strength of this session. */ readonly resumeTier: DeviceTrustResumeTier; } /** * Enroll device-trust for a vault against an already-unlocked keyring. * * Generates a fresh **non-extractable** AES-GCM device key * (`crypto.subtle.generateKey(..., extractable: false, ...)`), wraps * the session's serialized keyring under it, and persists the CryptoKey * OBJECT + blob as one structured-clone record. Re-enrolling the same * vault overwrites the previous record (fresh key, fresh blob). * * @throws {DeviceTrustEnrollmentError} when the keyring holds no DEKs * (not an unlocked session) or a DEK is not extractable. * @throws `PolicyDeniedError` (hub) when `options.policy` forbids or * bounds the {@link DEVICE_TRUST_GATE} gate. * @throws {DeviceTrustStorageError} when no store is available. */ declare function enrollDeviceTrust(keyring: UnlockedKeyring, options: EnrollDeviceTrustOptions): Promise; /** * Resume a session from this device's device-trust record. No network, * no prompt: load the CryptoKey + blob from storage, unwrap, return the * keyring (`kek: null`) with its capped {@link DeviceTrustResumeResult.resumeTier}. * * @throws {DeviceTrustNotFoundError} when no record exists (evicted / * cleared / never enrolled) — fail closed to a real-factor unlock. * @throws {DeviceTrustInvalidError} when the record exists but cannot * be unwrapped. * @throws {DeviceTrustStorageError} when no store is available. */ declare function resumeDeviceTrust(vault: string, options?: DeviceTrustStoreOptions): Promise; /** * Local revocation: delete this device's device-trust record (key + * blob). The mode is dead on this device until re-enrolled from a * real-factor unlock. Idempotent. */ declare function clearDeviceTrust(vault: string, options?: DeviceTrustStoreOptions): Promise; /** Whether a device-trust record exists for the vault on this device. */ declare function isDeviceTrustEnrolled(vault: string, options?: DeviceTrustStoreOptions): Promise; /** * The default {@link DeviceTrustStore}: one IndexedDB database * (`noydb-device-trust`) with a single object store. IndexedDB's * structured clone is what lets a non-extractable CryptoKey persist as * an OBJECT — the key survives, its bits stay unreadable. */ declare function indexedDbDeviceTrustStore(dbName?: string): DeviceTrustStore; /** * **@noy-db/on-pin** — session-resume PIN quick-lock for noy-db. * * The use case: after the user unlocks a vault with the full secret, * the session goes idle (screen lock, tab switch). Instead of re-entering * the full secret, the user types a 4–6 digit PIN (or taps their * device biometric) to **resume the already-open session**. * * ## What this is NOT * * This is **NOT** a secret replacement. If the vault is cold-started * (fresh app launch, no prior unlock), a PIN alone cannot open it — the * KEK must be re-derived from the real secret via PBKDF2-600K. * * ## Security model * * 1. **PIN never derives the KEK.** The PIN derives a transient wrapping * key via PBKDF2 (100k iterations, not 600k — the protection window is * short, so fewer iterations are acceptable). * 2. **The transient key wraps only the DEKs.** A `PinResumeState` carries * the encrypted DEK map but NOT the KEK. Even if the PIN is * compromised, an attacker cannot re-derive the KEK or unwrap a cold * keyring — they can only re-open THIS session's cached DEKs. * 3. **TTL-bounded.** Every `PinResumeState` has an `expiresAt`. After * expiry, `resumePin()` throws; the user must re-enter the full * secret. * 4. **Attempt-bounded.** After `maxAttempts` wrong PINs, the state * refuses further attempts until re-enrolment. * 5. **Memory-scoped by convention.** The caller is responsible for * storing the `PinResumeState` appropriately — ideally in memory * (lost when the process exits). Writing it to `localStorage` is * allowed but defeats the short-lived-session property, so it is * flagged here as a design decision the caller owns. * * ## Limits (read before shipping) * * - The `attempts` counter lives inside the `PinResumeState` object. * An attacker with a stale copy of the state can "reset" attempts * by reverting their copy. Real lockout enforcement needs a trusted * counter (server-side or OS secure enclave). Document this to * consumers. * - Offline brute-force is bounded by PBKDF2 cost + the secrecy of the * state blob. Do not persist the state to a public location. * - A 4-digit numeric PIN has only 10,000 possibilities. Even at 100k * PBKDF2 iterations each (~10^9 hash ops total), a GPU attacker * exhausts the entire space in **seconds**, not hours — PBKDF2's * iteration cost does not meaningfully protect a keyspace this small * once the state blob leaks. This is exactly why on-pin is a * UX-convenience resume factor, NOT primary authentication, and why * the state blob must never be persisted to a public location. * * ## API shape (mirrors @noy-db/on-* siblings) * * ```ts * import { enrollPin, resumePin } from '@noy-db/on-pin' * * // After the user has opened the vault with the full secret: * const state = await enrollPin(keyring, { pin: '1234', ttlMs: 15 * 60 * 1000 }) * // Keep `state` in memory. Do not write it anywhere durable. * * // Later, when session resumes: * const keyring = await resumePin(state, { pin: '1234' }) * ``` * * ## The second mode: device-trust * * This package also ships the **device-trust** session-resume mode * ("always safe to open on this device") — the session DEKs wrapped * under a non-extractable device-bound CryptoKey persisted in * IndexedDB, so the vault reopens with no user factor at all. See * `device-trust.ts` for the API (`enrollDeviceTrust` / * `resumeDeviceTrust`) and its explicit threat model (the OS lock * screen IS the factor). * * @packageDocumentation */ /** Default TTL: 15 minutes. Short by design — PIN resumes, doesn't replace. */ declare const PIN_DEFAULT_TTL_MS: number; /** Default max attempts before state refuses further unlock. */ declare const PIN_DEFAULT_MAX_ATTEMPTS = 5; /** * PBKDF2 iteration count for the PIN. Lower than the 600k used for * secret KEK derivation because (a) the window is short, (b) the * attempt counter bounds online attacks, (c) the state is not * persisted in a public location. Do not lower this further without * also raising attempt-counter rigour. */ declare const PIN_PBKDF2_ITERATIONS = 100000; declare class PinInvalidError extends Error { readonly code: "PIN_INVALID"; constructor(message?: string); } declare class PinExpiredError extends Error { readonly code: "PIN_EXPIRED"; constructor(message?: string); } declare class PinAttemptsExceededError extends Error { readonly code: "PIN_ATTEMPTS_EXCEEDED"; constructor(message?: string); } declare class PinEnrollmentError extends Error { readonly code: "PIN_ENROLLMENT_FAILED"; constructor(message?: string); } /** * Opaque serializable state produced by `enrollPin()`. Hand it to * `resumePin()` to unlock. Callers keep this in memory (not on disk / * sessionStorage in general) per the security model above. * * `attempts` is the only mutable field; incremented on wrong-PIN * failures. Callers should treat the rest as immutable. */ interface PinResumeState { /** Schema marker. */ readonly _noydb_on_pin: 1; /** Base64 PBKDF2 salt (32 random bytes). */ readonly salt: string; /** Base64 AES-GCM IV (12 random bytes) used to encrypt the wrapped payload. */ readonly iv: string; /** Base64 AES-GCM ciphertext — serialized keyring wrapped with the PIN-derived key. */ readonly wrappedKeyring: string; /** ISO-8601 timestamp after which `resumePin()` refuses. */ readonly expiresAt: string; /** Mutable counter — incremented on each wrong-PIN attempt. */ attempts: number; /** Upper bound; when `attempts >= maxAttempts`, resume throws. */ readonly maxAttempts: number; } interface EnrollPinOptions { /** The short secret. Typically 4–6 digits, but any string works. */ readonly pin: string; /** Resume window length. Default: 15 minutes. */ readonly ttlMs?: number; /** Max wrong-PIN attempts before the state is dead. Default: 5. */ readonly maxAttempts?: number; } interface ResumePinOptions { readonly pin: string; } /** * Enrol a PIN for session-resume against an already-unlocked keyring. * * Requires the keyring's DEKs to be extractable (`crypto.subtle.exportKey('raw', dek)` * must succeed). The hub creates DEKs with `extractable: true` by default. * * @throws `PinEnrollmentError` if any DEK is non-extractable. */ declare function enrollPin(keyring: UnlockedKeyring, options: EnrollPinOptions): Promise; /** * Resume a session from a previously-enrolled `PinResumeState`. * * The returned keyring has `kek: null` — PIN resume does NOT reconstruct * the KEK (by design). The DEKs are sufficient for normal reads and * writes; operations that require a KEK (opening additional vaults, * re-enrolling, key rotation) still need the full secret flow. * * @throws `PinExpiredError` if the resume window has elapsed. * @throws `PinAttemptsExceededError` if `attempts >= maxAttempts`. * @throws `PinInvalidError` if the PIN is wrong (state.attempts incremented). */ declare function resumePin(state: PinResumeState, options: ResumePinOptions): Promise; /** Fast TTL check without attempting decrypt. */ declare function isPinStateValid(state: PinResumeState): boolean; /** * Zero the state in place. After this, `resumePin()` will fail. * Use on explicit logout. */ declare function clearPinState(state: PinResumeState): void; export { DEVICE_TRUST_DEFAULT_RESUME_TIER, DEVICE_TRUST_GATE, DeviceTrustEnrollmentError, DeviceTrustInvalidError, DeviceTrustNotFoundError, type DeviceTrustResumeResult, type DeviceTrustResumeTier, type DeviceTrustState, DeviceTrustStorageError, type DeviceTrustStore, type DeviceTrustStoreOptions, type EnrollDeviceTrustOptions, type EnrollPinOptions, PIN_DEFAULT_MAX_ATTEMPTS, PIN_DEFAULT_TTL_MS, PIN_PBKDF2_ITERATIONS, PinAttemptsExceededError, PinEnrollmentError, PinExpiredError, PinInvalidError, type PinResumeState, type ResumePinOptions, clearDeviceTrust, clearPinState, enrollDeviceTrust, enrollPin, indexedDbDeviceTrustStore, isDeviceTrustEnrolled, isPinStateValid, resumeDeviceTrust, resumePin };