@noy-db/on-pin 0.1.0-pre.7 → 0.1.0-pre.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/on-pin** — session-resume PIN quick-lock for noy-db.\n *\n * The use case: after the user unlocks a vault with the full passphrase,\n * the session goes idle (screen lock, tab switch). Instead of re-entering\n * the full passphrase, the user types a 4–6 digit PIN (or taps their\n * device biometric) to **resume the already-open session**.\n *\n * ## What this is NOT\n *\n * This is **NOT** a passphrase replacement. If the vault is cold-started\n * (fresh app launch, no prior unlock), a PIN alone cannot open it — the\n * KEK must be re-derived from the real passphrase via PBKDF2-600K.\n *\n * ## Security model\n *\n * 1. **PIN never derives the KEK.** The PIN derives a transient wrapping\n * key via PBKDF2 (100k iterations, not 600k — the protection window is\n * short, so fewer iterations are acceptable).\n * 2. **The transient key wraps only the DEKs.** A `PinResumeState` carries\n * the encrypted DEK map but NOT the KEK. Even if the PIN is\n * compromised, an attacker cannot re-derive the KEK or unwrap a cold\n * keyring — they can only re-open THIS session's cached DEKs.\n * 3. **TTL-bounded.** Every `PinResumeState` has an `expiresAt`. After\n * expiry, `resumePin()` throws; the user must re-enter the full\n * passphrase.\n * 4. **Attempt-bounded.** After `maxAttempts` wrong PINs, the state\n * refuses further attempts until re-enrolment.\n * 5. **Memory-scoped by convention.** The caller is responsible for\n * storing the `PinResumeState` appropriately — ideally in memory\n * (lost when the process exits). Writing it to `localStorage` is\n * allowed but defeats the short-lived-session property, so it is\n * flagged here as a design decision the caller owns.\n *\n * ## Limits (read before shipping)\n *\n * - The `attempts` counter lives inside the `PinResumeState` object.\n * An attacker with a stale copy of the state can \"reset\" attempts\n * by reverting their copy. Real lockout enforcement needs a trusted\n * counter (server-side or OS secure enclave). Document this to\n * consumers.\n * - Offline brute-force is bounded by PBKDF2 cost + the secrecy of the\n * state blob. Do not persist the state to a public location.\n * - A 4-digit numeric PIN has only 10,000 possibilities. With 100k\n * PBKDF2 iterations each, a GPU attacker needs ~10^9 hash ops to\n * exhaust the space — roughly hours. Combined with the short TTL\n * and the attempts counter, this is acceptable for UX convenience\n * but NOT for primary authentication.\n *\n * ## API shape (mirrors @noy-db/on-* siblings)\n *\n * ```ts\n * import { enrollPin, resumePin } from '@noy-db/on-pin'\n *\n * // After the user has opened the vault with the full passphrase:\n * const state = await enrollPin(keyring, { pin: '1234', ttlMs: 15 * 60 * 1000 })\n * // Keep `state` in memory. Do not write it anywhere durable.\n *\n * // Later, when session resumes:\n * const keyring = await resumePin(state, { pin: '1234' })\n * ```\n *\n * @packageDocumentation\n */\n\nimport type { Role, Permissions, UnlockedKeyring } from '@noy-db/hub'\n\n// ─── Constants ──────────────────────────────────────────────────────────\n\n/** Default TTL: 15 minutes. Short by design — PIN resumes, doesn't replace. */\nexport const PIN_DEFAULT_TTL_MS = 15 * 60 * 1000\n\n/** Default max attempts before state refuses further unlock. */\nexport const PIN_DEFAULT_MAX_ATTEMPTS = 5\n\n/**\n * PBKDF2 iteration count for the PIN. Lower than the 600k used for\n * passphrase KEK derivation because (a) the window is short, (b) the\n * attempt counter bounds online attacks, (c) the state is not\n * persisted in a public location. Do not lower this further without\n * also raising attempt-counter rigour.\n */\nexport const PIN_PBKDF2_ITERATIONS = 100_000\n\n// ─── Errors ─────────────────────────────────────────────────────────────\n\nexport class PinInvalidError extends Error {\n readonly code = 'PIN_INVALID' as const\n constructor(message = 'PIN is incorrect.') {\n super(message)\n this.name = 'PinInvalidError'\n }\n}\n\nexport class PinExpiredError extends Error {\n readonly code = 'PIN_EXPIRED' as const\n constructor(message = 'PIN resume window has expired; re-enter full passphrase.') {\n super(message)\n this.name = 'PinExpiredError'\n }\n}\n\nexport class PinAttemptsExceededError extends Error {\n readonly code = 'PIN_ATTEMPTS_EXCEEDED' as const\n constructor(message = 'Too many wrong PIN attempts; re-enter full passphrase.') {\n super(message)\n this.name = 'PinAttemptsExceededError'\n }\n}\n\nexport class PinEnrollmentError extends Error {\n readonly code = 'PIN_ENROLLMENT_FAILED' as const\n constructor(message = 'PIN enrolment failed.') {\n super(message)\n this.name = 'PinEnrollmentError'\n }\n}\n\n// ─── Types ──────────────────────────────────────────────────────────────\n\n/**\n * Opaque serializable state produced by `enrollPin()`. Hand it to\n * `resumePin()` to unlock. Callers keep this in memory (not on disk /\n * sessionStorage in general) per the security model above.\n *\n * `attempts` is the only mutable field; incremented on wrong-PIN\n * failures. Callers should treat the rest as immutable.\n */\nexport interface PinResumeState {\n /** Schema marker. */\n readonly _noydb_on_pin: 1\n /** Base64 PBKDF2 salt (32 random bytes). */\n readonly salt: string\n /** Base64 AES-GCM IV (12 random bytes) used to encrypt the wrapped payload. */\n readonly iv: string\n /** Base64 AES-GCM ciphertext — serialized keyring wrapped with the PIN-derived key. */\n readonly wrappedKeyring: string\n /** ISO-8601 timestamp after which `resumePin()` refuses. */\n readonly expiresAt: string\n /** Mutable counter — incremented on each wrong-PIN attempt. */\n attempts: number\n /** Upper bound; when `attempts >= maxAttempts`, resume throws. */\n readonly maxAttempts: number\n}\n\nexport interface EnrollPinOptions {\n /** The short secret. Typically 4–6 digits, but any string works. */\n readonly pin: string\n /** Resume window length. Default: 15 minutes. */\n readonly ttlMs?: number\n /** Max wrong-PIN attempts before the state is dead. Default: 5. */\n readonly maxAttempts?: number\n}\n\nexport interface ResumePinOptions {\n readonly pin: string\n}\n\n// ─── Implementation ─────────────────────────────────────────────────────\n\n/**\n * Enrol a PIN for session-resume against an already-unlocked keyring.\n *\n * Requires the keyring's DEKs to be extractable (`crypto.subtle.exportKey('raw', dek)`\n * must succeed). The hub creates DEKs with `extractable: true` by default.\n *\n * @throws `PinEnrollmentError` if any DEK is non-extractable.\n */\nexport async function enrollPin(\n keyring: UnlockedKeyring,\n options: EnrollPinOptions,\n): Promise<PinResumeState> {\n const ttlMs = options.ttlMs ?? PIN_DEFAULT_TTL_MS\n const maxAttempts = options.maxAttempts ?? PIN_DEFAULT_MAX_ATTEMPTS\n\n const salt = crypto.getRandomValues(new Uint8Array(32))\n const iv = crypto.getRandomValues(new Uint8Array(12))\n\n const wrappingKey = await deriveWrappingKey(options.pin, salt)\n\n let serialized: Uint8Array\n try {\n serialized = await serializeKeyring(keyring)\n } catch (err) {\n throw new PinEnrollmentError(\n 'Failed to serialize keyring — DEK not extractable. ' +\n `Underlying error: ${err instanceof Error ? err.message : String(err)}`,\n )\n }\n\n const ciphertext = await crypto.subtle.encrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n wrappingKey,\n serialized as BufferSource,\n )\n\n return {\n _noydb_on_pin: 1,\n salt: bytesToBase64(salt),\n iv: bytesToBase64(iv),\n wrappedKeyring: bytesToBase64(new Uint8Array(ciphertext)),\n expiresAt: new Date(Date.now() + ttlMs).toISOString(),\n attempts: 0,\n maxAttempts,\n }\n}\n\n/**\n * Resume a session from a previously-enrolled `PinResumeState`.\n *\n * The returned keyring has `kek: null` — PIN resume does NOT reconstruct\n * the KEK (by design). The DEKs are sufficient for normal reads and\n * writes; operations that require a KEK (opening additional vaults,\n * re-enrolling, key rotation) still need the full passphrase flow.\n *\n * @throws `PinExpiredError` if the resume window has elapsed.\n * @throws `PinAttemptsExceededError` if `attempts >= maxAttempts`.\n * @throws `PinInvalidError` if the PIN is wrong (state.attempts incremented).\n */\nexport async function resumePin(\n state: PinResumeState,\n options: ResumePinOptions,\n): Promise<UnlockedKeyring> {\n if (Date.now() > new Date(state.expiresAt).getTime()) {\n throw new PinExpiredError()\n }\n if (state.attempts >= state.maxAttempts) {\n throw new PinAttemptsExceededError()\n }\n\n const salt = base64ToBytes(state.salt)\n const iv = base64ToBytes(state.iv)\n const ciphertext = base64ToBytes(state.wrappedKeyring)\n\n const wrappingKey = await deriveWrappingKey(options.pin, salt)\n\n let plaintext: ArrayBuffer\n try {\n plaintext = await crypto.subtle.decrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n wrappingKey,\n ciphertext as BufferSource,\n )\n } catch {\n // AES-GCM auth failure. Increment the attempts counter before\n // throwing so repeated wrong PINs progressively lock the state.\n state.attempts = state.attempts + 1\n throw new PinInvalidError()\n }\n\n return deserializeKeyring(new Uint8Array(plaintext))\n}\n\n/** Fast TTL check without attempting decrypt. */\nexport function isPinStateValid(state: PinResumeState): boolean {\n return (\n Date.now() <= new Date(state.expiresAt).getTime() &&\n state.attempts < state.maxAttempts\n )\n}\n\n/**\n * Zero the state in place. After this, `resumePin()` will fail.\n * Use on explicit logout.\n */\nexport function clearPinState(state: PinResumeState): void {\n // Overwrite the attempts counter past the max + expire the state.\n ;(state as { attempts: number }).attempts = state.maxAttempts\n ;(state as { expiresAt: string }).expiresAt = new Date(0).toISOString()\n ;(state as { wrappedKeyring: string }).wrappedKeyring = ''\n}\n\n// ─── Helpers ────────────────────────────────────────────────────────────\n\nasync function deriveWrappingKey(\n pin: string,\n salt: Uint8Array,\n): Promise<CryptoKey> {\n const ikm = await crypto.subtle.importKey(\n 'raw',\n new TextEncoder().encode(pin),\n 'PBKDF2',\n false,\n ['deriveKey'],\n )\n return crypto.subtle.deriveKey(\n {\n name: 'PBKDF2',\n salt: salt as BufferSource,\n iterations: PIN_PBKDF2_ITERATIONS,\n hash: 'SHA-256',\n },\n ikm,\n { name: 'AES-GCM', length: 256 },\n false,\n ['encrypt', 'decrypt'],\n )\n}\n\ninterface SerializedKeyring {\n userId: string\n displayName: string\n role: Role\n permissions: Permissions\n salt: string\n deks: Record<string, string>\n}\n\nasync function serializeKeyring(k: UnlockedKeyring): Promise<Uint8Array> {\n const deks: Record<string, string> = {}\n for (const [collection, key] of k.deks) {\n const raw = await crypto.subtle.exportKey('raw', key)\n deks[collection] = bytesToBase64(new Uint8Array(raw))\n }\n const json: SerializedKeyring = {\n userId: k.userId,\n displayName: k.displayName,\n role: k.role,\n permissions: k.permissions,\n salt: bytesToBase64(k.salt),\n deks,\n }\n return new TextEncoder().encode(JSON.stringify(json))\n}\n\nasync function deserializeKeyring(bytes: Uint8Array): Promise<UnlockedKeyring> {\n const parsed = JSON.parse(new TextDecoder().decode(bytes)) as SerializedKeyring\n const deks = new Map<string, CryptoKey>()\n for (const [coll, b64] of Object.entries(parsed.deks)) {\n const raw = base64ToBytes(b64)\n const key = await crypto.subtle.importKey(\n 'raw',\n raw as BufferSource,\n { name: 'AES-GCM' },\n true,\n ['encrypt', 'decrypt'],\n )\n deks.set(coll, key)\n }\n return {\n userId: parsed.userId,\n displayName: parsed.displayName,\n role: parsed.role,\n permissions: parsed.permissions,\n salt: base64ToBytes(parsed.salt),\n deks,\n // KEK is deliberately null — PIN-resume returns a keyring that can\n // read/write but cannot open additional vaults or rotate keys.\n kek: null as unknown as CryptoKey,\n authenticators: [],\n }\n}\n\nfunction bytesToBase64(bytes: Uint8Array): string {\n let s = ''\n for (const b of bytes) s += String.fromCharCode(b)\n return btoa(s)\n}\n\nfunction base64ToBytes(b64: string): Uint8Array {\n const s = atob(b64)\n const out = new Uint8Array(s.length)\n for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i)\n return out\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/on-pin** — session-resume PIN quick-lock for noy-db.\n *\n * The use case: after the user unlocks a vault with the full passphrase,\n * the session goes idle (screen lock, tab switch). Instead of re-entering\n * the full passphrase, the user types a 4–6 digit PIN (or taps their\n * device biometric) to **resume the already-open session**.\n *\n * ## What this is NOT\n *\n * This is **NOT** a passphrase replacement. If the vault is cold-started\n * (fresh app launch, no prior unlock), a PIN alone cannot open it — the\n * KEK must be re-derived from the real passphrase via PBKDF2-600K.\n *\n * ## Security model\n *\n * 1. **PIN never derives the KEK.** The PIN derives a transient wrapping\n * key via PBKDF2 (100k iterations, not 600k — the protection window is\n * short, so fewer iterations are acceptable).\n * 2. **The transient key wraps only the DEKs.** A `PinResumeState` carries\n * the encrypted DEK map but NOT the KEK. Even if the PIN is\n * compromised, an attacker cannot re-derive the KEK or unwrap a cold\n * keyring — they can only re-open THIS session's cached DEKs.\n * 3. **TTL-bounded.** Every `PinResumeState` has an `expiresAt`. After\n * expiry, `resumePin()` throws; the user must re-enter the full\n * passphrase.\n * 4. **Attempt-bounded.** After `maxAttempts` wrong PINs, the state\n * refuses further attempts until re-enrolment.\n * 5. **Memory-scoped by convention.** The caller is responsible for\n * storing the `PinResumeState` appropriately — ideally in memory\n * (lost when the process exits). Writing it to `localStorage` is\n * allowed but defeats the short-lived-session property, so it is\n * flagged here as a design decision the caller owns.\n *\n * ## Limits (read before shipping)\n *\n * - The `attempts` counter lives inside the `PinResumeState` object.\n * An attacker with a stale copy of the state can \"reset\" attempts\n * by reverting their copy. Real lockout enforcement needs a trusted\n * counter (server-side or OS secure enclave). Document this to\n * consumers.\n * - Offline brute-force is bounded by PBKDF2 cost + the secrecy of the\n * state blob. Do not persist the state to a public location.\n * - A 4-digit numeric PIN has only 10,000 possibilities. With 100k\n * PBKDF2 iterations each, a GPU attacker needs ~10^9 hash ops to\n * exhaust the space — roughly hours. Combined with the short TTL\n * and the attempts counter, this is acceptable for UX convenience\n * but NOT for primary authentication.\n *\n * ## API shape (mirrors @noy-db/on-* siblings)\n *\n * ```ts\n * import { enrollPin, resumePin } from '@noy-db/on-pin'\n *\n * // After the user has opened the vault with the full passphrase:\n * const state = await enrollPin(keyring, { pin: '1234', ttlMs: 15 * 60 * 1000 })\n * // Keep `state` in memory. Do not write it anywhere durable.\n *\n * // Later, when session resumes:\n * const keyring = await resumePin(state, { pin: '1234' })\n * ```\n *\n * @packageDocumentation\n */\n\nimport type { Role, Permissions, UnlockedKeyring } from '@noy-db/hub'\n\n// ─── Constants ──────────────────────────────────────────────────────────\n\n/** Default TTL: 15 minutes. Short by design — PIN resumes, doesn't replace. */\nexport const PIN_DEFAULT_TTL_MS = 15 * 60 * 1000\n\n/** Default max attempts before state refuses further unlock. */\nexport const PIN_DEFAULT_MAX_ATTEMPTS = 5\n\n/**\n * PBKDF2 iteration count for the PIN. Lower than the 600k used for\n * passphrase KEK derivation because (a) the window is short, (b) the\n * attempt counter bounds online attacks, (c) the state is not\n * persisted in a public location. Do not lower this further without\n * also raising attempt-counter rigour.\n */\nexport const PIN_PBKDF2_ITERATIONS = 100_000\n\n// ─── Errors ─────────────────────────────────────────────────────────────\n\nexport class PinInvalidError extends Error {\n readonly code = 'PIN_INVALID' as const\n constructor(message = 'PIN is incorrect.') {\n super(message)\n this.name = 'PinInvalidError'\n }\n}\n\nexport class PinExpiredError extends Error {\n readonly code = 'PIN_EXPIRED' as const\n constructor(message = 'PIN resume window has expired; re-enter full passphrase.') {\n super(message)\n this.name = 'PinExpiredError'\n }\n}\n\nexport class PinAttemptsExceededError extends Error {\n readonly code = 'PIN_ATTEMPTS_EXCEEDED' as const\n constructor(message = 'Too many wrong PIN attempts; re-enter full passphrase.') {\n super(message)\n this.name = 'PinAttemptsExceededError'\n }\n}\n\nexport class PinEnrollmentError extends Error {\n readonly code = 'PIN_ENROLLMENT_FAILED' as const\n constructor(message = 'PIN enrolment failed.') {\n super(message)\n this.name = 'PinEnrollmentError'\n }\n}\n\n// ─── Types ──────────────────────────────────────────────────────────────\n\n/**\n * Opaque serializable state produced by `enrollPin()`. Hand it to\n * `resumePin()` to unlock. Callers keep this in memory (not on disk /\n * sessionStorage in general) per the security model above.\n *\n * `attempts` is the only mutable field; incremented on wrong-PIN\n * failures. Callers should treat the rest as immutable.\n */\nexport interface PinResumeState {\n /** Schema marker. */\n readonly _noydb_on_pin: 1\n /** Base64 PBKDF2 salt (32 random bytes). */\n readonly salt: string\n /** Base64 AES-GCM IV (12 random bytes) used to encrypt the wrapped payload. */\n readonly iv: string\n /** Base64 AES-GCM ciphertext — serialized keyring wrapped with the PIN-derived key. */\n readonly wrappedKeyring: string\n /** ISO-8601 timestamp after which `resumePin()` refuses. */\n readonly expiresAt: string\n /** Mutable counter — incremented on each wrong-PIN attempt. */\n attempts: number\n /** Upper bound; when `attempts >= maxAttempts`, resume throws. */\n readonly maxAttempts: number\n}\n\nexport interface EnrollPinOptions {\n /** The short secret. Typically 4–6 digits, but any string works. */\n readonly pin: string\n /** Resume window length. Default: 15 minutes. */\n readonly ttlMs?: number\n /** Max wrong-PIN attempts before the state is dead. Default: 5. */\n readonly maxAttempts?: number\n}\n\nexport interface ResumePinOptions {\n readonly pin: string\n}\n\n// ─── Implementation ─────────────────────────────────────────────────────\n\n/**\n * Enrol a PIN for session-resume against an already-unlocked keyring.\n *\n * Requires the keyring's DEKs to be extractable (`crypto.subtle.exportKey('raw', dek)`\n * must succeed). The hub creates DEKs with `extractable: true` by default.\n *\n * @throws `PinEnrollmentError` if any DEK is non-extractable.\n */\nexport async function enrollPin(\n keyring: UnlockedKeyring,\n options: EnrollPinOptions,\n): Promise<PinResumeState> {\n const ttlMs = options.ttlMs ?? PIN_DEFAULT_TTL_MS\n const maxAttempts = options.maxAttempts ?? PIN_DEFAULT_MAX_ATTEMPTS\n\n const salt = crypto.getRandomValues(new Uint8Array(32))\n const iv = crypto.getRandomValues(new Uint8Array(12))\n\n const wrappingKey = await deriveWrappingKey(options.pin, salt)\n\n let serialized: Uint8Array\n try {\n serialized = await serializeKeyring(keyring)\n } catch (err) {\n throw new PinEnrollmentError(\n 'Failed to serialize keyring — DEK not extractable. ' +\n `Underlying error: ${err instanceof Error ? err.message : String(err)}`,\n )\n }\n\n const ciphertext = await crypto.subtle.encrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n wrappingKey,\n serialized as BufferSource,\n )\n\n return {\n _noydb_on_pin: 1,\n salt: bytesToBase64(salt),\n iv: bytesToBase64(iv),\n wrappedKeyring: bytesToBase64(new Uint8Array(ciphertext)),\n expiresAt: new Date(Date.now() + ttlMs).toISOString(),\n attempts: 0,\n maxAttempts,\n }\n}\n\n/**\n * Resume a session from a previously-enrolled `PinResumeState`.\n *\n * The returned keyring has `kek: null` — PIN resume does NOT reconstruct\n * the KEK (by design). The DEKs are sufficient for normal reads and\n * writes; operations that require a KEK (opening additional vaults,\n * re-enrolling, key rotation) still need the full passphrase flow.\n *\n * @throws `PinExpiredError` if the resume window has elapsed.\n * @throws `PinAttemptsExceededError` if `attempts >= maxAttempts`.\n * @throws `PinInvalidError` if the PIN is wrong (state.attempts incremented).\n */\nexport async function resumePin(\n state: PinResumeState,\n options: ResumePinOptions,\n): Promise<UnlockedKeyring> {\n if (Date.now() > new Date(state.expiresAt).getTime()) {\n throw new PinExpiredError()\n }\n if (state.attempts >= state.maxAttempts) {\n throw new PinAttemptsExceededError()\n }\n\n const salt = base64ToBytes(state.salt)\n const iv = base64ToBytes(state.iv)\n const ciphertext = base64ToBytes(state.wrappedKeyring)\n\n const wrappingKey = await deriveWrappingKey(options.pin, salt)\n\n let plaintext: ArrayBuffer\n try {\n plaintext = await crypto.subtle.decrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n wrappingKey,\n ciphertext as BufferSource,\n )\n } catch {\n // AES-GCM auth failure. Increment the attempts counter before\n // throwing so repeated wrong PINs progressively lock the state.\n state.attempts = state.attempts + 1\n throw new PinInvalidError()\n }\n\n return deserializeKeyring(new Uint8Array(plaintext))\n}\n\n/** Fast TTL check without attempting decrypt. */\nexport function isPinStateValid(state: PinResumeState): boolean {\n return (\n Date.now() <= new Date(state.expiresAt).getTime() &&\n state.attempts < state.maxAttempts\n )\n}\n\n/**\n * Zero the state in place. After this, `resumePin()` will fail.\n * Use on explicit logout.\n */\nexport function clearPinState(state: PinResumeState): void {\n // Overwrite the attempts counter past the max + expire the state.\n ;(state as { attempts: number }).attempts = state.maxAttempts\n ;(state as { expiresAt: string }).expiresAt = new Date(0).toISOString()\n ;(state as { wrappedKeyring: string }).wrappedKeyring = ''\n}\n\n// ─── Helpers ────────────────────────────────────────────────────────────\n\nasync function deriveWrappingKey(\n pin: string,\n salt: Uint8Array,\n): Promise<CryptoKey> {\n const ikm = await crypto.subtle.importKey(\n 'raw',\n new TextEncoder().encode(pin),\n 'PBKDF2',\n false,\n ['deriveKey'],\n )\n return crypto.subtle.deriveKey(\n {\n name: 'PBKDF2',\n salt: salt as BufferSource,\n iterations: PIN_PBKDF2_ITERATIONS,\n hash: 'SHA-256',\n },\n ikm,\n { name: 'AES-GCM', length: 256 },\n false,\n ['encrypt', 'decrypt'],\n )\n}\n\ninterface SerializedKeyring {\n userId: string\n displayName: string\n role: Role\n permissions: Permissions\n salt: string\n deks: Record<string, string>\n}\n\nasync function serializeKeyring(k: UnlockedKeyring): Promise<Uint8Array> {\n const deks: Record<string, string> = {}\n for (const [collection, key] of k.deks) {\n const raw = await crypto.subtle.exportKey('raw', key)\n deks[collection] = bytesToBase64(new Uint8Array(raw))\n }\n const json: SerializedKeyring = {\n userId: k.userId,\n displayName: k.displayName,\n role: k.role,\n permissions: k.permissions,\n salt: bytesToBase64(k.salt),\n deks,\n }\n return new TextEncoder().encode(JSON.stringify(json))\n}\n\nasync function deserializeKeyring(bytes: Uint8Array): Promise<UnlockedKeyring> {\n const parsed = JSON.parse(new TextDecoder().decode(bytes)) as SerializedKeyring\n const deks = new Map<string, CryptoKey>()\n for (const [coll, b64] of Object.entries(parsed.deks)) {\n const raw = base64ToBytes(b64)\n const key = await crypto.subtle.importKey(\n 'raw',\n raw as BufferSource,\n { name: 'AES-GCM' },\n true,\n ['encrypt', 'decrypt'],\n )\n deks.set(coll, key)\n }\n return {\n userId: parsed.userId,\n displayName: parsed.displayName,\n role: parsed.role,\n permissions: parsed.permissions,\n salt: base64ToBytes(parsed.salt),\n deks,\n // KEK is deliberately null — PIN-resume returns a keyring that can\n // read/write but cannot open additional vaults or rotate keys.\n kek: null,\n authenticators: [],\n }\n}\n\nfunction bytesToBase64(bytes: Uint8Array): string {\n let s = ''\n for (const b of bytes) s += String.fromCharCode(b)\n return btoa(s)\n}\n\nfunction base64ToBytes(b64: string): Uint8Array {\n const s = atob(b64)\n const out = new Uint8Array(s.length)\n for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i)\n return out\n}\n"],"mappings":"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","names":[]}
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/on-pin** — session-resume PIN quick-lock for noy-db.\n *\n * The use case: after the user unlocks a vault with the full passphrase,\n * the session goes idle (screen lock, tab switch). Instead of re-entering\n * the full passphrase, the user types a 4–6 digit PIN (or taps their\n * device biometric) to **resume the already-open session**.\n *\n * ## What this is NOT\n *\n * This is **NOT** a passphrase replacement. If the vault is cold-started\n * (fresh app launch, no prior unlock), a PIN alone cannot open it — the\n * KEK must be re-derived from the real passphrase via PBKDF2-600K.\n *\n * ## Security model\n *\n * 1. **PIN never derives the KEK.** The PIN derives a transient wrapping\n * key via PBKDF2 (100k iterations, not 600k — the protection window is\n * short, so fewer iterations are acceptable).\n * 2. **The transient key wraps only the DEKs.** A `PinResumeState` carries\n * the encrypted DEK map but NOT the KEK. Even if the PIN is\n * compromised, an attacker cannot re-derive the KEK or unwrap a cold\n * keyring — they can only re-open THIS session's cached DEKs.\n * 3. **TTL-bounded.** Every `PinResumeState` has an `expiresAt`. After\n * expiry, `resumePin()` throws; the user must re-enter the full\n * passphrase.\n * 4. **Attempt-bounded.** After `maxAttempts` wrong PINs, the state\n * refuses further attempts until re-enrolment.\n * 5. **Memory-scoped by convention.** The caller is responsible for\n * storing the `PinResumeState` appropriately — ideally in memory\n * (lost when the process exits). Writing it to `localStorage` is\n * allowed but defeats the short-lived-session property, so it is\n * flagged here as a design decision the caller owns.\n *\n * ## Limits (read before shipping)\n *\n * - The `attempts` counter lives inside the `PinResumeState` object.\n * An attacker with a stale copy of the state can \"reset\" attempts\n * by reverting their copy. Real lockout enforcement needs a trusted\n * counter (server-side or OS secure enclave). Document this to\n * consumers.\n * - Offline brute-force is bounded by PBKDF2 cost + the secrecy of the\n * state blob. Do not persist the state to a public location.\n * - A 4-digit numeric PIN has only 10,000 possibilities. With 100k\n * PBKDF2 iterations each, a GPU attacker needs ~10^9 hash ops to\n * exhaust the space — roughly hours. Combined with the short TTL\n * and the attempts counter, this is acceptable for UX convenience\n * but NOT for primary authentication.\n *\n * ## API shape (mirrors @noy-db/on-* siblings)\n *\n * ```ts\n * import { enrollPin, resumePin } from '@noy-db/on-pin'\n *\n * // After the user has opened the vault with the full passphrase:\n * const state = await enrollPin(keyring, { pin: '1234', ttlMs: 15 * 60 * 1000 })\n * // Keep `state` in memory. Do not write it anywhere durable.\n *\n * // Later, when session resumes:\n * const keyring = await resumePin(state, { pin: '1234' })\n * ```\n *\n * @packageDocumentation\n */\n\nimport type { Role, Permissions, UnlockedKeyring } from '@noy-db/hub'\n\n// ─── Constants ──────────────────────────────────────────────────────────\n\n/** Default TTL: 15 minutes. Short by design — PIN resumes, doesn't replace. */\nexport const PIN_DEFAULT_TTL_MS = 15 * 60 * 1000\n\n/** Default max attempts before state refuses further unlock. */\nexport const PIN_DEFAULT_MAX_ATTEMPTS = 5\n\n/**\n * PBKDF2 iteration count for the PIN. Lower than the 600k used for\n * passphrase KEK derivation because (a) the window is short, (b) the\n * attempt counter bounds online attacks, (c) the state is not\n * persisted in a public location. Do not lower this further without\n * also raising attempt-counter rigour.\n */\nexport const PIN_PBKDF2_ITERATIONS = 100_000\n\n// ─── Errors ─────────────────────────────────────────────────────────────\n\nexport class PinInvalidError extends Error {\n readonly code = 'PIN_INVALID' as const\n constructor(message = 'PIN is incorrect.') {\n super(message)\n this.name = 'PinInvalidError'\n }\n}\n\nexport class PinExpiredError extends Error {\n readonly code = 'PIN_EXPIRED' as const\n constructor(message = 'PIN resume window has expired; re-enter full passphrase.') {\n super(message)\n this.name = 'PinExpiredError'\n }\n}\n\nexport class PinAttemptsExceededError extends Error {\n readonly code = 'PIN_ATTEMPTS_EXCEEDED' as const\n constructor(message = 'Too many wrong PIN attempts; re-enter full passphrase.') {\n super(message)\n this.name = 'PinAttemptsExceededError'\n }\n}\n\nexport class PinEnrollmentError extends Error {\n readonly code = 'PIN_ENROLLMENT_FAILED' as const\n constructor(message = 'PIN enrolment failed.') {\n super(message)\n this.name = 'PinEnrollmentError'\n }\n}\n\n// ─── Types ──────────────────────────────────────────────────────────────\n\n/**\n * Opaque serializable state produced by `enrollPin()`. Hand it to\n * `resumePin()` to unlock. Callers keep this in memory (not on disk /\n * sessionStorage in general) per the security model above.\n *\n * `attempts` is the only mutable field; incremented on wrong-PIN\n * failures. Callers should treat the rest as immutable.\n */\nexport interface PinResumeState {\n /** Schema marker. */\n readonly _noydb_on_pin: 1\n /** Base64 PBKDF2 salt (32 random bytes). */\n readonly salt: string\n /** Base64 AES-GCM IV (12 random bytes) used to encrypt the wrapped payload. */\n readonly iv: string\n /** Base64 AES-GCM ciphertext — serialized keyring wrapped with the PIN-derived key. */\n readonly wrappedKeyring: string\n /** ISO-8601 timestamp after which `resumePin()` refuses. */\n readonly expiresAt: string\n /** Mutable counter — incremented on each wrong-PIN attempt. */\n attempts: number\n /** Upper bound; when `attempts >= maxAttempts`, resume throws. */\n readonly maxAttempts: number\n}\n\nexport interface EnrollPinOptions {\n /** The short secret. Typically 4–6 digits, but any string works. */\n readonly pin: string\n /** Resume window length. Default: 15 minutes. */\n readonly ttlMs?: number\n /** Max wrong-PIN attempts before the state is dead. Default: 5. */\n readonly maxAttempts?: number\n}\n\nexport interface ResumePinOptions {\n readonly pin: string\n}\n\n// ─── Implementation ─────────────────────────────────────────────────────\n\n/**\n * Enrol a PIN for session-resume against an already-unlocked keyring.\n *\n * Requires the keyring's DEKs to be extractable (`crypto.subtle.exportKey('raw', dek)`\n * must succeed). The hub creates DEKs with `extractable: true` by default.\n *\n * @throws `PinEnrollmentError` if any DEK is non-extractable.\n */\nexport async function enrollPin(\n keyring: UnlockedKeyring,\n options: EnrollPinOptions,\n): Promise<PinResumeState> {\n const ttlMs = options.ttlMs ?? PIN_DEFAULT_TTL_MS\n const maxAttempts = options.maxAttempts ?? PIN_DEFAULT_MAX_ATTEMPTS\n\n const salt = crypto.getRandomValues(new Uint8Array(32))\n const iv = crypto.getRandomValues(new Uint8Array(12))\n\n const wrappingKey = await deriveWrappingKey(options.pin, salt)\n\n let serialized: Uint8Array\n try {\n serialized = await serializeKeyring(keyring)\n } catch (err) {\n throw new PinEnrollmentError(\n 'Failed to serialize keyring — DEK not extractable. ' +\n `Underlying error: ${err instanceof Error ? err.message : String(err)}`,\n )\n }\n\n const ciphertext = await crypto.subtle.encrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n wrappingKey,\n serialized as BufferSource,\n )\n\n return {\n _noydb_on_pin: 1,\n salt: bytesToBase64(salt),\n iv: bytesToBase64(iv),\n wrappedKeyring: bytesToBase64(new Uint8Array(ciphertext)),\n expiresAt: new Date(Date.now() + ttlMs).toISOString(),\n attempts: 0,\n maxAttempts,\n }\n}\n\n/**\n * Resume a session from a previously-enrolled `PinResumeState`.\n *\n * The returned keyring has `kek: null` — PIN resume does NOT reconstruct\n * the KEK (by design). The DEKs are sufficient for normal reads and\n * writes; operations that require a KEK (opening additional vaults,\n * re-enrolling, key rotation) still need the full passphrase flow.\n *\n * @throws `PinExpiredError` if the resume window has elapsed.\n * @throws `PinAttemptsExceededError` if `attempts >= maxAttempts`.\n * @throws `PinInvalidError` if the PIN is wrong (state.attempts incremented).\n */\nexport async function resumePin(\n state: PinResumeState,\n options: ResumePinOptions,\n): Promise<UnlockedKeyring> {\n if (Date.now() > new Date(state.expiresAt).getTime()) {\n throw new PinExpiredError()\n }\n if (state.attempts >= state.maxAttempts) {\n throw new PinAttemptsExceededError()\n }\n\n const salt = base64ToBytes(state.salt)\n const iv = base64ToBytes(state.iv)\n const ciphertext = base64ToBytes(state.wrappedKeyring)\n\n const wrappingKey = await deriveWrappingKey(options.pin, salt)\n\n let plaintext: ArrayBuffer\n try {\n plaintext = await crypto.subtle.decrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n wrappingKey,\n ciphertext as BufferSource,\n )\n } catch {\n // AES-GCM auth failure. Increment the attempts counter before\n // throwing so repeated wrong PINs progressively lock the state.\n state.attempts = state.attempts + 1\n throw new PinInvalidError()\n }\n\n return deserializeKeyring(new Uint8Array(plaintext))\n}\n\n/** Fast TTL check without attempting decrypt. */\nexport function isPinStateValid(state: PinResumeState): boolean {\n return (\n Date.now() <= new Date(state.expiresAt).getTime() &&\n state.attempts < state.maxAttempts\n )\n}\n\n/**\n * Zero the state in place. After this, `resumePin()` will fail.\n * Use on explicit logout.\n */\nexport function clearPinState(state: PinResumeState): void {\n // Overwrite the attempts counter past the max + expire the state.\n ;(state as { attempts: number }).attempts = state.maxAttempts\n ;(state as { expiresAt: string }).expiresAt = new Date(0).toISOString()\n ;(state as { wrappedKeyring: string }).wrappedKeyring = ''\n}\n\n// ─── Helpers ────────────────────────────────────────────────────────────\n\nasync function deriveWrappingKey(\n pin: string,\n salt: Uint8Array,\n): Promise<CryptoKey> {\n const ikm = await crypto.subtle.importKey(\n 'raw',\n new TextEncoder().encode(pin),\n 'PBKDF2',\n false,\n ['deriveKey'],\n )\n return crypto.subtle.deriveKey(\n {\n name: 'PBKDF2',\n salt: salt as BufferSource,\n iterations: PIN_PBKDF2_ITERATIONS,\n hash: 'SHA-256',\n },\n ikm,\n { name: 'AES-GCM', length: 256 },\n false,\n ['encrypt', 'decrypt'],\n )\n}\n\ninterface SerializedKeyring {\n userId: string\n displayName: string\n role: Role\n permissions: Permissions\n salt: string\n deks: Record<string, string>\n}\n\nasync function serializeKeyring(k: UnlockedKeyring): Promise<Uint8Array> {\n const deks: Record<string, string> = {}\n for (const [collection, key] of k.deks) {\n const raw = await crypto.subtle.exportKey('raw', key)\n deks[collection] = bytesToBase64(new Uint8Array(raw))\n }\n const json: SerializedKeyring = {\n userId: k.userId,\n displayName: k.displayName,\n role: k.role,\n permissions: k.permissions,\n salt: bytesToBase64(k.salt),\n deks,\n }\n return new TextEncoder().encode(JSON.stringify(json))\n}\n\nasync function deserializeKeyring(bytes: Uint8Array): Promise<UnlockedKeyring> {\n const parsed = JSON.parse(new TextDecoder().decode(bytes)) as SerializedKeyring\n const deks = new Map<string, CryptoKey>()\n for (const [coll, b64] of Object.entries(parsed.deks)) {\n const raw = base64ToBytes(b64)\n const key = await crypto.subtle.importKey(\n 'raw',\n raw as BufferSource,\n { name: 'AES-GCM' },\n true,\n ['encrypt', 'decrypt'],\n )\n deks.set(coll, key)\n }\n return {\n userId: parsed.userId,\n displayName: parsed.displayName,\n role: parsed.role,\n permissions: parsed.permissions,\n salt: base64ToBytes(parsed.salt),\n deks,\n // KEK is deliberately null — PIN-resume returns a keyring that can\n // read/write but cannot open additional vaults or rotate keys.\n kek: null as unknown as CryptoKey,\n authenticators: [],\n }\n}\n\nfunction bytesToBase64(bytes: Uint8Array): string {\n let s = ''\n for (const b of bytes) s += String.fromCharCode(b)\n return btoa(s)\n}\n\nfunction base64ToBytes(b64: string): Uint8Array {\n const s = atob(b64)\n const out = new Uint8Array(s.length)\n for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i)\n return out\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/on-pin** — session-resume PIN quick-lock for noy-db.\n *\n * The use case: after the user unlocks a vault with the full passphrase,\n * the session goes idle (screen lock, tab switch). Instead of re-entering\n * the full passphrase, the user types a 4–6 digit PIN (or taps their\n * device biometric) to **resume the already-open session**.\n *\n * ## What this is NOT\n *\n * This is **NOT** a passphrase replacement. If the vault is cold-started\n * (fresh app launch, no prior unlock), a PIN alone cannot open it — the\n * KEK must be re-derived from the real passphrase via PBKDF2-600K.\n *\n * ## Security model\n *\n * 1. **PIN never derives the KEK.** The PIN derives a transient wrapping\n * key via PBKDF2 (100k iterations, not 600k — the protection window is\n * short, so fewer iterations are acceptable).\n * 2. **The transient key wraps only the DEKs.** A `PinResumeState` carries\n * the encrypted DEK map but NOT the KEK. Even if the PIN is\n * compromised, an attacker cannot re-derive the KEK or unwrap a cold\n * keyring — they can only re-open THIS session's cached DEKs.\n * 3. **TTL-bounded.** Every `PinResumeState` has an `expiresAt`. After\n * expiry, `resumePin()` throws; the user must re-enter the full\n * passphrase.\n * 4. **Attempt-bounded.** After `maxAttempts` wrong PINs, the state\n * refuses further attempts until re-enrolment.\n * 5. **Memory-scoped by convention.** The caller is responsible for\n * storing the `PinResumeState` appropriately — ideally in memory\n * (lost when the process exits). Writing it to `localStorage` is\n * allowed but defeats the short-lived-session property, so it is\n * flagged here as a design decision the caller owns.\n *\n * ## Limits (read before shipping)\n *\n * - The `attempts` counter lives inside the `PinResumeState` object.\n * An attacker with a stale copy of the state can \"reset\" attempts\n * by reverting their copy. Real lockout enforcement needs a trusted\n * counter (server-side or OS secure enclave). Document this to\n * consumers.\n * - Offline brute-force is bounded by PBKDF2 cost + the secrecy of the\n * state blob. Do not persist the state to a public location.\n * - A 4-digit numeric PIN has only 10,000 possibilities. With 100k\n * PBKDF2 iterations each, a GPU attacker needs ~10^9 hash ops to\n * exhaust the space — roughly hours. Combined with the short TTL\n * and the attempts counter, this is acceptable for UX convenience\n * but NOT for primary authentication.\n *\n * ## API shape (mirrors @noy-db/on-* siblings)\n *\n * ```ts\n * import { enrollPin, resumePin } from '@noy-db/on-pin'\n *\n * // After the user has opened the vault with the full passphrase:\n * const state = await enrollPin(keyring, { pin: '1234', ttlMs: 15 * 60 * 1000 })\n * // Keep `state` in memory. Do not write it anywhere durable.\n *\n * // Later, when session resumes:\n * const keyring = await resumePin(state, { pin: '1234' })\n * ```\n *\n * @packageDocumentation\n */\n\nimport type { Role, Permissions, UnlockedKeyring } from '@noy-db/hub'\n\n// ─── Constants ──────────────────────────────────────────────────────────\n\n/** Default TTL: 15 minutes. Short by design — PIN resumes, doesn't replace. */\nexport const PIN_DEFAULT_TTL_MS = 15 * 60 * 1000\n\n/** Default max attempts before state refuses further unlock. */\nexport const PIN_DEFAULT_MAX_ATTEMPTS = 5\n\n/**\n * PBKDF2 iteration count for the PIN. Lower than the 600k used for\n * passphrase KEK derivation because (a) the window is short, (b) the\n * attempt counter bounds online attacks, (c) the state is not\n * persisted in a public location. Do not lower this further without\n * also raising attempt-counter rigour.\n */\nexport const PIN_PBKDF2_ITERATIONS = 100_000\n\n// ─── Errors ─────────────────────────────────────────────────────────────\n\nexport class PinInvalidError extends Error {\n readonly code = 'PIN_INVALID' as const\n constructor(message = 'PIN is incorrect.') {\n super(message)\n this.name = 'PinInvalidError'\n }\n}\n\nexport class PinExpiredError extends Error {\n readonly code = 'PIN_EXPIRED' as const\n constructor(message = 'PIN resume window has expired; re-enter full passphrase.') {\n super(message)\n this.name = 'PinExpiredError'\n }\n}\n\nexport class PinAttemptsExceededError extends Error {\n readonly code = 'PIN_ATTEMPTS_EXCEEDED' as const\n constructor(message = 'Too many wrong PIN attempts; re-enter full passphrase.') {\n super(message)\n this.name = 'PinAttemptsExceededError'\n }\n}\n\nexport class PinEnrollmentError extends Error {\n readonly code = 'PIN_ENROLLMENT_FAILED' as const\n constructor(message = 'PIN enrolment failed.') {\n super(message)\n this.name = 'PinEnrollmentError'\n }\n}\n\n// ─── Types ──────────────────────────────────────────────────────────────\n\n/**\n * Opaque serializable state produced by `enrollPin()`. Hand it to\n * `resumePin()` to unlock. Callers keep this in memory (not on disk /\n * sessionStorage in general) per the security model above.\n *\n * `attempts` is the only mutable field; incremented on wrong-PIN\n * failures. Callers should treat the rest as immutable.\n */\nexport interface PinResumeState {\n /** Schema marker. */\n readonly _noydb_on_pin: 1\n /** Base64 PBKDF2 salt (32 random bytes). */\n readonly salt: string\n /** Base64 AES-GCM IV (12 random bytes) used to encrypt the wrapped payload. */\n readonly iv: string\n /** Base64 AES-GCM ciphertext — serialized keyring wrapped with the PIN-derived key. */\n readonly wrappedKeyring: string\n /** ISO-8601 timestamp after which `resumePin()` refuses. */\n readonly expiresAt: string\n /** Mutable counter — incremented on each wrong-PIN attempt. */\n attempts: number\n /** Upper bound; when `attempts >= maxAttempts`, resume throws. */\n readonly maxAttempts: number\n}\n\nexport interface EnrollPinOptions {\n /** The short secret. Typically 4–6 digits, but any string works. */\n readonly pin: string\n /** Resume window length. Default: 15 minutes. */\n readonly ttlMs?: number\n /** Max wrong-PIN attempts before the state is dead. Default: 5. */\n readonly maxAttempts?: number\n}\n\nexport interface ResumePinOptions {\n readonly pin: string\n}\n\n// ─── Implementation ─────────────────────────────────────────────────────\n\n/**\n * Enrol a PIN for session-resume against an already-unlocked keyring.\n *\n * Requires the keyring's DEKs to be extractable (`crypto.subtle.exportKey('raw', dek)`\n * must succeed). The hub creates DEKs with `extractable: true` by default.\n *\n * @throws `PinEnrollmentError` if any DEK is non-extractable.\n */\nexport async function enrollPin(\n keyring: UnlockedKeyring,\n options: EnrollPinOptions,\n): Promise<PinResumeState> {\n const ttlMs = options.ttlMs ?? PIN_DEFAULT_TTL_MS\n const maxAttempts = options.maxAttempts ?? PIN_DEFAULT_MAX_ATTEMPTS\n\n const salt = crypto.getRandomValues(new Uint8Array(32))\n const iv = crypto.getRandomValues(new Uint8Array(12))\n\n const wrappingKey = await deriveWrappingKey(options.pin, salt)\n\n let serialized: Uint8Array\n try {\n serialized = await serializeKeyring(keyring)\n } catch (err) {\n throw new PinEnrollmentError(\n 'Failed to serialize keyring — DEK not extractable. ' +\n `Underlying error: ${err instanceof Error ? err.message : String(err)}`,\n )\n }\n\n const ciphertext = await crypto.subtle.encrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n wrappingKey,\n serialized as BufferSource,\n )\n\n return {\n _noydb_on_pin: 1,\n salt: bytesToBase64(salt),\n iv: bytesToBase64(iv),\n wrappedKeyring: bytesToBase64(new Uint8Array(ciphertext)),\n expiresAt: new Date(Date.now() + ttlMs).toISOString(),\n attempts: 0,\n maxAttempts,\n }\n}\n\n/**\n * Resume a session from a previously-enrolled `PinResumeState`.\n *\n * The returned keyring has `kek: null` — PIN resume does NOT reconstruct\n * the KEK (by design). The DEKs are sufficient for normal reads and\n * writes; operations that require a KEK (opening additional vaults,\n * re-enrolling, key rotation) still need the full passphrase flow.\n *\n * @throws `PinExpiredError` if the resume window has elapsed.\n * @throws `PinAttemptsExceededError` if `attempts >= maxAttempts`.\n * @throws `PinInvalidError` if the PIN is wrong (state.attempts incremented).\n */\nexport async function resumePin(\n state: PinResumeState,\n options: ResumePinOptions,\n): Promise<UnlockedKeyring> {\n if (Date.now() > new Date(state.expiresAt).getTime()) {\n throw new PinExpiredError()\n }\n if (state.attempts >= state.maxAttempts) {\n throw new PinAttemptsExceededError()\n }\n\n const salt = base64ToBytes(state.salt)\n const iv = base64ToBytes(state.iv)\n const ciphertext = base64ToBytes(state.wrappedKeyring)\n\n const wrappingKey = await deriveWrappingKey(options.pin, salt)\n\n let plaintext: ArrayBuffer\n try {\n plaintext = await crypto.subtle.decrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n wrappingKey,\n ciphertext as BufferSource,\n )\n } catch {\n // AES-GCM auth failure. Increment the attempts counter before\n // throwing so repeated wrong PINs progressively lock the state.\n state.attempts = state.attempts + 1\n throw new PinInvalidError()\n }\n\n return deserializeKeyring(new Uint8Array(plaintext))\n}\n\n/** Fast TTL check without attempting decrypt. */\nexport function isPinStateValid(state: PinResumeState): boolean {\n return (\n Date.now() <= new Date(state.expiresAt).getTime() &&\n state.attempts < state.maxAttempts\n )\n}\n\n/**\n * Zero the state in place. After this, `resumePin()` will fail.\n * Use on explicit logout.\n */\nexport function clearPinState(state: PinResumeState): void {\n // Overwrite the attempts counter past the max + expire the state.\n ;(state as { attempts: number }).attempts = state.maxAttempts\n ;(state as { expiresAt: string }).expiresAt = new Date(0).toISOString()\n ;(state as { wrappedKeyring: string }).wrappedKeyring = ''\n}\n\n// ─── Helpers ────────────────────────────────────────────────────────────\n\nasync function deriveWrappingKey(\n pin: string,\n salt: Uint8Array,\n): Promise<CryptoKey> {\n const ikm = await crypto.subtle.importKey(\n 'raw',\n new TextEncoder().encode(pin),\n 'PBKDF2',\n false,\n ['deriveKey'],\n )\n return crypto.subtle.deriveKey(\n {\n name: 'PBKDF2',\n salt: salt as BufferSource,\n iterations: PIN_PBKDF2_ITERATIONS,\n hash: 'SHA-256',\n },\n ikm,\n { name: 'AES-GCM', length: 256 },\n false,\n ['encrypt', 'decrypt'],\n )\n}\n\ninterface SerializedKeyring {\n userId: string\n displayName: string\n role: Role\n permissions: Permissions\n salt: string\n deks: Record<string, string>\n}\n\nasync function serializeKeyring(k: UnlockedKeyring): Promise<Uint8Array> {\n const deks: Record<string, string> = {}\n for (const [collection, key] of k.deks) {\n const raw = await crypto.subtle.exportKey('raw', key)\n deks[collection] = bytesToBase64(new Uint8Array(raw))\n }\n const json: SerializedKeyring = {\n userId: k.userId,\n displayName: k.displayName,\n role: k.role,\n permissions: k.permissions,\n salt: bytesToBase64(k.salt),\n deks,\n }\n return new TextEncoder().encode(JSON.stringify(json))\n}\n\nasync function deserializeKeyring(bytes: Uint8Array): Promise<UnlockedKeyring> {\n const parsed = JSON.parse(new TextDecoder().decode(bytes)) as SerializedKeyring\n const deks = new Map<string, CryptoKey>()\n for (const [coll, b64] of Object.entries(parsed.deks)) {\n const raw = base64ToBytes(b64)\n const key = await crypto.subtle.importKey(\n 'raw',\n raw as BufferSource,\n { name: 'AES-GCM' },\n true,\n ['encrypt', 'decrypt'],\n )\n deks.set(coll, key)\n }\n return {\n userId: parsed.userId,\n displayName: parsed.displayName,\n role: parsed.role,\n permissions: parsed.permissions,\n salt: base64ToBytes(parsed.salt),\n deks,\n // KEK is deliberately null — PIN-resume returns a keyring that can\n // read/write but cannot open additional vaults or rotate keys.\n kek: null,\n authenticators: [],\n }\n}\n\nfunction bytesToBase64(bytes: Uint8Array): string {\n let s = ''\n for (const b of bytes) s += String.fromCharCode(b)\n return btoa(s)\n}\n\nfunction base64ToBytes(b64: string): Uint8Array {\n const s = atob(b64)\n const out = new Uint8Array(s.length)\n for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i)\n return out\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noy-db/on-pin",
3
- "version": "0.1.0-pre.7",
3
+ "version": "0.1.0-pre.8",
4
4
  "description": "Session-resume PIN quick-lock for noy-db — after a full passphrase unlock, a short-lived PIN (or a per-device biometric) re-unlocks the cached DEKs without re-typing the passphrase. PIN never replaces the passphrase; only resumes an already-unlocked session.",
5
5
  "license": "MIT",
6
6
  "author": "vLannaAi <vicio@lanna.ai>",
@@ -39,10 +39,10 @@
39
39
  "node": ">=18.0.0"
40
40
  },
41
41
  "peerDependencies": {
42
- "@noy-db/hub": "0.1.0-pre.7"
42
+ "@noy-db/hub": "0.1.0-pre.8"
43
43
  },
44
44
  "devDependencies": {
45
- "@noy-db/hub": "0.1.0-pre.7"
45
+ "@noy-db/hub": "0.1.0-pre.8"
46
46
  },
47
47
  "keywords": [
48
48
  "noy-db",