@noy-db/on-webauthn 0.8.0 → 0.9.0

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.
package/README.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # @noy-db/on-webauthn
2
2
 
3
+ <!-- prose-preamble
4
+ // Bindings the illustrative blocks below elide — the reader's own storage and
5
+ // store choice. Typed on purpose: an `any` here would stop the blocks below
6
+ // checking anything.
7
+ import type { NoydbStore, UnlockedKeyring } from '@noy-db/hub'
8
+ import type { WebAuthnEnrollment } from '@noy-db/on-webauthn'
9
+ declare const store: NoydbStore
10
+ declare const keyring: UnlockedKeyring
11
+ declare function loadEnrollmentFromIDB(): Promise<WebAuthnEnrollment>
12
+ -->
13
+
3
14
  [![npm](https://img.shields.io/npm/v/%40noy-db/on-webauthn.svg)](https://www.npmjs.com/package/@noy-db/on-webauthn)
4
15
 
5
16
  > WebAuthn hardware-key keyrings for noy-db
@@ -39,6 +50,47 @@ The callback is invoked lazily on the first `openVault(name)` per vault and the
39
50
 
40
51
  For first-time bootstrap (no enrollment exists yet), open the vault with a secret, enroll WebAuthn from the unlocked keyring (`enrollWebAuthn(keyring, ...)`), persist the enrollment, then swap to `getKeyring` on subsequent sessions.
41
52
 
53
+ ## One credential across subdomains — the RP ID
54
+
55
+ A WebAuthn credential belongs to a **relying party ID**, and the browser
56
+ defaults that to the page's own hostname. Enrol on `app.example.com` and the
57
+ credential is invisible to `console.example.com`. To share one tap across
58
+ subdomains, enrol under the **registrable domain**:
59
+
60
+ ```ts
61
+ import { enrollWebAuthn, unlockWebAuthn } from '@noy-db/on-webauthn'
62
+
63
+ // Enrol once, under the parent domain — valid from any of its subdomains.
64
+ const enrollment = await enrollWebAuthn(keyring, 'company-a', {
65
+ rp: { id: 'example.com', name: 'Example' },
66
+ })
67
+
68
+ // The RP ID is recorded on the enrollment, so the assertion reuses it.
69
+ // No second place to keep in sync.
70
+ const unlocked = await unlockWebAuthn(enrollment)
71
+ ```
72
+
73
+ The RP ID must be a registrable suffix of the enrolling page's origin: from
74
+ `app.example.com` you may pass `example.com`, but not `example.org` and not
75
+ `com`. The browser rejects anything else with a `SecurityError`.
76
+
77
+ ⚠️ **Records enrolled before `0.9.0` carry no `rpId`**, and for those the
78
+ assertion sends none — preserving the old behaviour, where the browser
79
+ defaults it to the asserting page. That is correct for a single-hostname
80
+ deployment and wrong for a cross-subdomain one, so pass it explicitly when
81
+ upgrading such a record:
82
+
83
+ ```ts
84
+ import { unlockWebAuthn } from '@noy-db/on-webauthn'
85
+
86
+ const legacy = await loadEnrollmentFromIDB()
87
+ const unlocked = await unlockWebAuthn(legacy, { rpId: 'example.com' })
88
+ ```
89
+
90
+ The same `rpId` option is accepted by `webAuthnSlotRewrapCeremony`, whose slot
91
+ `meta` is written by hub and does not carry an RP ID today — so for the
92
+ ceremony path the option is currently the only route.
93
+
42
94
  ## Status
43
95
 
44
96
  **Pre-release** (`0.1.0-pre.1`). API may change before `1.0`.
package/dist/index.d.ts CHANGED
@@ -46,18 +46,18 @@ export { ValidationError } from '@noy-db/hub';
46
46
  * Enrollment flow
47
47
  * ───────────────
48
48
  * 1. User is already authenticated (secret or existing session).
49
- * 2. Call `enrollWebAuthn(keyring, options)`.
49
+ * 2. Call `enrollWebAuthn(keyring, vault, options?)`.
50
50
  * 3. WebAuthn credential is created; PRF or rawId-derived key wraps the KEK.
51
- * 4. Returns a `WebAuthnEnrollment` — persist this to the noy-db adapter
52
- * via `saveEnrollment()`, or store it yourself in any encrypted collection.
51
+ * 4. Returns a `WebAuthnEnrollment` — persist it yourself, in any collection
52
+ * or in IndexedDB. This package ships no storage helper.
53
53
  *
54
54
  * Unlock flow
55
55
  * ───────────
56
- * 1. Load the `WebAuthnEnrollment` via `loadEnrollment()`.
57
- * 2. Call `unlockWebAuthn(enrollment, keyring)` — triggers the WebAuthn
56
+ * 1. Load the `WebAuthnEnrollment` back from wherever you persisted it.
57
+ * 2. Call `unlockWebAuthn(enrollment, options?)` — triggers the WebAuthn
58
58
  * assertion prompt.
59
- * 3. On success, returns the unwrapped `CryptoKey` (the KEK) — use it to
60
- * re-hydrate the session via `createSession()`.
59
+ * 3. On success, returns an `UnlockedKeyring`. Pass it to `createNoydb` via
60
+ * the `getKeyring` callback; see this package's README.
61
61
  */
62
62
 
63
63
  /**
@@ -139,6 +139,21 @@ interface WebAuthnEnrollment {
139
139
  readonly wrapIv: string;
140
140
  /** ISO timestamp of enrollment. */
141
141
  readonly enrolledAt: string;
142
+ /**
143
+ * The RP ID this credential was created under.
144
+ *
145
+ * ⚠️ OPTIONAL, and absence is not a default — records written before
146
+ * 0.9.0 do not carry it, and for those `unlockWebAuthn` sends no `rpId`,
147
+ * leaving the browser to default it to the asserting page's hostname.
148
+ * That is the old behaviour, preserved deliberately: a record enrolled
149
+ * on `app.example.com` must keep unlocking there.
150
+ *
151
+ * When present it is sent back as `publicKey.rpId` at assertion time, so
152
+ * enrolment and assertion cannot disagree — which is the whole point:
153
+ * enrol under the registrable domain (`rp: { id: 'example.com' }`) and
154
+ * every subdomain asserts against the same credential.
155
+ */
156
+ readonly rpId?: string;
142
157
  }
143
158
  /** Options for `enrollWebAuthn()`. */
144
159
  interface WebAuthnEnrollOptions {
@@ -182,6 +197,15 @@ interface WebAuthnEnrollOptions {
182
197
  interface WebAuthnUnlockOptions {
183
198
  /** WebAuthn timeout in milliseconds. Default: 60_000. */
184
199
  timeout?: number;
200
+ /**
201
+ * RP ID to assert against. Overrides the one recorded at enrolment.
202
+ *
203
+ * Needed when the record predates `WebAuthnEnrollment.rpId`, or when the
204
+ * slot's `meta` carries none — without it the browser defaults the RP ID
205
+ * to the asserting page's hostname, so a credential enrolled under
206
+ * `example.com` is invisible to `app.example.com`.
207
+ */
208
+ rpId?: string;
185
209
  /**
186
210
  * Only consulted by `webAuthnSlotRewrapCeremony()` — `unlockWebAuthn()`
187
211
  * ignores it, since unlocking an existing non-PRF record is always
package/dist/index.js CHANGED
@@ -213,7 +213,8 @@ async function enrollWebAuthn(keyring, vault, options = {}) {
213
213
  requireSingleDevice: options.requireSingleDevice ?? false,
214
214
  wrappedPayload,
215
215
  wrapIv,
216
- enrolledAt: (/* @__PURE__ */ new Date()).toISOString()
216
+ enrolledAt: (/* @__PURE__ */ new Date()).toISOString(),
217
+ rpId
217
218
  };
218
219
  }
219
220
  async function unlockWebAuthn(enrollment, options = {}) {
@@ -222,6 +223,7 @@ async function unlockWebAuthn(enrollment, options = {}) {
222
223
  }
223
224
  const timeout = options.timeout ?? 6e4;
224
225
  const credentialId = base64ToBuffer(enrollment.credentialId);
226
+ const rpId = options.rpId ?? enrollment.rpId;
225
227
  const extensionsInput = enrollment.prfUsed ? { prf: { eval: { first: PRF_SALT } } } : {};
226
228
  const assertion = await navigator.credentials.get({
227
229
  publicKey: {
@@ -229,7 +231,8 @@ async function unlockWebAuthn(enrollment, options = {}) {
229
231
  allowCredentials: [{ type: "public-key", id: credentialId }],
230
232
  userVerification: "required",
231
233
  extensions: extensionsInput,
232
- timeout
234
+ timeout,
235
+ ...rpId !== void 0 ? { rpId } : {}
233
236
  }
234
237
  });
235
238
  if (!assertion) {
@@ -286,6 +289,7 @@ async function webAuthnSlotRewrapCeremony(ctx, options = {}) {
286
289
  }
287
290
  const credentialIdBuf = base64ToBuffer(meta.credentialId);
288
291
  const timeout = options.timeout ?? 6e4;
292
+ const rpId = options.rpId ?? (typeof meta.rpId === "string" && meta.rpId.length > 0 ? meta.rpId : void 0);
289
293
  const extensionsInput = prfUsed ? { prf: { eval: { first: PRF_SALT } } } : {};
290
294
  const assertion = await navigator.credentials.get({
291
295
  publicKey: {
@@ -293,7 +297,8 @@ async function webAuthnSlotRewrapCeremony(ctx, options = {}) {
293
297
  allowCredentials: [{ type: "public-key", id: credentialIdBuf }],
294
298
  userVerification: "required",
295
299
  extensions: extensionsInput,
296
- timeout
300
+ timeout,
301
+ ...rpId !== void 0 ? { rpId } : {}
297
302
  }
298
303
  });
299
304
  if (!assertion) {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * @noy-db/on-webauthn —\n *\n * Hardware-key keyring for noy-db using the WebAuthn API.\n *\n * Covers every form factor:\n * - Platform authenticators: Touch ID, Face ID, Windows Hello, Android biometric\n * - Roaming authenticators: YubiKey (5C NFC, Bio), SoloKey, Titan, any FIDO2 key\n * - Passkey-capable platform authenticators: iCloud Keychain, Google Password Manager\n *\n * Key derivation model\n * ────────────────────\n * This package uses the **PRF (Pseudo-Random Function) extension** when\n * available to derive a deterministic wrapping key from the WebAuthn\n * credential. The PRF output is consistent across assertions on the same\n * device/credential, enabling unlock-without-secret while keeping the\n * derived key bound to the physical authenticator.\n *\n * When PRF is not supported by the authenticator (common on older hardware),\n * the package can fall back to HKDF-SHA256 over the credential's `rawId` —\n * but by default, enrollment refuses this path because the `rawId` is stored\n * in the record itself, making the record self-decrypting (presence gate, not\n * confidentiality). Use `allowNonPrfInsecure: true` to opt in; prefer a\n * PRF-capable device or the secret for real confidentiality.\n *\n * The derived key is NEVER persisted. It exists only in memory during the\n * unlock operation. What IS persisted (in the noy-db adapter, not in browser\n * storage) is the wrapped KEK: `encrypt(KEK, derivedKey)`.\n *\n * BE-flag guards\n * ──────────────\n * The backup-eligibility (BE) flag in a WebAuthn authenticator data signals\n * that the credential is (or can be) synced across devices — e.g. stored in\n * iCloud Keychain. For single-device security policies (air-gapped USB sticks,\n * high-security terminals), this is a threat: the credential is available on\n * any device where the user's iCloud account is signed in.\n *\n * The `requireSingleDevice: true` option rejects credentials with the BE flag\n * set during enrollment. Existing enrollments are checked at assertion time —\n * if the authenticator data shows BE=1 but `requireSingleDevice` was set at\n * enrollment, the assertion throws `WebAuthnMultiDeviceError`.\n *\n * Enrollment flow\n * ───────────────\n * 1. User is already authenticated (secret or existing session).\n * 2. Call `enrollWebAuthn(keyring, options)`.\n * 3. WebAuthn credential is created; PRF or rawId-derived key wraps the KEK.\n * 4. Returns a `WebAuthnEnrollment` — persist this to the noy-db adapter\n * via `saveEnrollment()`, or store it yourself in any encrypted collection.\n *\n * Unlock flow\n * ───────────\n * 1. Load the `WebAuthnEnrollment` via `loadEnrollment()`.\n * 2. Call `unlockWebAuthn(enrollment, keyring)` — triggers the WebAuthn\n * assertion prompt.\n * 3. On success, returns the unwrapped `CryptoKey` (the KEK) — use it to\n * re-hydrate the session via `createSession()`.\n */\n\nimport { bufferToBase64, base64ToBuffer } from '@noy-db/hub'\nimport { ValidationError } from '@noy-db/hub'\nimport type { Role } from '@noy-db/hub'\nimport type { UnlockedKeyring, EnrollAuthenticatorOptions, SlotRewrapContext } from '@noy-db/hub/on'\n\n// Re-export from core for convenience\nexport { ValidationError } from '@noy-db/hub'\n\n// ─── Error types ──────────────────────────────────────────────────────\n\n/**\n * Thrown when the WebAuthn API is not available in the current environment.\n *\n * Check `isWebAuthnAvailable()` before calling `enrollWebAuthn()` or\n * `unlockWebAuthn()` and show a fallback UI (secret entry) if this\n * returns false. Common scenarios: Node.js environments, older browsers,\n * non-HTTPS origins (WebAuthn requires a Secure Context).\n */\nexport class WebAuthnNotAvailableError extends Error {\n readonly code = 'WEBAUTHN_NOT_AVAILABLE'\n constructor() {\n super('WebAuthn is not available in this environment. A browser with navigator.credentials support is required.')\n this.name = 'WebAuthnNotAvailableError'\n }\n}\n\n/**\n * Thrown when the user dismisses the WebAuthn prompt without completing it.\n *\n * The `op` field distinguishes enrollment cancellation (user chose not to\n * enroll a hardware key) from assertion cancellation (user dismissed the\n * unlock prompt). Treat this as a user-initiated action, not an error — show\n * a \"use secret instead\" option rather than an error message.\n */\nexport class WebAuthnCancelledError extends Error {\n readonly code = 'WEBAUTHN_CANCELLED'\n constructor(op: 'enrollment' | 'assertion') {\n super(`WebAuthn ${op} was cancelled by the user.`)\n this.name = 'WebAuthnCancelledError'\n }\n}\n\n/**\n * Thrown when the authenticator has the backup-eligible (BE) flag set but\n * the vault requires a single-device credential (`requireSingleDevice: true`).\n *\n * A BE credential is synced across devices (e.g. iCloud Keychain, Google\n * Password Manager), which violates the single-device security model. The\n * user must enroll a hardware security key (YubiKey, Titan, SoloKey) instead.\n */\nexport class WebAuthnMultiDeviceError extends Error {\n readonly code = 'WEBAUTHN_MULTI_DEVICE'\n constructor() {\n super(\n 'This credential is backup-eligible (BE flag set) and may be synced across devices. ' +\n 'The vault requires a single-device credential (requireSingleDevice: true). ' +\n 'Please use a hardware security key (YubiKey, Titan, SoloKey) or a platform ' +\n 'authenticator that does not sync credentials across devices.',\n )\n this.name = 'WebAuthnMultiDeviceError'\n }\n}\n\n/**\n * Thrown when `enrollWebAuthn()` (or `webAuthnSlotRewrapCeremony()`) is\n * asked to enroll/rewrap without a PRF-capable authenticator and without\n * explicit opt-in via `allowNonPrfInsecure: true`.\n *\n * Non-PRF WebAuthn cannot provide confidentiality: its wrapping key derives\n * entirely from the credential's `rawId`, which is stored in cleartext in\n * the enrollment record itself — the record would self-decrypt with no\n * authenticator involved. By default, enrollment/rewrap refuses this path.\n * Use a PRF-capable credential, fall back to the secret, or set\n * `allowNonPrfInsecure: true` to explicitly accept a non-confidential\n * presence gate instead.\n */\nexport class WebAuthnPRFUnavailableError extends Error {\n readonly code = 'WEBAUTHN_PRF_UNAVAILABLE'\n constructor() {\n super(\n 'The PRF extension is not available on this authenticator, so it cannot provide ' +\n 'WebAuthn confidentiality — the non-PRF wrapping key derives entirely from the ' +\n \"credential's rawId, which is stored in the enrollment record itself (the record \" +\n 'would self-decrypt with no authenticator involved). Enrollment refuses this path ' +\n 'by default. Use a PRF-capable authenticator, use the secret instead, or pass ' +\n '{ allowNonPrfInsecure: true } to explicitly accept a non-confidential presence gate.',\n )\n this.name = 'WebAuthnPRFUnavailableError'\n }\n}\n\n// ─── Types ────────────────────────────────────────────────────────────\n\n/**\n * A persisted WebAuthn enrollment record. Store this in a noy-db\n * collection (encrypted like any other record) or return it from\n * `saveEnrollment()` / `loadEnrollment()` helpers.\n */\nexport interface WebAuthnEnrollment {\n /** Enrollment format version. */\n readonly _noydb_webauthn: 1\n /** The vault this enrollment was created for. */\n readonly vault: string\n /** The user ID this enrollment belongs to. */\n readonly userId: string\n /** WebAuthn credential ID (base64). Use for allowCredentials in assertions. */\n readonly credentialId: string\n /** Whether PRF was used for key derivation (vs rawId HKDF fallback). */\n readonly prfUsed: boolean\n /** Whether the BE (backup-eligibility) flag was present at enrollment time. */\n readonly beFlag: boolean\n /** Whether single-device was required at enrollment time. */\n readonly requireSingleDevice: boolean\n /** The wrapped KEK: encrypt(exportedDekMap, derivedKey). Base64. */\n readonly wrappedPayload: string\n /** IV used for the wrapping. Base64. */\n readonly wrapIv: string\n /** ISO timestamp of enrollment. */\n readonly enrolledAt: string\n}\n\n/** Options for `enrollWebAuthn()`. */\nexport interface WebAuthnEnrollOptions {\n /**\n * Relying party ID and name for the WebAuthn credential.\n * Defaults to `{ id: window.location.hostname, name: 'NOYDB' }`.\n */\n rp?: { id?: string; name: string }\n /**\n * If `true`, refuse to enroll credentials with the BE flag set\n * (multi-device / syncable passkeys). Defaults to `false`.\n *\n * Set to `true` for high-security deployments where the credential\n * must be bound to a single physical device (YubiKey, Titan, etc.).\n */\n requireSingleDevice?: boolean\n /**\n * WebAuthn timeout in milliseconds. Default: 60_000.\n */\n timeout?: number\n /**\n * If `true`, prefer a cross-platform authenticator (roaming security key).\n * If `false`, prefer a platform authenticator (Touch ID, Face ID).\n * If undefined, let the browser choose.\n */\n preferCrossPlatform?: boolean\n /**\n * Non-PRF WebAuthn cannot provide confidentiality — its wrapping key\n * derives from `credentialId`, which is stored in the enrollment record,\n * so the record self-decrypts with no authenticator. By default,\n * enrollment REFUSES the non-PRF path (throws `WebAuthnPRFUnavailableError`).\n * Set `true` ONLY to explicitly opt into a non-confidential presence gate;\n * prefer the secret or a PRF-capable credential for real confidentiality.\n */\n allowNonPrfInsecure?: boolean\n}\n\n/** Options for `unlockWebAuthn()`. */\nexport interface WebAuthnUnlockOptions {\n /** WebAuthn timeout in milliseconds. Default: 60_000. */\n timeout?: number\n /**\n * Only consulted by `webAuthnSlotRewrapCeremony()` — `unlockWebAuthn()`\n * ignores it, since unlocking an existing non-PRF record is always\n * permitted (back-compat; that record was never confidential). Same\n * semantics as `WebAuthnEnrollOptions.allowNonPrfInsecure`: required to\n * rewrap a non-PRF slot, since rewrap produces a fresh self-decrypting\n * wrapped payload.\n */\n allowNonPrfInsecure?: boolean\n}\n\n// ─── Environment check ─────────────────────────────────────────────────\n\n/**\n * Returns `true` if WebAuthn is available and can be used for enrollment or unlock.\n *\n * Checks for `navigator.credentials`, `window.PublicKeyCredential`, and a\n * Secure Context (`window.isSecureContext`). Call this before rendering the\n * \"Register hardware key\" button to avoid showing options that will fail.\n */\nexport function isWebAuthnAvailable(): boolean {\n return (\n typeof window !== 'undefined' &&\n typeof window.PublicKeyCredential !== 'undefined' &&\n typeof navigator !== 'undefined' &&\n typeof navigator.credentials !== 'undefined'\n )\n}\n\n// ─── PRF salt ─────────────────────────────────────────────────────────\n\nconst PRF_SALT = new TextEncoder().encode('noydb-webauthn-kek-derive')\n\n// ─── Key derivation helpers ────────────────────────────────────────────\n\n/**\n * Derive a wrapping key from PRF output.\n * PRF output is 32 bytes of authenticator-bound pseudo-random data.\n */\nasync function deriveKeyFromPRF(prfOutput: ArrayBuffer): Promise<CryptoKey> {\n const keyMaterial = await globalThis.crypto.subtle.importKey(\n 'raw',\n prfOutput,\n 'HKDF',\n false,\n ['deriveKey'],\n )\n return globalThis.crypto.subtle.deriveKey(\n {\n name: 'HKDF',\n hash: 'SHA-256',\n salt: PRF_SALT,\n info: new TextEncoder().encode('noydb-kek-wrap-v1'),\n },\n keyMaterial,\n { name: 'AES-GCM', length: 256 },\n false,\n ['encrypt', 'decrypt'],\n )\n}\n\n/**\n * Derive a wrapping key from the credential's rawId (fallback when PRF unavailable).\n * rawId IS the entire secret and is stored in the record itself — the record\n * self-decrypts with no authenticator involved (presence gate, not confidentiality).\n * Universally supported but only usable via explicit `allowNonPrfInsecure: true`.\n */\nasync function deriveKeyFromRawId(rawId: ArrayBuffer): Promise<CryptoKey> {\n const keyMaterial = await globalThis.crypto.subtle.importKey(\n 'raw',\n rawId,\n 'HKDF',\n false,\n ['deriveKey'],\n )\n return globalThis.crypto.subtle.deriveKey(\n {\n name: 'HKDF',\n hash: 'SHA-256',\n salt: new TextEncoder().encode('noydb-webauthn-rawid-fallback'),\n info: new TextEncoder().encode('noydb-kek-wrap-v1'),\n },\n keyMaterial,\n { name: 'AES-GCM', length: 256 },\n false,\n ['encrypt', 'decrypt'],\n )\n}\n\n// ─── BE flag extraction ────────────────────────────────────────────────\n\n/**\n * Extract the BE (backup-eligibility) flag from WebAuthn authenticator data.\n * Authenticator data byte layout (CTAP2 spec):\n * bytes 0-31: rpIdHash\n * byte 32: flags byte\n * bytes 33-36: signCount\n * ...\n *\n * Flags byte bit layout (bit 0 = LSB):\n * bit 0 (UP): user presence\n * bit 2 (UV): user verification\n * bit 3 (BE): backup eligibility\n * bit 4 (BS): backup state\n * bit 6 (AT): attested credential data present\n * bit 7 (ED): extension data present\n */\nfunction extractBEFlag(authData: ArrayBuffer): boolean {\n const bytes = new Uint8Array(authData)\n if (bytes.length < 33) return false\n const flagsByte = bytes[32]!\n return (flagsByte & 0b00001000) !== 0 // bit 3\n}\n\n// ─── Payload wrap/unwrap ───────────────────────────────────────────────\n\n/**\n * Serialize and encrypt the DEK map from `keyring` using `wrappingKey`.\n * The wrapped payload is what gets stored in the enrollment record.\n */\nasync function wrapKeyringSummary(\n keyring: UnlockedKeyring,\n wrappingKey: CryptoKey,\n): Promise<{ wrappedPayload: string; wrapIv: string }> {\n const dekMap: Record<string, string> = {}\n for (const [collName, dek] of keyring.deks) {\n const raw = await globalThis.crypto.subtle.exportKey('raw', dek)\n dekMap[collName] = bufferToBase64(raw)\n }\n\n const payload = JSON.stringify({\n userId: keyring.userId,\n displayName: keyring.displayName,\n role: keyring.role,\n permissions: keyring.permissions,\n deks: dekMap,\n salt: bufferToBase64(keyring.salt),\n })\n\n const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))\n const encrypted = await globalThis.crypto.subtle.encrypt(\n { name: 'AES-GCM', iv },\n wrappingKey,\n new TextEncoder().encode(payload),\n )\n\n return { wrappedPayload: bufferToBase64(encrypted), wrapIv: bufferToBase64(iv) }\n}\n\n/**\n * Decrypt and deserialize the keyring payload using `wrappingKey`.\n */\nasync function unwrapKeyringSummary(\n enrollment: WebAuthnEnrollment,\n wrappingKey: CryptoKey,\n): Promise<UnlockedKeyring> {\n const iv = base64ToBuffer(enrollment.wrapIv)\n const ciphertext = base64ToBuffer(enrollment.wrappedPayload)\n\n let plaintext: ArrayBuffer\n try {\n plaintext = await globalThis.crypto.subtle.decrypt(\n { name: 'AES-GCM', iv },\n wrappingKey,\n ciphertext,\n )\n } catch {\n throw new ValidationError('WebAuthn decryption failed — the authenticator may have changed or the enrollment may be corrupt.')\n }\n\n const parsed = JSON.parse(new TextDecoder().decode(plaintext)) as {\n userId: string\n displayName: string\n role: Role\n permissions: Record<string, 'rw' | 'ro'>\n deks: Record<string, string>\n salt: string\n }\n\n const deks = new Map<string, CryptoKey>()\n for (const [collName, rawBase64] of Object.entries(parsed.deks)) {\n const dek = await globalThis.crypto.subtle.importKey(\n 'raw',\n base64ToBuffer(rawBase64),\n { name: 'AES-GCM', length: 256 },\n true,\n ['encrypt', 'decrypt'],\n )\n deks.set(collName, dek)\n }\n\n return {\n userId: parsed.userId,\n displayName: parsed.displayName,\n role: parsed.role,\n permissions: parsed.permissions,\n deks,\n kek: null,\n salt: base64ToBuffer(parsed.salt),\n authenticators: [],\n }\n}\n\n// ─── Public API ────────────────────────────────────────────────────────\n\n/**\n * Enroll a WebAuthn credential for the given keyring.\n *\n * The caller must already have an unlocked keyring (from secret auth or\n * an existing session). The WebAuthn credential creation prompt is triggered\n * by this call.\n *\n * Returns a `WebAuthnEnrollment` that should be persisted — typically via\n * `saveEnrollment()` into a noy-db collection.\n *\n * @throws `WebAuthnNotAvailableError` if the environment doesn't support WebAuthn.\n * @throws `WebAuthnCancelledError` if the user cancels the credential creation.\n * @throws `WebAuthnMultiDeviceError` if `requireSingleDevice` is true and the\n * authenticator returned a credential with the BE flag set.\n * @throws `WebAuthnPRFUnavailableError` if the authenticator did not provide\n * PRF output and `options.allowNonPrfInsecure` is not `true` — see\n * that option's doc for why the non-PRF path is refused by default.\n */\nexport async function enrollWebAuthn(\n keyring: UnlockedKeyring,\n vault: string,\n options: WebAuthnEnrollOptions = {},\n): Promise<WebAuthnEnrollment> {\n if (!isWebAuthnAvailable()) {\n throw new WebAuthnNotAvailableError()\n }\n\n const rpId = options.rp?.id ?? (typeof window !== 'undefined' ? window.location.hostname : 'localhost')\n const rpName = options.rp?.name ?? 'NOYDB'\n const timeout = options.timeout ?? 60_000\n\n const challenge = globalThis.crypto.getRandomValues(new Uint8Array(32))\n const userIdBytes = new TextEncoder().encode(keyring.userId)\n\n const authenticatorSelection: AuthenticatorSelectionCriteria = {\n userVerification: 'required',\n residentKey: 'preferred',\n }\n if (options.preferCrossPlatform === true) {\n authenticatorSelection.authenticatorAttachment = 'cross-platform'\n } else if (options.preferCrossPlatform === false) {\n authenticatorSelection.authenticatorAttachment = 'platform'\n }\n\n // Request PRF extension for deterministic key derivation\n const extensionsInput = {\n prf: { eval: { first: PRF_SALT } },\n } as AuthenticationExtensionsClientInputs\n\n const credential = await navigator.credentials.create({\n publicKey: {\n challenge,\n rp: { id: rpId, name: rpName },\n user: {\n id: userIdBytes,\n name: keyring.userId,\n displayName: keyring.displayName,\n },\n pubKeyCredParams: [\n { type: 'public-key', alg: -7 }, // ES256\n { type: 'public-key', alg: -257 }, // RS256\n { type: 'public-key', alg: -8 }, // EdDSA\n ],\n authenticatorSelection,\n extensions: extensionsInput,\n timeout,\n },\n }) as PublicKeyCredential | null\n\n if (!credential) {\n throw new WebAuthnCancelledError('enrollment')\n }\n\n const authData = (credential.response as AuthenticatorAttestationResponse).getAuthenticatorData()\n const beFlag = extractBEFlag(authData)\n\n if (options.requireSingleDevice && beFlag) {\n throw new WebAuthnMultiDeviceError()\n }\n\n // Try to get PRF output from extensions\n const extensions = credential.getClientExtensionResults() as {\n prf?: { results?: { first?: ArrayBuffer } }\n }\n const prfOutput = extensions.prf?.results?.first\n const prfUsed = !!prfOutput\n\n if (!prfUsed && !options.allowNonPrfInsecure) {\n throw new WebAuthnPRFUnavailableError()\n }\n\n const wrappingKey = prfOutput\n ? await deriveKeyFromPRF(prfOutput)\n : await deriveKeyFromRawId(credential.rawId)\n\n const { wrappedPayload, wrapIv } = await wrapKeyringSummary(keyring, wrappingKey)\n\n return {\n _noydb_webauthn: 1,\n vault,\n userId: keyring.userId,\n credentialId: bufferToBase64(credential.rawId),\n prfUsed,\n beFlag,\n requireSingleDevice: options.requireSingleDevice ?? false,\n wrappedPayload,\n wrapIv,\n enrolledAt: new Date().toISOString(),\n }\n}\n\n/**\n * Unlock a vault using a previously enrolled WebAuthn credential.\n *\n * Triggers the WebAuthn assertion prompt. On success, decrypts the keyring\n * payload from the enrollment record and returns an `UnlockedKeyring`.\n *\n * The returned keyring has the same DEKs as at enrollment time. If DEK\n * VALUES have been re-minted since enrollment — `rotateKeys`, not\n * `rotateSecret` — this returns stale DEKs and the caller should detect\n * decryption failures and prompt for re-enrollment. A `rotateSecret`\n * phrase rotation does NOT stale them: it rewraps the same values.\n *\n * Non-PRF unlock (`enrollment.prfUsed === false`) is a presence gate, not\n * zero-knowledge confidentiality — it is kept working here for back-compat\n * with existing non-PRF enrollments; new enrollments refuse that path by\n * default (see `WebAuthnEnrollOptions.allowNonPrfInsecure`).\n *\n * @throws `WebAuthnNotAvailableError` if the environment doesn't support WebAuthn.\n * @throws `WebAuthnCancelledError` if the user cancels the assertion.\n * @throws `WebAuthnMultiDeviceError` if `requireSingleDevice` was set at\n * enrollment and the authenticator data now shows BE=1.\n * @throws `ValidationError` if decryption of the keyring payload fails.\n */\nexport async function unlockWebAuthn(\n enrollment: WebAuthnEnrollment,\n options: WebAuthnUnlockOptions = {},\n): Promise<UnlockedKeyring> {\n if (!isWebAuthnAvailable()) {\n throw new WebAuthnNotAvailableError()\n }\n\n const timeout = options.timeout ?? 60_000\n const credentialId = base64ToBuffer(enrollment.credentialId)\n\n const extensionsInput = (enrollment.prfUsed\n ? { prf: { eval: { first: PRF_SALT } } }\n : {}\n ) as AuthenticationExtensionsClientInputs\n\n const assertion = await navigator.credentials.get({\n publicKey: {\n challenge: globalThis.crypto.getRandomValues(new Uint8Array(32)),\n allowCredentials: [{ type: 'public-key', id: credentialId }],\n userVerification: 'required',\n extensions: extensionsInput,\n timeout,\n },\n }) as PublicKeyCredential | null\n\n if (!assertion) {\n throw new WebAuthnCancelledError('assertion')\n }\n\n // BE-flag guard at assertion time\n const authData = (assertion.response as AuthenticatorAssertionResponse).authenticatorData\n const beFlag = extractBEFlag(authData)\n if (enrollment.requireSingleDevice && beFlag) {\n throw new WebAuthnMultiDeviceError()\n }\n\n // Derive the wrapping key using the same method as enrollment\n let wrappingKey: CryptoKey\n if (enrollment.prfUsed) {\n const extensions = assertion.getClientExtensionResults() as {\n prf?: { results?: { first?: ArrayBuffer } }\n }\n const prfOutput = extensions.prf?.results?.first\n if (!prfOutput) {\n throw new ValidationError(\n 'PRF extension output not available at assertion time. ' +\n 'The authenticator may not support PRF. Re-enroll without PRF support.',\n )\n }\n wrappingKey = await deriveKeyFromPRF(prfOutput)\n } else {\n wrappingKey = await deriveKeyFromRawId(assertion.rawId)\n }\n\n return unwrapKeyringSummary(enrollment, wrappingKey)\n}\n\n/**\n * `SlotRewrapCeremony` for WebAuthn slots — used by hub's\n * `rotateSecret({ slotCeremonies: { [slotId]: webAuthnSlotRewrapCeremony } })`\n * to preserve a tier-2 WebAuthn enrollment across a tier-1 phrase\n * rotation without requiring re-enrollment of the credential.\n *\n * The credential itself is unaffected by phrase rotation — the\n * wrapping key derived from PRF (or rawId fallback) is bound to the\n * authenticator, not to the secret. What needs to change is the\n * **wrapped payload**: the encrypted blob the slot's `wrapped_kek`\n * field holds. After rotation the old payload is wrapped under the\n * OLD KEK, so hub can no longer thread it through the rotated\n * keyring; the new payload must hold `ctx.newDeks`.\n *\n * ⚠️ **The old payload's DEK VALUES are not stale — only its wrapping\n * is.** `rotateSecret` rewraps the SAME DEKs under a freshly-derived\n * KEK; only `rotateKeys` re-mints DEK values. Records are encrypted\n * with DEKs, so a captured `wrappedPayload` keeps decrypting\n * everything across any number of phrase rotations. An earlier\n * revision of this comment said \"now stale because rotateSecret\n * rewrapped them under a fresh KEK\", which reads as though rotation\n * invalidated the captured blob. It does not, and nothing in this\n * ceremony is a revocation step.\n *\n * ## Revoking a credential — this ceremony is a REQUIRED INPUT to it\n *\n * Hub's `revokeAuthenticator` is the real revocation, and it is three steps:\n * `removeAuthenticator` (hides the slot), `rotateKeys` (mints fresh DEKs —\n * the step that actually revokes), then `rotateSecret` with a\n * `slotCeremonies` entry for every slot that STAYS.\n *\n * ⛔ **A remaining slot handed no ceremony is DROPPED, not preserved.** After\n * the `rotateKeys` step its blob wraps DEKs that no longer exist, so keeping\n * it would leave a credential that silently fails. Its holder must re-enrol.\n *\n * So revoking ONE credential means supplying `webAuthnSlotRewrapCeremony` for\n * EVERY WebAuthn slot you intend to keep — and each requires that\n * credential to be present (a live assertion on the authenticator, which means the user in front of the device). Re-wrapping a credential\n * is the credential's own operation; there is no way to do it on the\n * holder's behalf.\n *\n * ⚠️ **Not in any published hub at the time of writing.**\n * `revokeAuthenticator` landed on noy-db `main` in `0612b4ac`, AFTER\n * `@noy-db/hub@0.7.1-pre.0` was cut and published — so it is in neither\n * `@latest` (0.7.0) nor `@next` (0.7.1-pre.0). Ask npm before promising it to\n * a consumer; do not infer its version from hub's `main`.\n *\n * Single ceremony, two operations:\n * 1. Trigger one WebAuthn assertion to derive the wrapping key.\n * 2. Decrypt the OLD `wrapped_kek` to extract identity fields\n * (userId, displayName, role, permissions, salt) — these don't\n * change on rotation, so they're carried verbatim into the new\n * payload.\n * 3. Encrypt the NEW payload (same identity + `ctx.newDeks`) under\n * the same wrapping key, with a fresh IV.\n * 4. Return `EnrollAuthenticatorOptions` preserving `oldSlot.id`\n * and `method: 'webauthn'` (hub validates these to prevent\n * slot-type swap mid-rotation).\n *\n * Previously a workaround was needed — detect dropped slots after\n * rotate and offer \"Re-enrol Touch ID\" inline. This ceremony\n * eliminates that step.\n *\n * Out of scope: tier-3 PIN. PIN state lives in `QuickUnlockStore`\n * (RAM-only), not in `KeyringFile.authenticators[]`, so\n * `slotCeremonies` doesn't apply. Clear PIN state on rotate; let\n * the user set a fresh PIN via `db.enrollUnlock` immediately after.\n *\n * @throws {WebAuthnNotAvailableError} when the environment lacks WebAuthn.\n * @throws {WebAuthnCancelledError} when the user dismisses the assertion.\n * @throws {WebAuthnMultiDeviceError} when `meta.requireSingleDevice` was\n * set at enrollment and the authenticator now reports BE=1.\n * @throws {ValidationError} when `oldSlot.method !== 'webauthn'`,\n * when required `meta` fields are missing, or when the old\n * payload fails to decrypt (credential changed / payload\n * tampered).\n * @throws {WebAuthnPRFUnavailableError} when the old slot is non-PRF and\n * `options.allowNonPrfInsecure` is not `true` — rewrapping a\n * non-PRF slot produces a fresh self-decrypting payload, so it\n * requires the same explicit acknowledgement as enrollment.\n *\n * @see passwordSlotRewrapCeremony — the password parallel.\n */\nexport async function webAuthnSlotRewrapCeremony(\n ctx: SlotRewrapContext,\n options: WebAuthnUnlockOptions = {},\n): Promise<EnrollAuthenticatorOptions> {\n if (ctx.oldSlot.method !== 'webauthn') {\n throw new ValidationError(\n `webAuthnSlotRewrapCeremony: oldSlot.method is \"${ctx.oldSlot.method}\"; expected \"webauthn\". ` +\n 'This ceremony only handles WebAuthn slots — pair other methods with their own helpers.',\n )\n }\n if (ctx.oldSlot.wrapKind === 'deks') {\n throw new ValidationError(\n 'webAuthnSlotRewrapCeremony: oldSlot is a wrap-DEKs slot; expected wrap-KEK. ' +\n 'WebAuthn slots use the wrap-KEK variant; mismatch indicates the slot was ' +\n 'enrolled by a different on-* package.',\n )\n }\n if (!isWebAuthnAvailable()) {\n throw new WebAuthnNotAvailableError()\n }\n\n // Pull the per-slot fields needed for assertion + decrypt. These were\n // written into `meta` by `enrollWebAuthn` (via `db.enrollWebAuthn`).\n const meta = ctx.oldSlot.meta as {\n credentialId?: unknown\n wrapIv?: unknown\n prfUsed?: unknown\n beFlag?: unknown\n requireSingleDevice?: unknown\n }\n if (typeof meta.credentialId !== 'string' || meta.credentialId.length === 0) {\n throw new ValidationError(\n 'webAuthnSlotRewrapCeremony: oldSlot.meta.credentialId is missing or invalid. ' +\n 'The slot was not enrolled via @noy-db/on-webauthn.',\n )\n }\n if (typeof meta.wrapIv !== 'string' || meta.wrapIv.length === 0) {\n throw new ValidationError(\n 'webAuthnSlotRewrapCeremony: oldSlot.meta.wrapIv is missing — the slot may be ' +\n 'pre-#16 (synthetic-keyring shape) and must be re-enrolled via db.enrollWebAuthn.',\n )\n }\n const prfUsed = meta.prfUsed === true\n\n if (!prfUsed && !options.allowNonPrfInsecure) {\n throw new WebAuthnPRFUnavailableError()\n }\n\n // 1. Trigger the assertion. Same path `unlockWebAuthn` uses.\n const credentialIdBuf = base64ToBuffer(meta.credentialId)\n const timeout = options.timeout ?? 60_000\n const extensionsInput = (prfUsed\n ? { prf: { eval: { first: PRF_SALT } } }\n : {}\n ) as AuthenticationExtensionsClientInputs\n\n const assertion = await navigator.credentials.get({\n publicKey: {\n challenge: globalThis.crypto.getRandomValues(new Uint8Array(32)),\n allowCredentials: [{ type: 'public-key', id: credentialIdBuf }],\n userVerification: 'required',\n extensions: extensionsInput,\n timeout,\n },\n }) as PublicKeyCredential | null\n\n if (!assertion) {\n throw new WebAuthnCancelledError('assertion')\n }\n\n // BE-flag guard at assertion time — same rule unlockWebAuthn enforces.\n const authData = (assertion.response as AuthenticatorAssertionResponse).authenticatorData\n const beFlag = extractBEFlag(authData)\n if (meta.requireSingleDevice === true && beFlag) {\n throw new WebAuthnMultiDeviceError()\n }\n\n // 2. Derive the wrapping key — deterministic per credential, so this\n // is the SAME key the original enrollment used.\n let wrappingKey: CryptoKey\n if (prfUsed) {\n const extensions = assertion.getClientExtensionResults() as {\n prf?: { results?: { first?: ArrayBuffer } }\n }\n const prfOutput = extensions.prf?.results?.first\n if (!prfOutput) {\n throw new ValidationError(\n 'webAuthnSlotRewrapCeremony: PRF output missing at assertion time. ' +\n 'The authenticator may have lost PRF capability — re-enrol the slot via db.enrollWebAuthn.',\n )\n }\n wrappingKey = await deriveKeyFromPRF(prfOutput)\n } else {\n wrappingKey = await deriveKeyFromRawId(assertion.rawId)\n }\n\n // 3. Decrypt the OLD wrapped_kek to extract carry-through identity\n // fields. The slot's wrapped_kek IS the old wrappedPayload\n // (mapping established by db.enrollWebAuthn at noydb.ts:1178).\n const oldIv = base64ToBuffer(meta.wrapIv)\n const oldCiphertext = base64ToBuffer(ctx.oldSlot.wrapped_kek)\n let oldPlaintext: ArrayBuffer\n try {\n oldPlaintext = await globalThis.crypto.subtle.decrypt(\n { name: 'AES-GCM', iv: oldIv },\n wrappingKey,\n oldCiphertext,\n )\n } catch {\n throw new ValidationError(\n 'webAuthnSlotRewrapCeremony: failed to decrypt the old wrapped payload. ' +\n 'The wrapping key derived from this credential does not match — possible ' +\n 'cross-tenant slot mix-up or a corrupted enrollment.',\n )\n }\n const oldPayload = JSON.parse(new TextDecoder().decode(oldPlaintext)) as {\n userId: string\n displayName: string\n role: Role\n permissions: Record<string, 'rw' | 'ro'>\n deks: Record<string, string>\n salt: string\n }\n\n // 4. Build the NEW payload — identity fields preserved, deks\n // replaced with ctx.newDeks (rewrapped under the new KEK in the\n // keyring file; here we serialize them as raw bytes for the\n // self-contained webauthn-side reconstruction).\n const newDekMap: Record<string, string> = {}\n for (const [collName, dek] of ctx.newDeks) {\n const raw = await globalThis.crypto.subtle.exportKey('raw', dek)\n newDekMap[collName] = bufferToBase64(raw)\n }\n const newPayload = JSON.stringify({\n userId: oldPayload.userId,\n displayName: oldPayload.displayName,\n role: oldPayload.role,\n permissions: oldPayload.permissions,\n deks: newDekMap,\n salt: oldPayload.salt,\n })\n\n // 5. Encrypt with the same wrapping key under a fresh IV.\n const newIv = globalThis.crypto.getRandomValues(new Uint8Array(12))\n const newCiphertext = await globalThis.crypto.subtle.encrypt(\n { name: 'AES-GCM', iv: newIv },\n wrappingKey,\n new TextEncoder().encode(newPayload),\n )\n\n // 6. Return EnrollAuthenticatorOptions preserving id + method.\n // Hub's rotate validates these — a mismatch throws ValidationError\n // (slot-type swap defense; see SlotRewrapContext docstring).\n return {\n id: ctx.oldSlot.id,\n method: 'webauthn',\n wrapped_kek: bufferToBase64(newCiphertext),\n enrolled_via_tier: ctx.oldSlot.enrolled_via_tier,\n meta: {\n credentialId: meta.credentialId,\n wrapIv: bufferToBase64(newIv),\n prfUsed,\n beFlag,\n requireSingleDevice: meta.requireSingleDevice === true,\n },\n }\n}\n\n/**\n * Check whether a `WebAuthnEnrollment` record looks well-formed.\n * Does not perform any cryptographic verification.\n */\nexport function isValidEnrollment(value: unknown): value is WebAuthnEnrollment {\n if (!value || typeof value !== 'object') return false\n const e = value as Record<string, unknown>\n return (\n e._noydb_webauthn === 1 &&\n typeof e.vault === 'string' &&\n typeof e.userId === 'string' &&\n typeof e.credentialId === 'string' &&\n typeof e.wrappedPayload === 'string' &&\n typeof e.wrapIv === 'string'\n )\n}\n"],"mappings":"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","names":["ValidationError"]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * @noy-db/on-webauthn —\n *\n * Hardware-key keyring for noy-db using the WebAuthn API.\n *\n * Covers every form factor:\n * - Platform authenticators: Touch ID, Face ID, Windows Hello, Android biometric\n * - Roaming authenticators: YubiKey (5C NFC, Bio), SoloKey, Titan, any FIDO2 key\n * - Passkey-capable platform authenticators: iCloud Keychain, Google Password Manager\n *\n * Key derivation model\n * ────────────────────\n * This package uses the **PRF (Pseudo-Random Function) extension** when\n * available to derive a deterministic wrapping key from the WebAuthn\n * credential. The PRF output is consistent across assertions on the same\n * device/credential, enabling unlock-without-secret while keeping the\n * derived key bound to the physical authenticator.\n *\n * When PRF is not supported by the authenticator (common on older hardware),\n * the package can fall back to HKDF-SHA256 over the credential's `rawId` —\n * but by default, enrollment refuses this path because the `rawId` is stored\n * in the record itself, making the record self-decrypting (presence gate, not\n * confidentiality). Use `allowNonPrfInsecure: true` to opt in; prefer a\n * PRF-capable device or the secret for real confidentiality.\n *\n * The derived key is NEVER persisted. It exists only in memory during the\n * unlock operation. What IS persisted (in the noy-db adapter, not in browser\n * storage) is the wrapped KEK: `encrypt(KEK, derivedKey)`.\n *\n * BE-flag guards\n * ──────────────\n * The backup-eligibility (BE) flag in a WebAuthn authenticator data signals\n * that the credential is (or can be) synced across devices — e.g. stored in\n * iCloud Keychain. For single-device security policies (air-gapped USB sticks,\n * high-security terminals), this is a threat: the credential is available on\n * any device where the user's iCloud account is signed in.\n *\n * The `requireSingleDevice: true` option rejects credentials with the BE flag\n * set during enrollment. Existing enrollments are checked at assertion time —\n * if the authenticator data shows BE=1 but `requireSingleDevice` was set at\n * enrollment, the assertion throws `WebAuthnMultiDeviceError`.\n *\n * Enrollment flow\n * ───────────────\n * 1. User is already authenticated (secret or existing session).\n * 2. Call `enrollWebAuthn(keyring, vault, options?)`.\n * 3. WebAuthn credential is created; PRF or rawId-derived key wraps the KEK.\n * 4. Returns a `WebAuthnEnrollment` — persist it yourself, in any collection\n * or in IndexedDB. This package ships no storage helper.\n *\n * Unlock flow\n * ───────────\n * 1. Load the `WebAuthnEnrollment` back from wherever you persisted it.\n * 2. Call `unlockWebAuthn(enrollment, options?)` — triggers the WebAuthn\n * assertion prompt.\n * 3. On success, returns an `UnlockedKeyring`. Pass it to `createNoydb` via\n * the `getKeyring` callback; see this package's README.\n */\n\nimport { bufferToBase64, base64ToBuffer } from '@noy-db/hub'\nimport { ValidationError } from '@noy-db/hub'\nimport type { Role } from '@noy-db/hub'\nimport type { UnlockedKeyring, EnrollAuthenticatorOptions, SlotRewrapContext } from '@noy-db/hub/on'\n\n// Re-export from core for convenience\nexport { ValidationError } from '@noy-db/hub'\n\n// ─── Error types ──────────────────────────────────────────────────────\n\n/**\n * Thrown when the WebAuthn API is not available in the current environment.\n *\n * Check `isWebAuthnAvailable()` before calling `enrollWebAuthn()` or\n * `unlockWebAuthn()` and show a fallback UI (secret entry) if this\n * returns false. Common scenarios: Node.js environments, older browsers,\n * non-HTTPS origins (WebAuthn requires a Secure Context).\n */\nexport class WebAuthnNotAvailableError extends Error {\n readonly code = 'WEBAUTHN_NOT_AVAILABLE'\n constructor() {\n super('WebAuthn is not available in this environment. A browser with navigator.credentials support is required.')\n this.name = 'WebAuthnNotAvailableError'\n }\n}\n\n/**\n * Thrown when the user dismisses the WebAuthn prompt without completing it.\n *\n * The `op` field distinguishes enrollment cancellation (user chose not to\n * enroll a hardware key) from assertion cancellation (user dismissed the\n * unlock prompt). Treat this as a user-initiated action, not an error — show\n * a \"use secret instead\" option rather than an error message.\n */\nexport class WebAuthnCancelledError extends Error {\n readonly code = 'WEBAUTHN_CANCELLED'\n constructor(op: 'enrollment' | 'assertion') {\n super(`WebAuthn ${op} was cancelled by the user.`)\n this.name = 'WebAuthnCancelledError'\n }\n}\n\n/**\n * Thrown when the authenticator has the backup-eligible (BE) flag set but\n * the vault requires a single-device credential (`requireSingleDevice: true`).\n *\n * A BE credential is synced across devices (e.g. iCloud Keychain, Google\n * Password Manager), which violates the single-device security model. The\n * user must enroll a hardware security key (YubiKey, Titan, SoloKey) instead.\n */\nexport class WebAuthnMultiDeviceError extends Error {\n readonly code = 'WEBAUTHN_MULTI_DEVICE'\n constructor() {\n super(\n 'This credential is backup-eligible (BE flag set) and may be synced across devices. ' +\n 'The vault requires a single-device credential (requireSingleDevice: true). ' +\n 'Please use a hardware security key (YubiKey, Titan, SoloKey) or a platform ' +\n 'authenticator that does not sync credentials across devices.',\n )\n this.name = 'WebAuthnMultiDeviceError'\n }\n}\n\n/**\n * Thrown when `enrollWebAuthn()` (or `webAuthnSlotRewrapCeremony()`) is\n * asked to enroll/rewrap without a PRF-capable authenticator and without\n * explicit opt-in via `allowNonPrfInsecure: true`.\n *\n * Non-PRF WebAuthn cannot provide confidentiality: its wrapping key derives\n * entirely from the credential's `rawId`, which is stored in cleartext in\n * the enrollment record itself — the record would self-decrypt with no\n * authenticator involved. By default, enrollment/rewrap refuses this path.\n * Use a PRF-capable credential, fall back to the secret, or set\n * `allowNonPrfInsecure: true` to explicitly accept a non-confidential\n * presence gate instead.\n */\nexport class WebAuthnPRFUnavailableError extends Error {\n readonly code = 'WEBAUTHN_PRF_UNAVAILABLE'\n constructor() {\n super(\n 'The PRF extension is not available on this authenticator, so it cannot provide ' +\n 'WebAuthn confidentiality — the non-PRF wrapping key derives entirely from the ' +\n \"credential's rawId, which is stored in the enrollment record itself (the record \" +\n 'would self-decrypt with no authenticator involved). Enrollment refuses this path ' +\n 'by default. Use a PRF-capable authenticator, use the secret instead, or pass ' +\n '{ allowNonPrfInsecure: true } to explicitly accept a non-confidential presence gate.',\n )\n this.name = 'WebAuthnPRFUnavailableError'\n }\n}\n\n// ─── Types ────────────────────────────────────────────────────────────\n\n/**\n * A persisted WebAuthn enrollment record. Store this in a noy-db\n * collection (encrypted like any other record) or return it from\n * `saveEnrollment()` / `loadEnrollment()` helpers.\n */\nexport interface WebAuthnEnrollment {\n /** Enrollment format version. */\n readonly _noydb_webauthn: 1\n /** The vault this enrollment was created for. */\n readonly vault: string\n /** The user ID this enrollment belongs to. */\n readonly userId: string\n /** WebAuthn credential ID (base64). Use for allowCredentials in assertions. */\n readonly credentialId: string\n /** Whether PRF was used for key derivation (vs rawId HKDF fallback). */\n readonly prfUsed: boolean\n /** Whether the BE (backup-eligibility) flag was present at enrollment time. */\n readonly beFlag: boolean\n /** Whether single-device was required at enrollment time. */\n readonly requireSingleDevice: boolean\n /** The wrapped KEK: encrypt(exportedDekMap, derivedKey). Base64. */\n readonly wrappedPayload: string\n /** IV used for the wrapping. Base64. */\n readonly wrapIv: string\n /** ISO timestamp of enrollment. */\n readonly enrolledAt: string\n /**\n * The RP ID this credential was created under.\n *\n * ⚠️ OPTIONAL, and absence is not a default — records written before\n * 0.9.0 do not carry it, and for those `unlockWebAuthn` sends no `rpId`,\n * leaving the browser to default it to the asserting page's hostname.\n * That is the old behaviour, preserved deliberately: a record enrolled\n * on `app.example.com` must keep unlocking there.\n *\n * When present it is sent back as `publicKey.rpId` at assertion time, so\n * enrolment and assertion cannot disagree — which is the whole point:\n * enrol under the registrable domain (`rp: { id: 'example.com' }`) and\n * every subdomain asserts against the same credential.\n */\n readonly rpId?: string\n}\n\n/** Options for `enrollWebAuthn()`. */\nexport interface WebAuthnEnrollOptions {\n /**\n * Relying party ID and name for the WebAuthn credential.\n * Defaults to `{ id: window.location.hostname, name: 'NOYDB' }`.\n */\n rp?: { id?: string; name: string }\n /**\n * If `true`, refuse to enroll credentials with the BE flag set\n * (multi-device / syncable passkeys). Defaults to `false`.\n *\n * Set to `true` for high-security deployments where the credential\n * must be bound to a single physical device (YubiKey, Titan, etc.).\n */\n requireSingleDevice?: boolean\n /**\n * WebAuthn timeout in milliseconds. Default: 60_000.\n */\n timeout?: number\n /**\n * If `true`, prefer a cross-platform authenticator (roaming security key).\n * If `false`, prefer a platform authenticator (Touch ID, Face ID).\n * If undefined, let the browser choose.\n */\n preferCrossPlatform?: boolean\n /**\n * Non-PRF WebAuthn cannot provide confidentiality — its wrapping key\n * derives from `credentialId`, which is stored in the enrollment record,\n * so the record self-decrypts with no authenticator. By default,\n * enrollment REFUSES the non-PRF path (throws `WebAuthnPRFUnavailableError`).\n * Set `true` ONLY to explicitly opt into a non-confidential presence gate;\n * prefer the secret or a PRF-capable credential for real confidentiality.\n */\n allowNonPrfInsecure?: boolean\n}\n\n/** Options for `unlockWebAuthn()`. */\nexport interface WebAuthnUnlockOptions {\n /** WebAuthn timeout in milliseconds. Default: 60_000. */\n timeout?: number\n /**\n * RP ID to assert against. Overrides the one recorded at enrolment.\n *\n * Needed when the record predates `WebAuthnEnrollment.rpId`, or when the\n * slot's `meta` carries none — without it the browser defaults the RP ID\n * to the asserting page's hostname, so a credential enrolled under\n * `example.com` is invisible to `app.example.com`.\n */\n rpId?: string\n /**\n * Only consulted by `webAuthnSlotRewrapCeremony()` — `unlockWebAuthn()`\n * ignores it, since unlocking an existing non-PRF record is always\n * permitted (back-compat; that record was never confidential). Same\n * semantics as `WebAuthnEnrollOptions.allowNonPrfInsecure`: required to\n * rewrap a non-PRF slot, since rewrap produces a fresh self-decrypting\n * wrapped payload.\n */\n allowNonPrfInsecure?: boolean\n}\n\n// ─── Environment check ─────────────────────────────────────────────────\n\n/**\n * Returns `true` if WebAuthn is available and can be used for enrollment or unlock.\n *\n * Checks for `navigator.credentials`, `window.PublicKeyCredential`, and a\n * Secure Context (`window.isSecureContext`). Call this before rendering the\n * \"Register hardware key\" button to avoid showing options that will fail.\n */\nexport function isWebAuthnAvailable(): boolean {\n return (\n typeof window !== 'undefined' &&\n typeof window.PublicKeyCredential !== 'undefined' &&\n typeof navigator !== 'undefined' &&\n typeof navigator.credentials !== 'undefined'\n )\n}\n\n// ─── PRF salt ─────────────────────────────────────────────────────────\n\nconst PRF_SALT = new TextEncoder().encode('noydb-webauthn-kek-derive')\n\n// ─── Key derivation helpers ────────────────────────────────────────────\n\n/**\n * Derive a wrapping key from PRF output.\n * PRF output is 32 bytes of authenticator-bound pseudo-random data.\n */\nasync function deriveKeyFromPRF(prfOutput: ArrayBuffer): Promise<CryptoKey> {\n const keyMaterial = await globalThis.crypto.subtle.importKey(\n 'raw',\n prfOutput,\n 'HKDF',\n false,\n ['deriveKey'],\n )\n return globalThis.crypto.subtle.deriveKey(\n {\n name: 'HKDF',\n hash: 'SHA-256',\n salt: PRF_SALT,\n info: new TextEncoder().encode('noydb-kek-wrap-v1'),\n },\n keyMaterial,\n { name: 'AES-GCM', length: 256 },\n false,\n ['encrypt', 'decrypt'],\n )\n}\n\n/**\n * Derive a wrapping key from the credential's rawId (fallback when PRF unavailable).\n * rawId IS the entire secret and is stored in the record itself — the record\n * self-decrypts with no authenticator involved (presence gate, not confidentiality).\n * Universally supported but only usable via explicit `allowNonPrfInsecure: true`.\n */\nasync function deriveKeyFromRawId(rawId: ArrayBuffer): Promise<CryptoKey> {\n const keyMaterial = await globalThis.crypto.subtle.importKey(\n 'raw',\n rawId,\n 'HKDF',\n false,\n ['deriveKey'],\n )\n return globalThis.crypto.subtle.deriveKey(\n {\n name: 'HKDF',\n hash: 'SHA-256',\n salt: new TextEncoder().encode('noydb-webauthn-rawid-fallback'),\n info: new TextEncoder().encode('noydb-kek-wrap-v1'),\n },\n keyMaterial,\n { name: 'AES-GCM', length: 256 },\n false,\n ['encrypt', 'decrypt'],\n )\n}\n\n// ─── BE flag extraction ────────────────────────────────────────────────\n\n/**\n * Extract the BE (backup-eligibility) flag from WebAuthn authenticator data.\n * Authenticator data byte layout (CTAP2 spec):\n * bytes 0-31: rpIdHash\n * byte 32: flags byte\n * bytes 33-36: signCount\n * ...\n *\n * Flags byte bit layout (bit 0 = LSB):\n * bit 0 (UP): user presence\n * bit 2 (UV): user verification\n * bit 3 (BE): backup eligibility\n * bit 4 (BS): backup state\n * bit 6 (AT): attested credential data present\n * bit 7 (ED): extension data present\n */\nfunction extractBEFlag(authData: ArrayBuffer): boolean {\n const bytes = new Uint8Array(authData)\n if (bytes.length < 33) return false\n const flagsByte = bytes[32]!\n return (flagsByte & 0b00001000) !== 0 // bit 3\n}\n\n// ─── Payload wrap/unwrap ───────────────────────────────────────────────\n\n/**\n * Serialize and encrypt the DEK map from `keyring` using `wrappingKey`.\n * The wrapped payload is what gets stored in the enrollment record.\n */\nasync function wrapKeyringSummary(\n keyring: UnlockedKeyring,\n wrappingKey: CryptoKey,\n): Promise<{ wrappedPayload: string; wrapIv: string }> {\n const dekMap: Record<string, string> = {}\n for (const [collName, dek] of keyring.deks) {\n const raw = await globalThis.crypto.subtle.exportKey('raw', dek)\n dekMap[collName] = bufferToBase64(raw)\n }\n\n const payload = JSON.stringify({\n userId: keyring.userId,\n displayName: keyring.displayName,\n role: keyring.role,\n permissions: keyring.permissions,\n deks: dekMap,\n salt: bufferToBase64(keyring.salt),\n })\n\n const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))\n const encrypted = await globalThis.crypto.subtle.encrypt(\n { name: 'AES-GCM', iv },\n wrappingKey,\n new TextEncoder().encode(payload),\n )\n\n return { wrappedPayload: bufferToBase64(encrypted), wrapIv: bufferToBase64(iv) }\n}\n\n/**\n * Decrypt and deserialize the keyring payload using `wrappingKey`.\n */\nasync function unwrapKeyringSummary(\n enrollment: WebAuthnEnrollment,\n wrappingKey: CryptoKey,\n): Promise<UnlockedKeyring> {\n const iv = base64ToBuffer(enrollment.wrapIv)\n const ciphertext = base64ToBuffer(enrollment.wrappedPayload)\n\n let plaintext: ArrayBuffer\n try {\n plaintext = await globalThis.crypto.subtle.decrypt(\n { name: 'AES-GCM', iv },\n wrappingKey,\n ciphertext,\n )\n } catch {\n throw new ValidationError('WebAuthn decryption failed — the authenticator may have changed or the enrollment may be corrupt.')\n }\n\n const parsed = JSON.parse(new TextDecoder().decode(plaintext)) as {\n userId: string\n displayName: string\n role: Role\n permissions: Record<string, 'rw' | 'ro'>\n deks: Record<string, string>\n salt: string\n }\n\n const deks = new Map<string, CryptoKey>()\n for (const [collName, rawBase64] of Object.entries(parsed.deks)) {\n const dek = await globalThis.crypto.subtle.importKey(\n 'raw',\n base64ToBuffer(rawBase64),\n { name: 'AES-GCM', length: 256 },\n true,\n ['encrypt', 'decrypt'],\n )\n deks.set(collName, dek)\n }\n\n return {\n userId: parsed.userId,\n displayName: parsed.displayName,\n role: parsed.role,\n permissions: parsed.permissions,\n deks,\n kek: null,\n salt: base64ToBuffer(parsed.salt),\n authenticators: [],\n }\n}\n\n// ─── Public API ────────────────────────────────────────────────────────\n\n/**\n * Enroll a WebAuthn credential for the given keyring.\n *\n * The caller must already have an unlocked keyring (from secret auth or\n * an existing session). The WebAuthn credential creation prompt is triggered\n * by this call.\n *\n * Returns a `WebAuthnEnrollment` that should be persisted — typically via\n * `saveEnrollment()` into a noy-db collection.\n *\n * @throws `WebAuthnNotAvailableError` if the environment doesn't support WebAuthn.\n * @throws `WebAuthnCancelledError` if the user cancels the credential creation.\n * @throws `WebAuthnMultiDeviceError` if `requireSingleDevice` is true and the\n * authenticator returned a credential with the BE flag set.\n * @throws `WebAuthnPRFUnavailableError` if the authenticator did not provide\n * PRF output and `options.allowNonPrfInsecure` is not `true` — see\n * that option's doc for why the non-PRF path is refused by default.\n */\nexport async function enrollWebAuthn(\n keyring: UnlockedKeyring,\n vault: string,\n options: WebAuthnEnrollOptions = {},\n): Promise<WebAuthnEnrollment> {\n if (!isWebAuthnAvailable()) {\n throw new WebAuthnNotAvailableError()\n }\n\n const rpId = options.rp?.id ?? (typeof window !== 'undefined' ? window.location.hostname : 'localhost')\n const rpName = options.rp?.name ?? 'NOYDB'\n const timeout = options.timeout ?? 60_000\n\n const challenge = globalThis.crypto.getRandomValues(new Uint8Array(32))\n const userIdBytes = new TextEncoder().encode(keyring.userId)\n\n const authenticatorSelection: AuthenticatorSelectionCriteria = {\n userVerification: 'required',\n residentKey: 'preferred',\n }\n if (options.preferCrossPlatform === true) {\n authenticatorSelection.authenticatorAttachment = 'cross-platform'\n } else if (options.preferCrossPlatform === false) {\n authenticatorSelection.authenticatorAttachment = 'platform'\n }\n\n // Request PRF extension for deterministic key derivation\n const extensionsInput = {\n prf: { eval: { first: PRF_SALT } },\n } as AuthenticationExtensionsClientInputs\n\n const credential = await navigator.credentials.create({\n publicKey: {\n challenge,\n rp: { id: rpId, name: rpName },\n user: {\n id: userIdBytes,\n name: keyring.userId,\n displayName: keyring.displayName,\n },\n pubKeyCredParams: [\n { type: 'public-key', alg: -7 }, // ES256\n { type: 'public-key', alg: -257 }, // RS256\n { type: 'public-key', alg: -8 }, // EdDSA\n ],\n authenticatorSelection,\n extensions: extensionsInput,\n timeout,\n },\n }) as PublicKeyCredential | null\n\n if (!credential) {\n throw new WebAuthnCancelledError('enrollment')\n }\n\n const authData = (credential.response as AuthenticatorAttestationResponse).getAuthenticatorData()\n const beFlag = extractBEFlag(authData)\n\n if (options.requireSingleDevice && beFlag) {\n throw new WebAuthnMultiDeviceError()\n }\n\n // Try to get PRF output from extensions\n const extensions = credential.getClientExtensionResults() as {\n prf?: { results?: { first?: ArrayBuffer } }\n }\n const prfOutput = extensions.prf?.results?.first\n const prfUsed = !!prfOutput\n\n if (!prfUsed && !options.allowNonPrfInsecure) {\n throw new WebAuthnPRFUnavailableError()\n }\n\n const wrappingKey = prfOutput\n ? await deriveKeyFromPRF(prfOutput)\n : await deriveKeyFromRawId(credential.rawId)\n\n const { wrappedPayload, wrapIv } = await wrapKeyringSummary(keyring, wrappingKey)\n\n return {\n _noydb_webauthn: 1,\n vault,\n userId: keyring.userId,\n credentialId: bufferToBase64(credential.rawId),\n prfUsed,\n beFlag,\n requireSingleDevice: options.requireSingleDevice ?? false,\n wrappedPayload,\n wrapIv,\n enrolledAt: new Date().toISOString(),\n rpId,\n }\n}\n\n/**\n * Unlock a vault using a previously enrolled WebAuthn credential.\n *\n * Triggers the WebAuthn assertion prompt. On success, decrypts the keyring\n * payload from the enrollment record and returns an `UnlockedKeyring`.\n *\n * The returned keyring has the same DEKs as at enrollment time. If DEK\n * VALUES have been re-minted since enrollment — `rotateKeys`, not\n * `rotateSecret` — this returns stale DEKs and the caller should detect\n * decryption failures and prompt for re-enrollment. A `rotateSecret`\n * phrase rotation does NOT stale them: it rewraps the same values.\n *\n * Non-PRF unlock (`enrollment.prfUsed === false`) is a presence gate, not\n * zero-knowledge confidentiality — it is kept working here for back-compat\n * with existing non-PRF enrollments; new enrollments refuse that path by\n * default (see `WebAuthnEnrollOptions.allowNonPrfInsecure`).\n *\n * @throws `WebAuthnNotAvailableError` if the environment doesn't support WebAuthn.\n * @throws `WebAuthnCancelledError` if the user cancels the assertion.\n * @throws `WebAuthnMultiDeviceError` if `requireSingleDevice` was set at\n * enrollment and the authenticator data now shows BE=1.\n * @throws `ValidationError` if decryption of the keyring payload fails.\n */\nexport async function unlockWebAuthn(\n enrollment: WebAuthnEnrollment,\n options: WebAuthnUnlockOptions = {},\n): Promise<UnlockedKeyring> {\n if (!isWebAuthnAvailable()) {\n throw new WebAuthnNotAvailableError()\n }\n\n const timeout = options.timeout ?? 60_000\n const credentialId = base64ToBuffer(enrollment.credentialId)\n // Omitted, not defaulted: sending `rpId: undefined` is not the same as\n // sending nothing, and an old record has no RP ID to send.\n const rpId = options.rpId ?? enrollment.rpId\n\n const extensionsInput = (enrollment.prfUsed\n ? { prf: { eval: { first: PRF_SALT } } }\n : {}\n ) as AuthenticationExtensionsClientInputs\n\n const assertion = await navigator.credentials.get({\n publicKey: {\n challenge: globalThis.crypto.getRandomValues(new Uint8Array(32)),\n allowCredentials: [{ type: 'public-key', id: credentialId }],\n userVerification: 'required',\n extensions: extensionsInput,\n timeout,\n ...(rpId !== undefined ? { rpId } : {}),\n },\n }) as PublicKeyCredential | null\n\n if (!assertion) {\n throw new WebAuthnCancelledError('assertion')\n }\n\n // BE-flag guard at assertion time\n const authData = (assertion.response as AuthenticatorAssertionResponse).authenticatorData\n const beFlag = extractBEFlag(authData)\n if (enrollment.requireSingleDevice && beFlag) {\n throw new WebAuthnMultiDeviceError()\n }\n\n // Derive the wrapping key using the same method as enrollment\n let wrappingKey: CryptoKey\n if (enrollment.prfUsed) {\n const extensions = assertion.getClientExtensionResults() as {\n prf?: { results?: { first?: ArrayBuffer } }\n }\n const prfOutput = extensions.prf?.results?.first\n if (!prfOutput) {\n throw new ValidationError(\n 'PRF extension output not available at assertion time. ' +\n 'The authenticator may not support PRF. Re-enroll without PRF support.',\n )\n }\n wrappingKey = await deriveKeyFromPRF(prfOutput)\n } else {\n wrappingKey = await deriveKeyFromRawId(assertion.rawId)\n }\n\n return unwrapKeyringSummary(enrollment, wrappingKey)\n}\n\n/**\n * `SlotRewrapCeremony` for WebAuthn slots — used by hub's\n * `rotateSecret({ slotCeremonies: { [slotId]: webAuthnSlotRewrapCeremony } })`\n * to preserve a tier-2 WebAuthn enrollment across a tier-1 phrase\n * rotation without requiring re-enrollment of the credential.\n *\n * The credential itself is unaffected by phrase rotation — the\n * wrapping key derived from PRF (or rawId fallback) is bound to the\n * authenticator, not to the secret. What needs to change is the\n * **wrapped payload**: the encrypted blob the slot's `wrapped_kek`\n * field holds. After rotation the old payload is wrapped under the\n * OLD KEK, so hub can no longer thread it through the rotated\n * keyring; the new payload must hold `ctx.newDeks`.\n *\n * ⚠️ **The old payload's DEK VALUES are not stale — only its wrapping\n * is.** `rotateSecret` rewraps the SAME DEKs under a freshly-derived\n * KEK; only `rotateKeys` re-mints DEK values. Records are encrypted\n * with DEKs, so a captured `wrappedPayload` keeps decrypting\n * everything across any number of phrase rotations. An earlier\n * revision of this comment said \"now stale because rotateSecret\n * rewrapped them under a fresh KEK\", which reads as though rotation\n * invalidated the captured blob. It does not, and nothing in this\n * ceremony is a revocation step.\n *\n * ## Revoking a credential — this ceremony is a REQUIRED INPUT to it\n *\n * Hub's `revokeAuthenticator` is the real revocation, and it is three steps:\n * `removeAuthenticator` (hides the slot), `rotateKeys` (mints fresh DEKs —\n * the step that actually revokes), then `rotateSecret` with a\n * `slotCeremonies` entry for every slot that STAYS.\n *\n * ⛔ **A remaining slot handed no ceremony is DROPPED, not preserved.** After\n * the `rotateKeys` step its blob wraps DEKs that no longer exist, so keeping\n * it would leave a credential that silently fails. Its holder must re-enrol.\n *\n * So revoking ONE credential means supplying `webAuthnSlotRewrapCeremony` for\n * EVERY WebAuthn slot you intend to keep — and each requires that\n * credential to be present (a live assertion on the authenticator, which means the user in front of the device). Re-wrapping a credential\n * is the credential's own operation; there is no way to do it on the\n * holder's behalf.\n *\n * ⚠️ **Not in any published hub at the time of writing.**\n * `revokeAuthenticator` landed on noy-db `main` in `0612b4ac`, AFTER\n * `@noy-db/hub@0.7.1-pre.0` was cut and published — so it is in neither\n * `@latest` (0.7.0) nor `@next` (0.7.1-pre.0). Ask npm before promising it to\n * a consumer; do not infer its version from hub's `main`.\n *\n * Single ceremony, two operations:\n * 1. Trigger one WebAuthn assertion to derive the wrapping key.\n * 2. Decrypt the OLD `wrapped_kek` to extract identity fields\n * (userId, displayName, role, permissions, salt) — these don't\n * change on rotation, so they're carried verbatim into the new\n * payload.\n * 3. Encrypt the NEW payload (same identity + `ctx.newDeks`) under\n * the same wrapping key, with a fresh IV.\n * 4. Return `EnrollAuthenticatorOptions` preserving `oldSlot.id`\n * and `method: 'webauthn'` (hub validates these to prevent\n * slot-type swap mid-rotation).\n *\n * Previously a workaround was needed — detect dropped slots after\n * rotate and offer \"Re-enrol Touch ID\" inline. This ceremony\n * eliminates that step.\n *\n * Out of scope: tier-3 PIN. PIN state lives in `QuickUnlockStore`\n * (RAM-only), not in `KeyringFile.authenticators[]`, so\n * `slotCeremonies` doesn't apply. Clear PIN state on rotate; let\n * the user set a fresh PIN via `db.enrollUnlock` immediately after.\n *\n * @throws {WebAuthnNotAvailableError} when the environment lacks WebAuthn.\n * @throws {WebAuthnCancelledError} when the user dismisses the assertion.\n * @throws {WebAuthnMultiDeviceError} when `meta.requireSingleDevice` was\n * set at enrollment and the authenticator now reports BE=1.\n * @throws {ValidationError} when `oldSlot.method !== 'webauthn'`,\n * when required `meta` fields are missing, or when the old\n * payload fails to decrypt (credential changed / payload\n * tampered).\n * @throws {WebAuthnPRFUnavailableError} when the old slot is non-PRF and\n * `options.allowNonPrfInsecure` is not `true` — rewrapping a\n * non-PRF slot produces a fresh self-decrypting payload, so it\n * requires the same explicit acknowledgement as enrollment.\n *\n * @see passwordSlotRewrapCeremony — the password parallel.\n */\nexport async function webAuthnSlotRewrapCeremony(\n ctx: SlotRewrapContext,\n options: WebAuthnUnlockOptions = {},\n): Promise<EnrollAuthenticatorOptions> {\n if (ctx.oldSlot.method !== 'webauthn') {\n throw new ValidationError(\n `webAuthnSlotRewrapCeremony: oldSlot.method is \"${ctx.oldSlot.method}\"; expected \"webauthn\". ` +\n 'This ceremony only handles WebAuthn slots — pair other methods with their own helpers.',\n )\n }\n if (ctx.oldSlot.wrapKind === 'deks') {\n throw new ValidationError(\n 'webAuthnSlotRewrapCeremony: oldSlot is a wrap-DEKs slot; expected wrap-KEK. ' +\n 'WebAuthn slots use the wrap-KEK variant; mismatch indicates the slot was ' +\n 'enrolled by a different on-* package.',\n )\n }\n if (!isWebAuthnAvailable()) {\n throw new WebAuthnNotAvailableError()\n }\n\n // Pull the per-slot fields needed for assertion + decrypt. These were\n // written into `meta` by `enrollWebAuthn` (via `db.enrollWebAuthn`).\n const meta = ctx.oldSlot.meta as {\n credentialId?: unknown\n wrapIv?: unknown\n prfUsed?: unknown\n beFlag?: unknown\n requireSingleDevice?: unknown\n rpId?: unknown\n }\n if (typeof meta.credentialId !== 'string' || meta.credentialId.length === 0) {\n throw new ValidationError(\n 'webAuthnSlotRewrapCeremony: oldSlot.meta.credentialId is missing or invalid. ' +\n 'The slot was not enrolled via @noy-db/on-webauthn.',\n )\n }\n if (typeof meta.wrapIv !== 'string' || meta.wrapIv.length === 0) {\n throw new ValidationError(\n 'webAuthnSlotRewrapCeremony: oldSlot.meta.wrapIv is missing — the slot may be ' +\n 'pre-#16 (synthetic-keyring shape) and must be re-enrolled via db.enrollWebAuthn.',\n )\n }\n const prfUsed = meta.prfUsed === true\n\n if (!prfUsed && !options.allowNonPrfInsecure) {\n throw new WebAuthnPRFUnavailableError()\n }\n\n // 1. Trigger the assertion. Same path `unlockWebAuthn` uses.\n const credentialIdBuf = base64ToBuffer(meta.credentialId)\n const timeout = options.timeout ?? 60_000\n // `meta` is written by hub's `db.enrollWebAuthn`, not by this package, so\n // an RP ID may simply not be there. Read it defensively and let the\n // caller override — same omit-don't-default rule as `unlockWebAuthn`.\n const rpId = options.rpId ?? (typeof meta.rpId === 'string' && meta.rpId.length > 0 ? meta.rpId : undefined)\n const extensionsInput = (prfUsed\n ? { prf: { eval: { first: PRF_SALT } } }\n : {}\n ) as AuthenticationExtensionsClientInputs\n\n const assertion = await navigator.credentials.get({\n publicKey: {\n challenge: globalThis.crypto.getRandomValues(new Uint8Array(32)),\n allowCredentials: [{ type: 'public-key', id: credentialIdBuf }],\n userVerification: 'required',\n extensions: extensionsInput,\n timeout,\n ...(rpId !== undefined ? { rpId } : {}),\n },\n }) as PublicKeyCredential | null\n\n if (!assertion) {\n throw new WebAuthnCancelledError('assertion')\n }\n\n // BE-flag guard at assertion time — same rule unlockWebAuthn enforces.\n const authData = (assertion.response as AuthenticatorAssertionResponse).authenticatorData\n const beFlag = extractBEFlag(authData)\n if (meta.requireSingleDevice === true && beFlag) {\n throw new WebAuthnMultiDeviceError()\n }\n\n // 2. Derive the wrapping key — deterministic per credential, so this\n // is the SAME key the original enrollment used.\n let wrappingKey: CryptoKey\n if (prfUsed) {\n const extensions = assertion.getClientExtensionResults() as {\n prf?: { results?: { first?: ArrayBuffer } }\n }\n const prfOutput = extensions.prf?.results?.first\n if (!prfOutput) {\n throw new ValidationError(\n 'webAuthnSlotRewrapCeremony: PRF output missing at assertion time. ' +\n 'The authenticator may have lost PRF capability — re-enrol the slot via db.enrollWebAuthn.',\n )\n }\n wrappingKey = await deriveKeyFromPRF(prfOutput)\n } else {\n wrappingKey = await deriveKeyFromRawId(assertion.rawId)\n }\n\n // 3. Decrypt the OLD wrapped_kek to extract carry-through identity\n // fields. The slot's wrapped_kek IS the old wrappedPayload\n // (mapping established by db.enrollWebAuthn at noydb.ts:1178).\n const oldIv = base64ToBuffer(meta.wrapIv)\n const oldCiphertext = base64ToBuffer(ctx.oldSlot.wrapped_kek)\n let oldPlaintext: ArrayBuffer\n try {\n oldPlaintext = await globalThis.crypto.subtle.decrypt(\n { name: 'AES-GCM', iv: oldIv },\n wrappingKey,\n oldCiphertext,\n )\n } catch {\n throw new ValidationError(\n 'webAuthnSlotRewrapCeremony: failed to decrypt the old wrapped payload. ' +\n 'The wrapping key derived from this credential does not match — possible ' +\n 'cross-tenant slot mix-up or a corrupted enrollment.',\n )\n }\n const oldPayload = JSON.parse(new TextDecoder().decode(oldPlaintext)) as {\n userId: string\n displayName: string\n role: Role\n permissions: Record<string, 'rw' | 'ro'>\n deks: Record<string, string>\n salt: string\n }\n\n // 4. Build the NEW payload — identity fields preserved, deks\n // replaced with ctx.newDeks (rewrapped under the new KEK in the\n // keyring file; here we serialize them as raw bytes for the\n // self-contained webauthn-side reconstruction).\n const newDekMap: Record<string, string> = {}\n for (const [collName, dek] of ctx.newDeks) {\n const raw = await globalThis.crypto.subtle.exportKey('raw', dek)\n newDekMap[collName] = bufferToBase64(raw)\n }\n const newPayload = JSON.stringify({\n userId: oldPayload.userId,\n displayName: oldPayload.displayName,\n role: oldPayload.role,\n permissions: oldPayload.permissions,\n deks: newDekMap,\n salt: oldPayload.salt,\n })\n\n // 5. Encrypt with the same wrapping key under a fresh IV.\n const newIv = globalThis.crypto.getRandomValues(new Uint8Array(12))\n const newCiphertext = await globalThis.crypto.subtle.encrypt(\n { name: 'AES-GCM', iv: newIv },\n wrappingKey,\n new TextEncoder().encode(newPayload),\n )\n\n // 6. Return EnrollAuthenticatorOptions preserving id + method.\n // Hub's rotate validates these — a mismatch throws ValidationError\n // (slot-type swap defense; see SlotRewrapContext docstring).\n return {\n id: ctx.oldSlot.id,\n method: 'webauthn',\n wrapped_kek: bufferToBase64(newCiphertext),\n enrolled_via_tier: ctx.oldSlot.enrolled_via_tier,\n meta: {\n credentialId: meta.credentialId,\n wrapIv: bufferToBase64(newIv),\n prfUsed,\n beFlag,\n requireSingleDevice: meta.requireSingleDevice === true,\n },\n }\n}\n\n/**\n * Check whether a `WebAuthnEnrollment` record looks well-formed.\n * Does not perform any cryptographic verification.\n */\nexport function isValidEnrollment(value: unknown): value is WebAuthnEnrollment {\n if (!value || typeof value !== 'object') return false\n const e = value as Record<string, unknown>\n return (\n e._noydb_webauthn === 1 &&\n typeof e.vault === 'string' &&\n typeof e.userId === 'string' &&\n typeof e.credentialId === 'string' &&\n typeof e.wrappedPayload === 'string' &&\n typeof e.wrapIv === 'string'\n )\n}\n"],"mappings":"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","names":["ValidationError"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noy-db/on-webauthn",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "WebAuthn hardware-key keyrings for noy-db — Touch ID, Face ID, Windows Hello, YubiKey, FIDO2 passkeys",
5
5
  "license": "Apache-2.0",
6
6
  "author": "vLannaAi <vicio@lanna.ai>",
@@ -33,11 +33,11 @@
33
33
  "node": ">=22.0.0"
34
34
  },
35
35
  "peerDependencies": {
36
- "@noy-db/hub": "^0.7.0 || ^0.7.1-pre.0 || ^0.8.0-pre.0"
36
+ "@noy-db/hub": "^0.7.0 || ^0.7.1-pre.0 || ^0.8.0-pre.0 || ^0.9.0-pre.1"
37
37
  },
38
38
  "devDependencies": {
39
- "@noy-db/hub": "0.8.0",
40
- "@noy-db/test-ceremony-conformance": "0.8.0",
39
+ "@noy-db/hub": "0.9.0",
40
+ "@noy-db/ports": "0.9.0",
41
41
  "happy-dom": "^18.0.0"
42
42
  },
43
43
  "keywords": [
@@ -59,10 +59,11 @@
59
59
  "access": "public",
60
60
  "tag": "latest"
61
61
  },
62
+ "dependencies": {},
62
63
  "scripts": {
63
64
  "build": "tsup",
64
65
  "test": "vitest run",
65
66
  "lint": "eslint src/",
66
- "typecheck": "tsc --noEmit"
67
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json"
67
68
  }
68
69
  }