@noy-db/on-webauthn 0.1.0-pre.8 → 0.1.0-pre.9
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/dist/index.cjs +111 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +50 -2
- package/dist/index.d.ts +50 -2
- package/dist/index.js +109 -1
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -28,7 +28,8 @@ __export(index_exports, {
|
|
|
28
28
|
enrollWebAuthn: () => enrollWebAuthn,
|
|
29
29
|
isValidEnrollment: () => isValidEnrollment,
|
|
30
30
|
isWebAuthnAvailable: () => isWebAuthnAvailable,
|
|
31
|
-
unlockWebAuthn: () => unlockWebAuthn
|
|
31
|
+
unlockWebAuthn: () => unlockWebAuthn,
|
|
32
|
+
webAuthnSlotRewrapCeremony: () => webAuthnSlotRewrapCeremony
|
|
32
33
|
});
|
|
33
34
|
module.exports = __toCommonJS(index_exports);
|
|
34
35
|
var import_hub = require("@noy-db/hub");
|
|
@@ -284,6 +285,113 @@ async function unlockWebAuthn(enrollment, options = {}) {
|
|
|
284
285
|
}
|
|
285
286
|
return unwrapKeyringSummary(enrollment, wrappingKey);
|
|
286
287
|
}
|
|
288
|
+
async function webAuthnSlotRewrapCeremony(ctx, options = {}) {
|
|
289
|
+
if (ctx.oldSlot.method !== "webauthn") {
|
|
290
|
+
throw new import_hub2.ValidationError(
|
|
291
|
+
`webAuthnSlotRewrapCeremony: oldSlot.method is "${ctx.oldSlot.method}"; expected "webauthn". This ceremony only handles WebAuthn slots \u2014 pair other methods with their own helpers.`
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (ctx.oldSlot.wrapKind === "deks") {
|
|
295
|
+
throw new import_hub2.ValidationError(
|
|
296
|
+
"webAuthnSlotRewrapCeremony: oldSlot is a wrap-DEKs slot; expected wrap-KEK. WebAuthn slots use the wrap-KEK variant; mismatch indicates the slot was enrolled by a different on-* package."
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
if (!isWebAuthnAvailable()) {
|
|
300
|
+
throw new WebAuthnNotAvailableError();
|
|
301
|
+
}
|
|
302
|
+
const meta = ctx.oldSlot.meta;
|
|
303
|
+
if (typeof meta.credentialId !== "string" || meta.credentialId.length === 0) {
|
|
304
|
+
throw new import_hub2.ValidationError(
|
|
305
|
+
"webAuthnSlotRewrapCeremony: oldSlot.meta.credentialId is missing or invalid. The slot was not enrolled via @noy-db/on-webauthn."
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
if (typeof meta.wrapIv !== "string" || meta.wrapIv.length === 0) {
|
|
309
|
+
throw new import_hub2.ValidationError(
|
|
310
|
+
"webAuthnSlotRewrapCeremony: oldSlot.meta.wrapIv is missing \u2014 the slot may be pre-#16 (synthetic-keyring shape) and must be re-enrolled via db.enrollWebAuthn."
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
const prfUsed = meta.prfUsed === true;
|
|
314
|
+
const credentialIdBuf = (0, import_hub.base64ToBuffer)(meta.credentialId);
|
|
315
|
+
const timeout = options.timeout ?? 6e4;
|
|
316
|
+
const extensionsInput = prfUsed ? { prf: { eval: { first: PRF_SALT } } } : {};
|
|
317
|
+
const assertion = await navigator.credentials.get({
|
|
318
|
+
publicKey: {
|
|
319
|
+
challenge: globalThis.crypto.getRandomValues(new Uint8Array(32)),
|
|
320
|
+
allowCredentials: [{ type: "public-key", id: credentialIdBuf }],
|
|
321
|
+
userVerification: "required",
|
|
322
|
+
extensions: extensionsInput,
|
|
323
|
+
timeout
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
if (!assertion) {
|
|
327
|
+
throw new WebAuthnCancelledError("assertion");
|
|
328
|
+
}
|
|
329
|
+
const authData = assertion.response.authenticatorData;
|
|
330
|
+
const beFlag = extractBEFlag(authData);
|
|
331
|
+
if (meta.requireSingleDevice === true && beFlag) {
|
|
332
|
+
throw new WebAuthnMultiDeviceError();
|
|
333
|
+
}
|
|
334
|
+
let wrappingKey;
|
|
335
|
+
if (prfUsed) {
|
|
336
|
+
const extensions = assertion.getClientExtensionResults();
|
|
337
|
+
const prfOutput = extensions.prf?.results?.first;
|
|
338
|
+
if (!prfOutput) {
|
|
339
|
+
throw new import_hub2.ValidationError(
|
|
340
|
+
"webAuthnSlotRewrapCeremony: PRF output missing at assertion time. The authenticator may have lost PRF capability \u2014 re-enrol the slot via db.enrollWebAuthn."
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
wrappingKey = await deriveKeyFromPRF(prfOutput);
|
|
344
|
+
} else {
|
|
345
|
+
wrappingKey = await deriveKeyFromRawId(assertion.rawId);
|
|
346
|
+
}
|
|
347
|
+
const oldIv = (0, import_hub.base64ToBuffer)(meta.wrapIv);
|
|
348
|
+
const oldCiphertext = (0, import_hub.base64ToBuffer)(ctx.oldSlot.wrapped_kek);
|
|
349
|
+
let oldPlaintext;
|
|
350
|
+
try {
|
|
351
|
+
oldPlaintext = await globalThis.crypto.subtle.decrypt(
|
|
352
|
+
{ name: "AES-GCM", iv: oldIv },
|
|
353
|
+
wrappingKey,
|
|
354
|
+
oldCiphertext
|
|
355
|
+
);
|
|
356
|
+
} catch {
|
|
357
|
+
throw new import_hub2.ValidationError(
|
|
358
|
+
"webAuthnSlotRewrapCeremony: failed to decrypt the old wrapped payload. The wrapping key derived from this credential does not match \u2014 possible cross-tenant slot mix-up or a corrupted enrollment."
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
const oldPayload = JSON.parse(new TextDecoder().decode(oldPlaintext));
|
|
362
|
+
const newDekMap = {};
|
|
363
|
+
for (const [collName, dek] of ctx.newDeks) {
|
|
364
|
+
const raw = await globalThis.crypto.subtle.exportKey("raw", dek);
|
|
365
|
+
newDekMap[collName] = (0, import_hub.bufferToBase64)(raw);
|
|
366
|
+
}
|
|
367
|
+
const newPayload = JSON.stringify({
|
|
368
|
+
userId: oldPayload.userId,
|
|
369
|
+
displayName: oldPayload.displayName,
|
|
370
|
+
role: oldPayload.role,
|
|
371
|
+
permissions: oldPayload.permissions,
|
|
372
|
+
deks: newDekMap,
|
|
373
|
+
salt: oldPayload.salt
|
|
374
|
+
});
|
|
375
|
+
const newIv = globalThis.crypto.getRandomValues(new Uint8Array(12));
|
|
376
|
+
const newCiphertext = await globalThis.crypto.subtle.encrypt(
|
|
377
|
+
{ name: "AES-GCM", iv: newIv },
|
|
378
|
+
wrappingKey,
|
|
379
|
+
new TextEncoder().encode(newPayload)
|
|
380
|
+
);
|
|
381
|
+
return {
|
|
382
|
+
id: ctx.oldSlot.id,
|
|
383
|
+
method: "webauthn",
|
|
384
|
+
wrapped_kek: (0, import_hub.bufferToBase64)(newCiphertext),
|
|
385
|
+
enrolled_via_tier: ctx.oldSlot.enrolled_via_tier,
|
|
386
|
+
meta: {
|
|
387
|
+
credentialId: meta.credentialId,
|
|
388
|
+
wrapIv: (0, import_hub.bufferToBase64)(newIv),
|
|
389
|
+
prfUsed,
|
|
390
|
+
beFlag,
|
|
391
|
+
requireSingleDevice: meta.requireSingleDevice === true
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
}
|
|
287
395
|
function isValidEnrollment(value) {
|
|
288
396
|
if (!value || typeof value !== "object") return false;
|
|
289
397
|
const e = value;
|
|
@@ -299,6 +407,7 @@ function isValidEnrollment(value) {
|
|
|
299
407
|
enrollWebAuthn,
|
|
300
408
|
isValidEnrollment,
|
|
301
409
|
isWebAuthnAvailable,
|
|
302
|
-
unlockWebAuthn
|
|
410
|
+
unlockWebAuthn,
|
|
411
|
+
webAuthnSlotRewrapCeremony
|
|
303
412
|
});
|
|
304
413
|
//# sourceMappingURL=index.cjs.map
|
package/dist/index.cjs.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-passphrase 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 falls back to HKDF-SHA256 over the credential's `rawId` —\n * the same approach as the pre-existing `@noy-db/core` biometric module.\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 (passphrase 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 { UnlockedKeyring, Role } from '@noy-db/hub'\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 (passphrase 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 passphrase 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 (as a non-fatal warning, caught internally) when the PRF extension\n * is not supported by the authenticator.\n *\n * NOYDB prefers PRF for key derivation because it produces a\n * credential-bound output that is deterministic and not extractable from\n * the authenticator. When PRF is unavailable, enrollment falls back to\n * HKDF over the credential's `rawId` — weaker binding, but still functional.\n * This error is caught at enrollment time; callers only see it if they\n * explicitly opt into strict PRF-only mode.\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. ' +\n 'Enrollment will fall back to rawId-based key derivation. ' +\n 'This provides weaker binding to the specific authenticator.',\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\n/** Options for `unlockWebAuthn()`. */\nexport interface WebAuthnUnlockOptions {\n /** WebAuthn timeout in milliseconds. Default: 60_000. */\n timeout?: number\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 * Weaker than PRF (rawId may be observable to the server) but universally supported.\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 passphrase 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 */\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 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 DEKs\n * have been rotated since enrollment, this will return stale DEKs — the\n * caller should detect decryption failures and prompt for re-enrollment.\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 as BufferSource }],\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 * 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":["import_hub"]}
|
|
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-passphrase 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 falls back to HKDF-SHA256 over the credential's `rawId` —\n * the same approach as the pre-existing `@noy-db/core` biometric module.\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 (passphrase 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 { UnlockedKeyring, Role, EnrollAuthenticatorOptions, SlotRewrapContext } from '@noy-db/hub'\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 (passphrase 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 passphrase 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 (as a non-fatal warning, caught internally) when the PRF extension\n * is not supported by the authenticator.\n *\n * NOYDB prefers PRF for key derivation because it produces a\n * credential-bound output that is deterministic and not extractable from\n * the authenticator. When PRF is unavailable, enrollment falls back to\n * HKDF over the credential's `rawId` — weaker binding, but still functional.\n * This error is caught at enrollment time; callers only see it if they\n * explicitly opt into strict PRF-only mode.\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. ' +\n 'Enrollment will fall back to rawId-based key derivation. ' +\n 'This provides weaker binding to the specific authenticator.',\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\n/** Options for `unlockWebAuthn()`. */\nexport interface WebAuthnUnlockOptions {\n /** WebAuthn timeout in milliseconds. Default: 60_000. */\n timeout?: number\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 * Weaker than PRF (rawId may be observable to the server) but universally supported.\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 passphrase 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 */\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 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 DEKs\n * have been rotated since enrollment, this will return stale DEKs — the\n * caller should detect decryption failures and prompt for re-enrollment.\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 as BufferSource }],\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 * `rotatePassphrase({ 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 (#56).\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 passphrase. 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 still has the old\n * DEKs (now stale because rotatePassphrase rewrapped them under a\n * fresh KEK); the new payload must hold the freshly rewrapped\n * `ctx.newDeks`.\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 * Niwat (consumer) shipped a workaround at #44 — detect dropped slots\n * after 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 *\n * @see #56 #29 — the ceremony plumbing this fills in.\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 // 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 as BufferSource }],\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":["import_hub"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { UnlockedKeyring } from '@noy-db/hub';
|
|
1
|
+
import { UnlockedKeyring, SlotRewrapContext, EnrollAuthenticatorOptions } from '@noy-db/hub';
|
|
2
2
|
export { ValidationError } from '@noy-db/hub';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -210,10 +210,58 @@ declare function enrollWebAuthn(keyring: UnlockedKeyring, vault: string, options
|
|
|
210
210
|
* @throws `ValidationError` if decryption of the keyring payload fails.
|
|
211
211
|
*/
|
|
212
212
|
declare function unlockWebAuthn(enrollment: WebAuthnEnrollment, options?: WebAuthnUnlockOptions): Promise<UnlockedKeyring>;
|
|
213
|
+
/**
|
|
214
|
+
* `SlotRewrapCeremony` for WebAuthn slots — used by hub's
|
|
215
|
+
* `rotatePassphrase({ slotCeremonies: { [slotId]: webAuthnSlotRewrapCeremony } })`
|
|
216
|
+
* to preserve a tier-2 WebAuthn enrollment across a tier-1 phrase
|
|
217
|
+
* rotation without requiring re-enrollment of the credential (#56).
|
|
218
|
+
*
|
|
219
|
+
* The credential itself is unaffected by phrase rotation — the
|
|
220
|
+
* wrapping key derived from PRF (or rawId fallback) is bound to the
|
|
221
|
+
* authenticator, not to the passphrase. What needs to change is the
|
|
222
|
+
* **wrapped payload**: the encrypted blob the slot's `wrapped_kek`
|
|
223
|
+
* field holds. After rotation, the old payload still has the old
|
|
224
|
+
* DEKs (now stale because rotatePassphrase rewrapped them under a
|
|
225
|
+
* fresh KEK); the new payload must hold the freshly rewrapped
|
|
226
|
+
* `ctx.newDeks`.
|
|
227
|
+
*
|
|
228
|
+
* Single ceremony, two operations:
|
|
229
|
+
* 1. Trigger one WebAuthn assertion to derive the wrapping key.
|
|
230
|
+
* 2. Decrypt the OLD `wrapped_kek` to extract identity fields
|
|
231
|
+
* (userId, displayName, role, permissions, salt) — these don't
|
|
232
|
+
* change on rotation, so they're carried verbatim into the new
|
|
233
|
+
* payload.
|
|
234
|
+
* 3. Encrypt the NEW payload (same identity + `ctx.newDeks`) under
|
|
235
|
+
* the same wrapping key, with a fresh IV.
|
|
236
|
+
* 4. Return `EnrollAuthenticatorOptions` preserving `oldSlot.id`
|
|
237
|
+
* and `method: 'webauthn'` (hub validates these to prevent
|
|
238
|
+
* slot-type swap mid-rotation).
|
|
239
|
+
*
|
|
240
|
+
* Niwat (consumer) shipped a workaround at #44 — detect dropped slots
|
|
241
|
+
* after rotate and offer "Re-enrol Touch ID" inline. This ceremony
|
|
242
|
+
* eliminates that step.
|
|
243
|
+
*
|
|
244
|
+
* Out of scope: tier-3 PIN. PIN state lives in `QuickUnlockStore`
|
|
245
|
+
* (RAM-only), not in `KeyringFile.authenticators[]`, so
|
|
246
|
+
* `slotCeremonies` doesn't apply. Clear PIN state on rotate; let
|
|
247
|
+
* the user set a fresh PIN via `db.enrollUnlock` immediately after.
|
|
248
|
+
*
|
|
249
|
+
* @throws {WebAuthnNotAvailableError} when the environment lacks WebAuthn.
|
|
250
|
+
* @throws {WebAuthnCancelledError} when the user dismisses the assertion.
|
|
251
|
+
* @throws {WebAuthnMultiDeviceError} when `meta.requireSingleDevice` was
|
|
252
|
+
* set at enrollment and the authenticator now reports BE=1.
|
|
253
|
+
* @throws {ValidationError} when `oldSlot.method !== 'webauthn'`,
|
|
254
|
+
* when required `meta` fields are missing, or when the old
|
|
255
|
+
* payload fails to decrypt (credential changed / payload
|
|
256
|
+
* tampered).
|
|
257
|
+
*
|
|
258
|
+
* @see #56 #29 — the ceremony plumbing this fills in.
|
|
259
|
+
*/
|
|
260
|
+
declare function webAuthnSlotRewrapCeremony(ctx: SlotRewrapContext, options?: WebAuthnUnlockOptions): Promise<EnrollAuthenticatorOptions>;
|
|
213
261
|
/**
|
|
214
262
|
* Check whether a `WebAuthnEnrollment` record looks well-formed.
|
|
215
263
|
* Does not perform any cryptographic verification.
|
|
216
264
|
*/
|
|
217
265
|
declare function isValidEnrollment(value: unknown): value is WebAuthnEnrollment;
|
|
218
266
|
|
|
219
|
-
export { WebAuthnCancelledError, type WebAuthnEnrollOptions, type WebAuthnEnrollment, WebAuthnMultiDeviceError, WebAuthnNotAvailableError, WebAuthnPRFUnavailableError, type WebAuthnUnlockOptions, enrollWebAuthn, isValidEnrollment, isWebAuthnAvailable, unlockWebAuthn };
|
|
267
|
+
export { WebAuthnCancelledError, type WebAuthnEnrollOptions, type WebAuthnEnrollment, WebAuthnMultiDeviceError, WebAuthnNotAvailableError, WebAuthnPRFUnavailableError, type WebAuthnUnlockOptions, enrollWebAuthn, isValidEnrollment, isWebAuthnAvailable, unlockWebAuthn, webAuthnSlotRewrapCeremony };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { UnlockedKeyring } from '@noy-db/hub';
|
|
1
|
+
import { UnlockedKeyring, SlotRewrapContext, EnrollAuthenticatorOptions } from '@noy-db/hub';
|
|
2
2
|
export { ValidationError } from '@noy-db/hub';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -210,10 +210,58 @@ declare function enrollWebAuthn(keyring: UnlockedKeyring, vault: string, options
|
|
|
210
210
|
* @throws `ValidationError` if decryption of the keyring payload fails.
|
|
211
211
|
*/
|
|
212
212
|
declare function unlockWebAuthn(enrollment: WebAuthnEnrollment, options?: WebAuthnUnlockOptions): Promise<UnlockedKeyring>;
|
|
213
|
+
/**
|
|
214
|
+
* `SlotRewrapCeremony` for WebAuthn slots — used by hub's
|
|
215
|
+
* `rotatePassphrase({ slotCeremonies: { [slotId]: webAuthnSlotRewrapCeremony } })`
|
|
216
|
+
* to preserve a tier-2 WebAuthn enrollment across a tier-1 phrase
|
|
217
|
+
* rotation without requiring re-enrollment of the credential (#56).
|
|
218
|
+
*
|
|
219
|
+
* The credential itself is unaffected by phrase rotation — the
|
|
220
|
+
* wrapping key derived from PRF (or rawId fallback) is bound to the
|
|
221
|
+
* authenticator, not to the passphrase. What needs to change is the
|
|
222
|
+
* **wrapped payload**: the encrypted blob the slot's `wrapped_kek`
|
|
223
|
+
* field holds. After rotation, the old payload still has the old
|
|
224
|
+
* DEKs (now stale because rotatePassphrase rewrapped them under a
|
|
225
|
+
* fresh KEK); the new payload must hold the freshly rewrapped
|
|
226
|
+
* `ctx.newDeks`.
|
|
227
|
+
*
|
|
228
|
+
* Single ceremony, two operations:
|
|
229
|
+
* 1. Trigger one WebAuthn assertion to derive the wrapping key.
|
|
230
|
+
* 2. Decrypt the OLD `wrapped_kek` to extract identity fields
|
|
231
|
+
* (userId, displayName, role, permissions, salt) — these don't
|
|
232
|
+
* change on rotation, so they're carried verbatim into the new
|
|
233
|
+
* payload.
|
|
234
|
+
* 3. Encrypt the NEW payload (same identity + `ctx.newDeks`) under
|
|
235
|
+
* the same wrapping key, with a fresh IV.
|
|
236
|
+
* 4. Return `EnrollAuthenticatorOptions` preserving `oldSlot.id`
|
|
237
|
+
* and `method: 'webauthn'` (hub validates these to prevent
|
|
238
|
+
* slot-type swap mid-rotation).
|
|
239
|
+
*
|
|
240
|
+
* Niwat (consumer) shipped a workaround at #44 — detect dropped slots
|
|
241
|
+
* after rotate and offer "Re-enrol Touch ID" inline. This ceremony
|
|
242
|
+
* eliminates that step.
|
|
243
|
+
*
|
|
244
|
+
* Out of scope: tier-3 PIN. PIN state lives in `QuickUnlockStore`
|
|
245
|
+
* (RAM-only), not in `KeyringFile.authenticators[]`, so
|
|
246
|
+
* `slotCeremonies` doesn't apply. Clear PIN state on rotate; let
|
|
247
|
+
* the user set a fresh PIN via `db.enrollUnlock` immediately after.
|
|
248
|
+
*
|
|
249
|
+
* @throws {WebAuthnNotAvailableError} when the environment lacks WebAuthn.
|
|
250
|
+
* @throws {WebAuthnCancelledError} when the user dismisses the assertion.
|
|
251
|
+
* @throws {WebAuthnMultiDeviceError} when `meta.requireSingleDevice` was
|
|
252
|
+
* set at enrollment and the authenticator now reports BE=1.
|
|
253
|
+
* @throws {ValidationError} when `oldSlot.method !== 'webauthn'`,
|
|
254
|
+
* when required `meta` fields are missing, or when the old
|
|
255
|
+
* payload fails to decrypt (credential changed / payload
|
|
256
|
+
* tampered).
|
|
257
|
+
*
|
|
258
|
+
* @see #56 #29 — the ceremony plumbing this fills in.
|
|
259
|
+
*/
|
|
260
|
+
declare function webAuthnSlotRewrapCeremony(ctx: SlotRewrapContext, options?: WebAuthnUnlockOptions): Promise<EnrollAuthenticatorOptions>;
|
|
213
261
|
/**
|
|
214
262
|
* Check whether a `WebAuthnEnrollment` record looks well-formed.
|
|
215
263
|
* Does not perform any cryptographic verification.
|
|
216
264
|
*/
|
|
217
265
|
declare function isValidEnrollment(value: unknown): value is WebAuthnEnrollment;
|
|
218
266
|
|
|
219
|
-
export { WebAuthnCancelledError, type WebAuthnEnrollOptions, type WebAuthnEnrollment, WebAuthnMultiDeviceError, WebAuthnNotAvailableError, WebAuthnPRFUnavailableError, type WebAuthnUnlockOptions, enrollWebAuthn, isValidEnrollment, isWebAuthnAvailable, unlockWebAuthn };
|
|
267
|
+
export { WebAuthnCancelledError, type WebAuthnEnrollOptions, type WebAuthnEnrollment, WebAuthnMultiDeviceError, WebAuthnNotAvailableError, WebAuthnPRFUnavailableError, type WebAuthnUnlockOptions, enrollWebAuthn, isValidEnrollment, isWebAuthnAvailable, unlockWebAuthn, webAuthnSlotRewrapCeremony };
|
package/dist/index.js
CHANGED
|
@@ -252,6 +252,113 @@ async function unlockWebAuthn(enrollment, options = {}) {
|
|
|
252
252
|
}
|
|
253
253
|
return unwrapKeyringSummary(enrollment, wrappingKey);
|
|
254
254
|
}
|
|
255
|
+
async function webAuthnSlotRewrapCeremony(ctx, options = {}) {
|
|
256
|
+
if (ctx.oldSlot.method !== "webauthn") {
|
|
257
|
+
throw new ValidationError(
|
|
258
|
+
`webAuthnSlotRewrapCeremony: oldSlot.method is "${ctx.oldSlot.method}"; expected "webauthn". This ceremony only handles WebAuthn slots \u2014 pair other methods with their own helpers.`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
if (ctx.oldSlot.wrapKind === "deks") {
|
|
262
|
+
throw new ValidationError(
|
|
263
|
+
"webAuthnSlotRewrapCeremony: oldSlot is a wrap-DEKs slot; expected wrap-KEK. WebAuthn slots use the wrap-KEK variant; mismatch indicates the slot was enrolled by a different on-* package."
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
if (!isWebAuthnAvailable()) {
|
|
267
|
+
throw new WebAuthnNotAvailableError();
|
|
268
|
+
}
|
|
269
|
+
const meta = ctx.oldSlot.meta;
|
|
270
|
+
if (typeof meta.credentialId !== "string" || meta.credentialId.length === 0) {
|
|
271
|
+
throw new ValidationError(
|
|
272
|
+
"webAuthnSlotRewrapCeremony: oldSlot.meta.credentialId is missing or invalid. The slot was not enrolled via @noy-db/on-webauthn."
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (typeof meta.wrapIv !== "string" || meta.wrapIv.length === 0) {
|
|
276
|
+
throw new ValidationError(
|
|
277
|
+
"webAuthnSlotRewrapCeremony: oldSlot.meta.wrapIv is missing \u2014 the slot may be pre-#16 (synthetic-keyring shape) and must be re-enrolled via db.enrollWebAuthn."
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
const prfUsed = meta.prfUsed === true;
|
|
281
|
+
const credentialIdBuf = base64ToBuffer(meta.credentialId);
|
|
282
|
+
const timeout = options.timeout ?? 6e4;
|
|
283
|
+
const extensionsInput = prfUsed ? { prf: { eval: { first: PRF_SALT } } } : {};
|
|
284
|
+
const assertion = await navigator.credentials.get({
|
|
285
|
+
publicKey: {
|
|
286
|
+
challenge: globalThis.crypto.getRandomValues(new Uint8Array(32)),
|
|
287
|
+
allowCredentials: [{ type: "public-key", id: credentialIdBuf }],
|
|
288
|
+
userVerification: "required",
|
|
289
|
+
extensions: extensionsInput,
|
|
290
|
+
timeout
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
if (!assertion) {
|
|
294
|
+
throw new WebAuthnCancelledError("assertion");
|
|
295
|
+
}
|
|
296
|
+
const authData = assertion.response.authenticatorData;
|
|
297
|
+
const beFlag = extractBEFlag(authData);
|
|
298
|
+
if (meta.requireSingleDevice === true && beFlag) {
|
|
299
|
+
throw new WebAuthnMultiDeviceError();
|
|
300
|
+
}
|
|
301
|
+
let wrappingKey;
|
|
302
|
+
if (prfUsed) {
|
|
303
|
+
const extensions = assertion.getClientExtensionResults();
|
|
304
|
+
const prfOutput = extensions.prf?.results?.first;
|
|
305
|
+
if (!prfOutput) {
|
|
306
|
+
throw new ValidationError(
|
|
307
|
+
"webAuthnSlotRewrapCeremony: PRF output missing at assertion time. The authenticator may have lost PRF capability \u2014 re-enrol the slot via db.enrollWebAuthn."
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
wrappingKey = await deriveKeyFromPRF(prfOutput);
|
|
311
|
+
} else {
|
|
312
|
+
wrappingKey = await deriveKeyFromRawId(assertion.rawId);
|
|
313
|
+
}
|
|
314
|
+
const oldIv = base64ToBuffer(meta.wrapIv);
|
|
315
|
+
const oldCiphertext = base64ToBuffer(ctx.oldSlot.wrapped_kek);
|
|
316
|
+
let oldPlaintext;
|
|
317
|
+
try {
|
|
318
|
+
oldPlaintext = await globalThis.crypto.subtle.decrypt(
|
|
319
|
+
{ name: "AES-GCM", iv: oldIv },
|
|
320
|
+
wrappingKey,
|
|
321
|
+
oldCiphertext
|
|
322
|
+
);
|
|
323
|
+
} catch {
|
|
324
|
+
throw new ValidationError(
|
|
325
|
+
"webAuthnSlotRewrapCeremony: failed to decrypt the old wrapped payload. The wrapping key derived from this credential does not match \u2014 possible cross-tenant slot mix-up or a corrupted enrollment."
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
const oldPayload = JSON.parse(new TextDecoder().decode(oldPlaintext));
|
|
329
|
+
const newDekMap = {};
|
|
330
|
+
for (const [collName, dek] of ctx.newDeks) {
|
|
331
|
+
const raw = await globalThis.crypto.subtle.exportKey("raw", dek);
|
|
332
|
+
newDekMap[collName] = bufferToBase64(raw);
|
|
333
|
+
}
|
|
334
|
+
const newPayload = JSON.stringify({
|
|
335
|
+
userId: oldPayload.userId,
|
|
336
|
+
displayName: oldPayload.displayName,
|
|
337
|
+
role: oldPayload.role,
|
|
338
|
+
permissions: oldPayload.permissions,
|
|
339
|
+
deks: newDekMap,
|
|
340
|
+
salt: oldPayload.salt
|
|
341
|
+
});
|
|
342
|
+
const newIv = globalThis.crypto.getRandomValues(new Uint8Array(12));
|
|
343
|
+
const newCiphertext = await globalThis.crypto.subtle.encrypt(
|
|
344
|
+
{ name: "AES-GCM", iv: newIv },
|
|
345
|
+
wrappingKey,
|
|
346
|
+
new TextEncoder().encode(newPayload)
|
|
347
|
+
);
|
|
348
|
+
return {
|
|
349
|
+
id: ctx.oldSlot.id,
|
|
350
|
+
method: "webauthn",
|
|
351
|
+
wrapped_kek: bufferToBase64(newCiphertext),
|
|
352
|
+
enrolled_via_tier: ctx.oldSlot.enrolled_via_tier,
|
|
353
|
+
meta: {
|
|
354
|
+
credentialId: meta.credentialId,
|
|
355
|
+
wrapIv: bufferToBase64(newIv),
|
|
356
|
+
prfUsed,
|
|
357
|
+
beFlag,
|
|
358
|
+
requireSingleDevice: meta.requireSingleDevice === true
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
}
|
|
255
362
|
function isValidEnrollment(value) {
|
|
256
363
|
if (!value || typeof value !== "object") return false;
|
|
257
364
|
const e = value;
|
|
@@ -266,6 +373,7 @@ export {
|
|
|
266
373
|
enrollWebAuthn,
|
|
267
374
|
isValidEnrollment,
|
|
268
375
|
isWebAuthnAvailable,
|
|
269
|
-
unlockWebAuthn
|
|
376
|
+
unlockWebAuthn,
|
|
377
|
+
webAuthnSlotRewrapCeremony
|
|
270
378
|
};
|
|
271
379
|
//# sourceMappingURL=index.js.map
|
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-passphrase 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 falls back to HKDF-SHA256 over the credential's `rawId` —\n * the same approach as the pre-existing `@noy-db/core` biometric module.\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 (passphrase 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 { UnlockedKeyring, Role } from '@noy-db/hub'\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 (passphrase 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 passphrase 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 (as a non-fatal warning, caught internally) when the PRF extension\n * is not supported by the authenticator.\n *\n * NOYDB prefers PRF for key derivation because it produces a\n * credential-bound output that is deterministic and not extractable from\n * the authenticator. When PRF is unavailable, enrollment falls back to\n * HKDF over the credential's `rawId` — weaker binding, but still functional.\n * This error is caught at enrollment time; callers only see it if they\n * explicitly opt into strict PRF-only mode.\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. ' +\n 'Enrollment will fall back to rawId-based key derivation. ' +\n 'This provides weaker binding to the specific authenticator.',\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\n/** Options for `unlockWebAuthn()`. */\nexport interface WebAuthnUnlockOptions {\n /** WebAuthn timeout in milliseconds. Default: 60_000. */\n timeout?: number\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 * Weaker than PRF (rawId may be observable to the server) but universally supported.\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 passphrase 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 */\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 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 DEKs\n * have been rotated since enrollment, this will return stale DEKs — the\n * caller should detect decryption failures and prompt for re-enrollment.\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 as BufferSource }],\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 * 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-passphrase 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 falls back to HKDF-SHA256 over the credential's `rawId` —\n * the same approach as the pre-existing `@noy-db/core` biometric module.\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 (passphrase 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 { UnlockedKeyring, Role, EnrollAuthenticatorOptions, SlotRewrapContext } from '@noy-db/hub'\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 (passphrase 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 passphrase 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 (as a non-fatal warning, caught internally) when the PRF extension\n * is not supported by the authenticator.\n *\n * NOYDB prefers PRF for key derivation because it produces a\n * credential-bound output that is deterministic and not extractable from\n * the authenticator. When PRF is unavailable, enrollment falls back to\n * HKDF over the credential's `rawId` — weaker binding, but still functional.\n * This error is caught at enrollment time; callers only see it if they\n * explicitly opt into strict PRF-only mode.\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. ' +\n 'Enrollment will fall back to rawId-based key derivation. ' +\n 'This provides weaker binding to the specific authenticator.',\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\n/** Options for `unlockWebAuthn()`. */\nexport interface WebAuthnUnlockOptions {\n /** WebAuthn timeout in milliseconds. Default: 60_000. */\n timeout?: number\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 * Weaker than PRF (rawId may be observable to the server) but universally supported.\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 passphrase 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 */\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 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 DEKs\n * have been rotated since enrollment, this will return stale DEKs — the\n * caller should detect decryption failures and prompt for re-enrollment.\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 as BufferSource }],\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 * `rotatePassphrase({ 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 (#56).\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 passphrase. 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 still has the old\n * DEKs (now stale because rotatePassphrase rewrapped them under a\n * fresh KEK); the new payload must hold the freshly rewrapped\n * `ctx.newDeks`.\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 * Niwat (consumer) shipped a workaround at #44 — detect dropped slots\n * after 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 *\n * @see #56 #29 — the ceremony plumbing this fills in.\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 // 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 as BufferSource }],\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"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noy-db/on-webauthn",
|
|
3
|
-
"version": "0.1.0-pre.
|
|
3
|
+
"version": "0.1.0-pre.9",
|
|
4
4
|
"description": "WebAuthn hardware-key keyrings for noy-db — Touch ID, Face ID, Windows Hello, YubiKey, FIDO2 passkeys",
|
|
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.
|
|
42
|
+
"@noy-db/hub": "0.1.0-pre.9"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@noy-db/hub": "0.1.0-pre.
|
|
45
|
+
"@noy-db/hub": "0.1.0-pre.9"
|
|
46
46
|
},
|
|
47
47
|
"keywords": [
|
|
48
48
|
"noy-db",
|