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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -25,14 +25,13 @@ __export(index_exports, {
25
25
  PasswordInvalidError: () => PasswordInvalidError,
26
26
  PasswordTooWeakError: () => PasswordTooWeakError,
27
27
  enrollPasswordAuthenticator: () => enrollPasswordAuthenticator,
28
- unwrapKekWithPassword: () => unwrapKekWithPassword,
28
+ unwrapDeksWithPassword: () => unwrapDeksWithPassword,
29
29
  verifyPasswordSlot: () => verifyPasswordSlot
30
30
  });
31
31
  module.exports = __toCommonJS(index_exports);
32
+ var import_hub = require("@noy-db/hub");
32
33
  var PASSWORD_PBKDF2_ITERATIONS = 6e5;
33
34
  var PASSWORD_DEFAULT_MIN_LENGTH = 12;
34
- var SALT_BYTES = 32;
35
- var subtle = globalThis.crypto.subtle;
36
35
  var PasswordTooWeakError = class extends Error {
37
36
  code = "PASSWORD_TOO_WEAK";
38
37
  minLength;
@@ -62,84 +61,73 @@ async function enrollPasswordAuthenticator(keyring, options) {
62
61
  `Password does not match the configured pattern: ${options.pattern.toString()}.`
63
62
  );
64
63
  }
65
- if (!keyring.kek) {
64
+ if (keyring.deks.size === 0) {
66
65
  throw new Error(
67
- "enrollPasswordAuthenticator: the supplied keyring has no KEK in memory. Tier-3 quick-resume keyrings cannot enrol new tier-2 slots; re-authenticate at tier 1 first."
66
+ "enrollPasswordAuthenticator: the supplied keyring has no DEKs in memory. Re-authenticate at tier 1 first."
68
67
  );
69
68
  }
70
- const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
71
- const wrappingKey = await derivePasswordWrappingKey(options.password, salt);
72
- const wrapped = await subtle.wrapKey("raw", keyring.kek, wrappingKey, "AES-KW");
69
+ const blob = await (0, import_hub.mintWrappedDeksBlob)(keyring.deks, options.password);
73
70
  return {
74
71
  id: options.id ?? "password-daily",
75
72
  method: "password",
76
- wrapped_kek: bytesToBase64(new Uint8Array(wrapped)),
73
+ wrapKind: "deks",
74
+ wrapped_deks: blob.wrappedDeks,
75
+ iv: blob.iv,
77
76
  meta: {
78
- salt: bytesToBase64(salt),
77
+ salt: blob.salt,
79
78
  minLength,
80
79
  ...options.pattern !== void 0 ? { pattern: options.pattern.source } : {}
81
80
  },
82
81
  enrolled_via_tier: options.enrolledViaTier ?? 1
83
82
  };
84
83
  }
85
- async function unwrapKekWithPassword(slot, password) {
84
+ async function unwrapDeksWithPassword(slot, password) {
85
+ if (slot.wrapKind !== "deks") {
86
+ throw new PasswordInvalidError(
87
+ "Password slot is not a wrap-DEKs slot. Pre-pre.8 wrap-KEK password slots are no longer supported \u2014 re-enrol via enrollPasswordAuthenticator."
88
+ );
89
+ }
86
90
  const meta = slot.meta;
87
91
  if (typeof meta.salt !== "string") {
88
92
  throw new PasswordInvalidError(
89
93
  "Password slot is missing the per-slot salt \u2014 keyring may be corrupted."
90
94
  );
91
95
  }
92
- const salt = base64ToBytes(meta.salt);
93
- const wrappingKey = await derivePasswordWrappingKey(password, salt);
94
96
  try {
95
- return await subtle.unwrapKey(
96
- "raw",
97
- base64ToBytes(slot.wrapped_kek),
98
- wrappingKey,
99
- "AES-KW",
100
- { name: "AES-KW", length: 256 },
101
- false,
102
- ["wrapKey", "unwrapKey"]
97
+ return await (0, import_hub.unwrapDeksFromBlob)(
98
+ { salt: meta.salt, iv: slot.iv, wrappedDeks: slot.wrapped_deks },
99
+ password
103
100
  );
104
101
  } catch {
105
102
  throw new PasswordInvalidError();
106
103
  }
107
104
  }
108
105
  async function verifyPasswordSlot(slot, password, options) {
109
- const kek = await unwrapKekWithPassword(slot, password);
110
- return options.materialize(kek);
111
- }
112
- async function derivePasswordWrappingKey(password, salt) {
113
- const ikm = await subtle.importKey(
114
- "raw",
115
- new TextEncoder().encode(password),
116
- "PBKDF2",
117
- false,
118
- ["deriveKey"]
119
- );
120
- return subtle.deriveKey(
121
- {
122
- name: "PBKDF2",
123
- salt,
124
- iterations: PASSWORD_PBKDF2_ITERATIONS,
125
- hash: "SHA-256"
126
- },
127
- ikm,
128
- { name: "AES-KW", length: 256 },
129
- false,
130
- ["wrapKey", "unwrapKey"]
131
- );
132
- }
133
- function bytesToBase64(bytes) {
134
- let s = "";
135
- for (const b of bytes) s += String.fromCharCode(b);
136
- return btoa(s);
137
- }
138
- function base64ToBytes(b64) {
139
- const s = atob(b64);
140
- const out = new Uint8Array(s.length);
141
- for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
142
- return out;
106
+ const deks = await unwrapDeksWithPassword(slot, password);
107
+ const env = await options.store.get(options.vault, "_keyring", options.userId);
108
+ if (!env) {
109
+ throw new PasswordInvalidError(
110
+ `verifyPasswordSlot: no keyring found at "${options.vault}/_keyring/${options.userId}". Verify the vault and userId are correct.`
111
+ );
112
+ }
113
+ const file = JSON.parse(env._data);
114
+ const salt = new Uint8Array((0, import_hub.base64ToBuffer)(file.salt));
115
+ return {
116
+ userId: file.user_id,
117
+ displayName: file.display_name,
118
+ role: file.role,
119
+ permissions: file.permissions,
120
+ authenticators: file.authenticators ?? [],
121
+ salt,
122
+ ...file.export_capability !== void 0 && { exportCapability: file.export_capability },
123
+ ...file.import_capability !== void 0 && { importCapability: file.import_capability },
124
+ ...file.policy !== void 0 && { policy: file.policy },
125
+ deks,
126
+ // Wrap-DEKs unlock cannot recover the KEK. Sensitive ops route
127
+ // through tier-1 via re-entry of the master phrase. Matches the
128
+ // existing tier-3 (`@noy-db/on-pin`) pattern.
129
+ kek: null
130
+ };
143
131
  }
144
132
  // Annotate the CommonJS export names for ESM import in node:
145
133
  0 && (module.exports = {
@@ -148,7 +136,7 @@ function base64ToBytes(b64) {
148
136
  PasswordInvalidError,
149
137
  PasswordTooWeakError,
150
138
  enrollPasswordAuthenticator,
151
- unwrapKekWithPassword,
139
+ unwrapDeksWithPassword,
152
140
  verifyPasswordSlot
153
141
  });
154
142
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/on-password** — tier-2 daily-password authenticator slot.\n *\n * The user's tier-1 *phrase* is the rarely-typed master that derives\n * the KEK. This package adds a SEPARATE secret — a daily-typed\n * password — as a tier-2 slot in the multi-slot keyring. The two\n * credentials have:\n *\n * - **Different lifecycles** — the phrase rotates yearly; the password\n * rotates per the developer's policy.\n * - **Different strength rules** — the phrase is validated against the\n * phrase format (issue #7); the password is validated against a\n * length / regex rule the developer chooses.\n * - **Different storage** — the phrase derives the KEK; the password\n * derives a wrapping key that wraps the SAME KEK in its own\n * keyring slot (LUKS pattern).\n *\n * The slot is added to the keyring via `db.enrollAuthenticator`; the\n * KEK is recovered via `db.unlockViaAuthenticator` — both routes hit\n * the policy gate engine (issue #9).\n *\n * @see docs/subsystems/session-tiers.md → Tier 2 — `on-password`\n *\n * @packageDocumentation\n */\nimport type {\n EnrollAuthenticatorOptions,\n KeyringAuthenticator,\n UnlockedKeyring,\n} from '@noy-db/hub'\n\n/** PBKDF2 iteration count — matches the tier-1 phrase derivation. */\nexport const PASSWORD_PBKDF2_ITERATIONS = 600_000\n\n/** Default minimum password length. Override per-app via `enrollPasswordAuthenticator`. */\nexport const PASSWORD_DEFAULT_MIN_LENGTH = 12\n\n/** Per-slot salt size. */\nconst SALT_BYTES = 32\n\nconst subtle = globalThis.crypto.subtle\n\n// ─── Errors ────────────────────────────────────────────────────────────\n\nexport class PasswordTooWeakError extends Error {\n readonly code = 'PASSWORD_TOO_WEAK' as const\n readonly minLength: number\n constructor(minLength: number, message?: string) {\n super(\n message ??\n `Password must be at least ${String(minLength)} characters. ` +\n 'For accounts with a separate tier-1 phrase, prefer a longer password ' +\n 'or pair with TOTP/email-OTP via @noy-db/on-totp or @noy-db/on-email-otp.',\n )\n this.name = 'PasswordTooWeakError'\n this.minLength = minLength\n }\n}\n\nexport class PasswordInvalidError extends Error {\n readonly code = 'PASSWORD_INVALID' as const\n constructor(message = 'Password does not unlock this slot.') {\n super(message)\n this.name = 'PasswordInvalidError'\n }\n}\n\n// ─── Public API ────────────────────────────────────────────────────────\n\n/** Options for {@link enrollPasswordAuthenticator}. */\nexport interface EnrollPasswordOptions {\n /** Slot id. Default: `'password-daily'`. */\n readonly id?: string\n /** Daily password the user will type. Distinct from the tier-1 phrase. */\n readonly password: string\n /** Minimum length. Default 12. */\n readonly minLength?: number\n /** Optional regex the password must satisfy in addition to length. */\n readonly pattern?: RegExp\n /** Tier the active session held when enrolling. Default 1. */\n readonly enrolledViaTier?: 1 | 2\n}\n\n/**\n * Build the keyring slot for a tier-2 password authenticator. Returns\n * an `EnrollAuthenticatorOptions` value the caller hands to\n * `db.enrollAuthenticator(vault, slot)` — separating the cryptographic\n * step (this function) from the persistence step (the hub) keeps the\n * package small and lets the hub's policy gate run between the two.\n *\n * Usage:\n *\n * ```ts\n * import { enrollPasswordAuthenticator } from '@noy-db/on-password'\n *\n * const slot = await enrollPasswordAuthenticator(unlocked, {\n * password: 'daily-password-2026',\n * minLength: 14,\n * })\n * await db.enrollAuthenticator('acme', slot, {\n * factors: [{ kind: 'totp' }],\n * })\n * ```\n */\nexport async function enrollPasswordAuthenticator(\n keyring: UnlockedKeyring,\n options: EnrollPasswordOptions,\n): Promise<EnrollAuthenticatorOptions> {\n const minLength = options.minLength ?? PASSWORD_DEFAULT_MIN_LENGTH\n if (options.password.length < minLength) {\n throw new PasswordTooWeakError(minLength)\n }\n if (options.pattern && !options.pattern.test(options.password)) {\n throw new PasswordTooWeakError(\n minLength,\n `Password does not match the configured pattern: ${options.pattern.toString()}.`,\n )\n }\n\n if (!keyring.kek) {\n throw new Error(\n 'enrollPasswordAuthenticator: the supplied keyring has no KEK in memory. ' +\n 'Tier-3 quick-resume keyrings cannot enrol new tier-2 slots; re-authenticate at tier 1 first.',\n )\n }\n\n const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES))\n const wrappingKey = await derivePasswordWrappingKey(options.password, salt)\n const wrapped = await subtle.wrapKey('raw', keyring.kek, wrappingKey, 'AES-KW')\n\n return {\n id: options.id ?? 'password-daily',\n method: 'password',\n wrapped_kek: bytesToBase64(new Uint8Array(wrapped)),\n meta: {\n salt: bytesToBase64(salt),\n minLength,\n ...(options.pattern !== undefined ? { pattern: options.pattern.source } : {}),\n },\n enrolled_via_tier: options.enrolledViaTier ?? 1,\n }\n}\n\n/**\n * Recover the KEK from a password slot's `wrapped_kek` ciphertext.\n * Returns the unwrapped KEK as a non-extractable `CryptoKey` ready for\n * AES-KW unwrap of DEKs. Used inside the verify callback passed to\n * `db.unlockViaAuthenticator`.\n *\n * @throws {@link PasswordInvalidError} when the password is wrong.\n */\nexport async function unwrapKekWithPassword(\n slot: KeyringAuthenticator,\n password: string,\n): Promise<CryptoKey> {\n const meta = slot.meta as { salt?: unknown }\n if (typeof meta.salt !== 'string') {\n throw new PasswordInvalidError(\n 'Password slot is missing the per-slot salt — keyring may be corrupted.',\n )\n }\n const salt = base64ToBytes(meta.salt)\n const wrappingKey = await derivePasswordWrappingKey(password, salt)\n try {\n return await subtle.unwrapKey(\n 'raw',\n base64ToBytes(slot.wrapped_kek) as BufferSource,\n wrappingKey,\n 'AES-KW',\n { name: 'AES-KW', length: 256 },\n false,\n ['wrapKey', 'unwrapKey'],\n )\n } catch {\n throw new PasswordInvalidError()\n }\n}\n\n/**\n * Hub-friendly verify callback. Pass to `db.unlockViaAuthenticator`:\n *\n * ```ts\n * const unlocked = await db.unlockViaAuthenticator('acme', 'password-daily',\n * (slot) => verifyPasswordSlot(slot, 'daily-password-2026', { adapter: store, vault: 'acme', userId: 'alice' }),\n * )\n * ```\n *\n * The callback re-loads the keyring file via the supplied adapter,\n * unwraps every DEK with the recovered KEK, and returns the\n * `UnlockedKeyring` the hub installs in its keyring cache.\n *\n * @throws {@link PasswordInvalidError} when the password is wrong.\n */\nexport async function verifyPasswordSlot(\n slot: KeyringAuthenticator,\n password: string,\n options: VerifyPasswordSlotOptions,\n): Promise<UnlockedKeyring> {\n const kek = await unwrapKekWithPassword(slot, password)\n return options.materialize(kek)\n}\n\n/** Adapter shape required by {@link verifyPasswordSlot}. */\nexport interface VerifyPasswordSlotOptions {\n /**\n * Caller-supplied \"given the recovered KEK, build the\n * `UnlockedKeyring`\" routine. The hub provides the standard\n * implementation in {@link buildUnlockedKeyringFromKek}; consumers\n * with non-standard storage (e.g. encrypted browser-extension\n * stores) can pass their own.\n */\n readonly materialize: (kek: CryptoKey) => Promise<UnlockedKeyring>\n}\n\n// ─── Helpers ───────────────────────────────────────────────────────────\n\nasync function derivePasswordWrappingKey(\n password: string,\n salt: Uint8Array,\n): Promise<CryptoKey> {\n const ikm = await subtle.importKey(\n 'raw',\n new TextEncoder().encode(password),\n 'PBKDF2',\n false,\n ['deriveKey'],\n )\n return subtle.deriveKey(\n {\n name: 'PBKDF2',\n salt: salt as BufferSource,\n iterations: PASSWORD_PBKDF2_ITERATIONS,\n hash: 'SHA-256',\n },\n ikm,\n { name: 'AES-KW', length: 256 },\n false,\n ['wrapKey', 'unwrapKey'],\n )\n}\n\nfunction bytesToBase64(bytes: Uint8Array): string {\n let s = ''\n for (const b of bytes) s += String.fromCharCode(b)\n return btoa(s)\n}\n\nfunction base64ToBytes(b64: string): Uint8Array {\n const s = atob(b64)\n const out = new Uint8Array(s.length)\n for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i)\n return out\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/on-password** — tier-2 daily-password authenticator slot.\n *\n * The user's tier-1 *phrase* is the rarely-typed master that derives\n * the KEK. This package adds a SEPARATE secret — a daily-typed\n * password — as a tier-2 slot in the multi-slot keyring. The two\n * credentials have:\n *\n * - **Different lifecycles** — the phrase rotates yearly; the password\n * rotates per the developer's policy.\n * - **Different strength rules** — the phrase is validated against the\n * phrase format (issue #7); the password is validated against a\n * length / regex rule the developer chooses.\n * - **Different storage** — the phrase derives the KEK; the password\n * derives a wrapping key that wraps the SAME DEK SET in its own\n * keyring slot (LUKS-like multi-slot, wrap-DEKs variant).\n *\n * ## Wrap-DEKs format (#26 Path C)\n *\n * Slots produced by this package use the wrap-DEKs variant of\n * `KeyringAuthenticator` — they encrypt the serialized DEK set under\n * a password-derived AES-GCM key, NOT the KEK. This unifies tier-2\n * password slots with the tier-0 (paper recovery, `mintPaperRecoveryEntry`)\n * and tier-3 (`@noy-db/on-pin`'s `wrappedKeyring`) primitives — all\n * three sidestep the non-extractable-KEK constraint by wrapping the\n * DEK set rather than the KEK itself.\n *\n * Trade-off: an `UnlockedKeyring` produced via password-slot unlock\n * has `kek: null`. Sensitive operations (`enrollAuthenticator`,\n * `rotatePassphrase`) require a tier-1 unlock anyway — re-enter the\n * master phrase.\n *\n * @see docs/subsystems/session-tiers.md → Tier 2 — `on-password`\n *\n * @packageDocumentation\n */\nimport {\n base64ToBuffer,\n mintWrappedDeksBlob,\n unwrapDeksFromBlob,\n type EnrollAuthenticatorOptions,\n type KeyringAuthenticator,\n type KeyringFile,\n type NoydbStore,\n type UnlockedKeyring,\n} from '@noy-db/hub'\n\n/**\n * PBKDF2 iteration count — matches the tier-1 phrase derivation.\n * Exported as a documented invariant; the actual derivation lives\n * in hub's canonical wrap-DEKs primitive (#44).\n */\nexport const PASSWORD_PBKDF2_ITERATIONS = 600_000\n\n/** Default minimum password length. Override per-app via `enrollPasswordAuthenticator`. */\nexport const PASSWORD_DEFAULT_MIN_LENGTH = 12\n\n// ─── Errors ────────────────────────────────────────────────────────────\n\nexport class PasswordTooWeakError extends Error {\n readonly code = 'PASSWORD_TOO_WEAK' as const\n readonly minLength: number\n constructor(minLength: number, message?: string) {\n super(\n message ??\n `Password must be at least ${String(minLength)} characters. ` +\n 'For accounts with a separate tier-1 phrase, prefer a longer password ' +\n 'or pair with TOTP/email-OTP via @noy-db/on-totp or @noy-db/on-email-otp.',\n )\n this.name = 'PasswordTooWeakError'\n this.minLength = minLength\n }\n}\n\nexport class PasswordInvalidError extends Error {\n readonly code = 'PASSWORD_INVALID' as const\n constructor(message = 'Password does not unlock this slot.') {\n super(message)\n this.name = 'PasswordInvalidError'\n }\n}\n\n// ─── Public API ────────────────────────────────────────────────────────\n\n/** Options for {@link enrollPasswordAuthenticator}. */\nexport interface EnrollPasswordOptions {\n /** Slot id. Default: `'password-daily'`. */\n readonly id?: string\n /** Daily password the user will type. Distinct from the tier-1 phrase. */\n readonly password: string\n /** Minimum length. Default 12. */\n readonly minLength?: number\n /** Optional regex the password must satisfy in addition to length. */\n readonly pattern?: RegExp\n /** Tier the active session held when enrolling. Default 1. */\n readonly enrolledViaTier?: 1 | 2\n}\n\n/**\n * Build the keyring slot for a tier-2 password authenticator. Returns\n * an `EnrollAuthenticatorOptions` value the caller hands to\n * `db.enrollAuthenticator(vault, slot)` — separating the cryptographic\n * step (this function) from the persistence step (the hub) keeps the\n * package small and lets the hub's policy gate run between the two.\n *\n * The slot uses the **wrap-DEKs** variant of `KeyringAuthenticator`:\n * the DEK set is serialized to JSON and encrypted with AES-GCM under\n * a PBKDF2-derived key. No requirement on `keyring.kek` — works with\n * tier-1 unlocked keyrings; throws if `keyring.deks` is empty.\n *\n * Usage:\n *\n * ```ts\n * import { enrollPasswordAuthenticator } from '@noy-db/on-password'\n *\n * const keyring = await db.getKeyring('acme')\n * const slot = await enrollPasswordAuthenticator(keyring, {\n * password: 'daily-password-2026',\n * minLength: 14,\n * })\n * await db.enrollAuthenticator('acme', slot, {\n * factors: [{ kind: 'totp' }],\n * })\n * ```\n */\nexport async function enrollPasswordAuthenticator(\n keyring: UnlockedKeyring,\n options: EnrollPasswordOptions,\n): Promise<EnrollAuthenticatorOptions> {\n const minLength = options.minLength ?? PASSWORD_DEFAULT_MIN_LENGTH\n if (options.password.length < minLength) {\n throw new PasswordTooWeakError(minLength)\n }\n if (options.pattern && !options.pattern.test(options.password)) {\n throw new PasswordTooWeakError(\n minLength,\n `Password does not match the configured pattern: ${options.pattern.toString()}.`,\n )\n }\n\n if (keyring.deks.size === 0) {\n throw new Error(\n 'enrollPasswordAuthenticator: the supplied keyring has no DEKs in memory. ' +\n 'Re-authenticate at tier 1 first.',\n )\n }\n\n // Delegate the wrap-DEKs crypto to the canonical hub primitive (#44).\n // The slot envelope still stores `salt` inside `meta` for backward\n // compatibility with the pre-#44 slot format; the issue defers\n // moving it to top-level for parity with PaperRecoveryEntry.\n const blob = await mintWrappedDeksBlob(keyring.deks, options.password)\n\n return {\n id: options.id ?? 'password-daily',\n method: 'password',\n wrapKind: 'deks',\n wrapped_deks: blob.wrappedDeks,\n iv: blob.iv,\n meta: {\n salt: blob.salt,\n minLength,\n ...(options.pattern !== undefined ? { pattern: options.pattern.source } : {}),\n },\n enrolled_via_tier: options.enrolledViaTier ?? 1,\n }\n}\n\n/**\n * Recover the DEK set from a wrap-DEKs password slot. Returns the raw\n * DEK map; the hub-friendly verifier {@link verifyPasswordSlot} wraps\n * this and produces a full `UnlockedKeyring`.\n *\n * @throws {@link PasswordInvalidError} when the password is wrong or\n * the slot is not a wrap-DEKs slot (e.g. a legacy wrap-KEK password\n * slot from before pre.8 — those need re-enrollment).\n */\nexport async function unwrapDeksWithPassword(\n slot: KeyringAuthenticator,\n password: string,\n): Promise<Map<string, CryptoKey>> {\n if (slot.wrapKind !== 'deks') {\n throw new PasswordInvalidError(\n 'Password slot is not a wrap-DEKs slot. Pre-pre.8 wrap-KEK password ' +\n 'slots are no longer supported — re-enrol via enrollPasswordAuthenticator.',\n )\n }\n\n const meta = slot.meta as { salt?: unknown }\n if (typeof meta.salt !== 'string') {\n throw new PasswordInvalidError(\n 'Password slot is missing the per-slot salt — keyring may be corrupted.',\n )\n }\n\n // Delegate to the canonical wrap-DEKs primitive (#44). The blob's\n // three fields (salt / iv / wrappedDeks) are reconstructed from\n // the slot's split layout: salt lives in meta, iv + wrappedDeks\n // at top level. Pre-#44, this code re-implemented the same crypto\n // inline.\n try {\n return await unwrapDeksFromBlob(\n { salt: meta.salt, iv: slot.iv, wrappedDeks: slot.wrapped_deks },\n password,\n )\n } catch {\n throw new PasswordInvalidError()\n }\n}\n\n/**\n * Hub-friendly verify callback. Pass to `db.unlockViaAuthenticator`:\n *\n * ```ts\n * import { verifyPasswordSlot } from '@noy-db/on-password'\n *\n * const unlocked = await db.unlockViaAuthenticator('acme', 'password-daily',\n * (slot) => verifyPasswordSlot(slot, 'daily-password-2026',\n * { store, vault: 'acme', userId: 'alice' }),\n * )\n * ```\n *\n * Unwraps the DEK set with the supplied password and returns an\n * `UnlockedKeyring` the hub installs in its keyring cache. The\n * returned keyring has `kek: null` — sensitive operations (enrol new\n * slot, rotate phrase) require a tier-1 unlock from the master phrase.\n *\n * The `{ store, vault, userId }` shape works in both contexts:\n * - **Warm re-unlock** — db is alive; consumer already has identity\n * in memory but pays one cheap `_keyring/<userId>` read.\n * - **Cold-start** (`createNoydb({ getKeyring: ... })`) — consumer\n * does NOT have a keyring yet; the verifier loads identity from\n * disk before returning the unlocked keyring. This is the\n * primary cold-start tier-2 path (Niwat tier-2b uses this).\n *\n * Identity fields (userId, displayName, role, permissions,\n * authenticators, salt, capability bits, per-keyring policy) are read\n * directly from the `_keyring/<userId>` envelope's plaintext header —\n * those fields are not encrypted because the sync engine + grant flow\n * need them without a key.\n *\n * @throws {@link PasswordInvalidError} when the password is wrong or\n * the keyring file is missing.\n */\nexport async function verifyPasswordSlot(\n slot: KeyringAuthenticator,\n password: string,\n options: VerifyPasswordSlotOptions,\n): Promise<UnlockedKeyring> {\n const deks = await unwrapDeksWithPassword(slot, password)\n\n const env = await options.store.get(options.vault, '_keyring', options.userId)\n if (!env) {\n throw new PasswordInvalidError(\n `verifyPasswordSlot: no keyring found at \"${options.vault}/_keyring/${options.userId}\". ` +\n 'Verify the vault and userId are correct.',\n )\n }\n const file = JSON.parse(env._data) as KeyringFile\n const salt = new Uint8Array(base64ToBuffer(file.salt))\n\n return {\n userId: file.user_id,\n displayName: file.display_name,\n role: file.role,\n permissions: file.permissions,\n authenticators: file.authenticators ?? [],\n salt,\n ...(file.export_capability !== undefined && { exportCapability: file.export_capability }),\n ...(file.import_capability !== undefined && { importCapability: file.import_capability }),\n ...(file.policy !== undefined && { policy: file.policy }),\n deks,\n // Wrap-DEKs unlock cannot recover the KEK. Sensitive ops route\n // through tier-1 via re-entry of the master phrase. Matches the\n // existing tier-3 (`@noy-db/on-pin`) pattern.\n kek: null,\n }\n}\n\n/** Adapter shape required by {@link verifyPasswordSlot}. */\nexport interface VerifyPasswordSlotOptions {\n /** The vault's NoydbStore — used to load `_keyring/<userId>`. */\n readonly store: NoydbStore\n /** Vault name — same value passed to `db.openVault(name)`. */\n readonly vault: string\n /** User id — same value passed to `createNoydb({ user })`. */\n readonly userId: string\n}\n\n// All wrap-DEKs crypto helpers (PBKDF2 derivation, base64\n// codecs) moved to hub's `team/wrapped-deks.ts` in #44 — both this\n// package and `mintPaperRecoveryEntry` now share one implementation.\n"],"mappings":"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","names":[]}
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { UnlockedKeyring, EnrollAuthenticatorOptions, KeyringAuthenticator } from '@noy-db/hub';
1
+ import { NoydbStore, UnlockedKeyring, EnrollAuthenticatorOptions, KeyringAuthenticator } from '@noy-db/hub';
2
2
 
3
3
  /**
4
4
  * **@noy-db/on-password** — tier-2 daily-password authenticator slot.
@@ -14,19 +14,34 @@ import { UnlockedKeyring, EnrollAuthenticatorOptions, KeyringAuthenticator } fro
14
14
  * phrase format (issue #7); the password is validated against a
15
15
  * length / regex rule the developer chooses.
16
16
  * - **Different storage** — the phrase derives the KEK; the password
17
- * derives a wrapping key that wraps the SAME KEK in its own
18
- * keyring slot (LUKS pattern).
17
+ * derives a wrapping key that wraps the SAME DEK SET in its own
18
+ * keyring slot (LUKS-like multi-slot, wrap-DEKs variant).
19
19
  *
20
- * The slot is added to the keyring via `db.enrollAuthenticator`; the
21
- * KEK is recovered via `db.unlockViaAuthenticator` — both routes hit
22
- * the policy gate engine (issue #9).
20
+ * ## Wrap-DEKs format (#26 Path C)
21
+ *
22
+ * Slots produced by this package use the wrap-DEKs variant of
23
+ * `KeyringAuthenticator` — they encrypt the serialized DEK set under
24
+ * a password-derived AES-GCM key, NOT the KEK. This unifies tier-2
25
+ * password slots with the tier-0 (paper recovery, `mintPaperRecoveryEntry`)
26
+ * and tier-3 (`@noy-db/on-pin`'s `wrappedKeyring`) primitives — all
27
+ * three sidestep the non-extractable-KEK constraint by wrapping the
28
+ * DEK set rather than the KEK itself.
29
+ *
30
+ * Trade-off: an `UnlockedKeyring` produced via password-slot unlock
31
+ * has `kek: null`. Sensitive operations (`enrollAuthenticator`,
32
+ * `rotatePassphrase`) require a tier-1 unlock anyway — re-enter the
33
+ * master phrase.
23
34
  *
24
35
  * @see docs/subsystems/session-tiers.md → Tier 2 — `on-password`
25
36
  *
26
37
  * @packageDocumentation
27
38
  */
28
39
 
29
- /** PBKDF2 iteration count — matches the tier-1 phrase derivation. */
40
+ /**
41
+ * PBKDF2 iteration count — matches the tier-1 phrase derivation.
42
+ * Exported as a documented invariant; the actual derivation lives
43
+ * in hub's canonical wrap-DEKs primitive (#44).
44
+ */
30
45
  declare const PASSWORD_PBKDF2_ITERATIONS = 600000;
31
46
  /** Default minimum password length. Override per-app via `enrollPasswordAuthenticator`. */
32
47
  declare const PASSWORD_DEFAULT_MIN_LENGTH = 12;
@@ -59,12 +74,18 @@ interface EnrollPasswordOptions {
59
74
  * step (this function) from the persistence step (the hub) keeps the
60
75
  * package small and lets the hub's policy gate run between the two.
61
76
  *
77
+ * The slot uses the **wrap-DEKs** variant of `KeyringAuthenticator`:
78
+ * the DEK set is serialized to JSON and encrypted with AES-GCM under
79
+ * a PBKDF2-derived key. No requirement on `keyring.kek` — works with
80
+ * tier-1 unlocked keyrings; throws if `keyring.deks` is empty.
81
+ *
62
82
  * Usage:
63
83
  *
64
84
  * ```ts
65
85
  * import { enrollPasswordAuthenticator } from '@noy-db/on-password'
66
86
  *
67
- * const slot = await enrollPasswordAuthenticator(unlocked, {
87
+ * const keyring = await db.getKeyring('acme')
88
+ * const slot = await enrollPasswordAuthenticator(keyring, {
68
89
  * password: 'daily-password-2026',
69
90
  * minLength: 14,
70
91
  * })
@@ -75,40 +96,58 @@ interface EnrollPasswordOptions {
75
96
  */
76
97
  declare function enrollPasswordAuthenticator(keyring: UnlockedKeyring, options: EnrollPasswordOptions): Promise<EnrollAuthenticatorOptions>;
77
98
  /**
78
- * Recover the KEK from a password slot's `wrapped_kek` ciphertext.
79
- * Returns the unwrapped KEK as a non-extractable `CryptoKey` ready for
80
- * AES-KW unwrap of DEKs. Used inside the verify callback passed to
81
- * `db.unlockViaAuthenticator`.
99
+ * Recover the DEK set from a wrap-DEKs password slot. Returns the raw
100
+ * DEK map; the hub-friendly verifier {@link verifyPasswordSlot} wraps
101
+ * this and produces a full `UnlockedKeyring`.
82
102
  *
83
- * @throws {@link PasswordInvalidError} when the password is wrong.
103
+ * @throws {@link PasswordInvalidError} when the password is wrong or
104
+ * the slot is not a wrap-DEKs slot (e.g. a legacy wrap-KEK password
105
+ * slot from before pre.8 — those need re-enrollment).
84
106
  */
85
- declare function unwrapKekWithPassword(slot: KeyringAuthenticator, password: string): Promise<CryptoKey>;
107
+ declare function unwrapDeksWithPassword(slot: KeyringAuthenticator, password: string): Promise<Map<string, CryptoKey>>;
86
108
  /**
87
109
  * Hub-friendly verify callback. Pass to `db.unlockViaAuthenticator`:
88
110
  *
89
111
  * ```ts
112
+ * import { verifyPasswordSlot } from '@noy-db/on-password'
113
+ *
90
114
  * const unlocked = await db.unlockViaAuthenticator('acme', 'password-daily',
91
- * (slot) => verifyPasswordSlot(slot, 'daily-password-2026', { adapter: store, vault: 'acme', userId: 'alice' }),
115
+ * (slot) => verifyPasswordSlot(slot, 'daily-password-2026',
116
+ * { store, vault: 'acme', userId: 'alice' }),
92
117
  * )
93
118
  * ```
94
119
  *
95
- * The callback re-loads the keyring file via the supplied adapter,
96
- * unwraps every DEK with the recovered KEK, and returns the
97
- * `UnlockedKeyring` the hub installs in its keyring cache.
120
+ * Unwraps the DEK set with the supplied password and returns an
121
+ * `UnlockedKeyring` the hub installs in its keyring cache. The
122
+ * returned keyring has `kek: null` sensitive operations (enrol new
123
+ * slot, rotate phrase) require a tier-1 unlock from the master phrase.
124
+ *
125
+ * The `{ store, vault, userId }` shape works in both contexts:
126
+ * - **Warm re-unlock** — db is alive; consumer already has identity
127
+ * in memory but pays one cheap `_keyring/<userId>` read.
128
+ * - **Cold-start** (`createNoydb({ getKeyring: ... })`) — consumer
129
+ * does NOT have a keyring yet; the verifier loads identity from
130
+ * disk before returning the unlocked keyring. This is the
131
+ * primary cold-start tier-2 path (Niwat tier-2b uses this).
132
+ *
133
+ * Identity fields (userId, displayName, role, permissions,
134
+ * authenticators, salt, capability bits, per-keyring policy) are read
135
+ * directly from the `_keyring/<userId>` envelope's plaintext header —
136
+ * those fields are not encrypted because the sync engine + grant flow
137
+ * need them without a key.
98
138
  *
99
- * @throws {@link PasswordInvalidError} when the password is wrong.
139
+ * @throws {@link PasswordInvalidError} when the password is wrong or
140
+ * the keyring file is missing.
100
141
  */
101
142
  declare function verifyPasswordSlot(slot: KeyringAuthenticator, password: string, options: VerifyPasswordSlotOptions): Promise<UnlockedKeyring>;
102
143
  /** Adapter shape required by {@link verifyPasswordSlot}. */
103
144
  interface VerifyPasswordSlotOptions {
104
- /**
105
- * Caller-supplied "given the recovered KEK, build the
106
- * `UnlockedKeyring`" routine. The hub provides the standard
107
- * implementation in {@link buildUnlockedKeyringFromKek}; consumers
108
- * with non-standard storage (e.g. encrypted browser-extension
109
- * stores) can pass their own.
110
- */
111
- readonly materialize: (kek: CryptoKey) => Promise<UnlockedKeyring>;
145
+ /** The vault's NoydbStore — used to load `_keyring/<userId>`. */
146
+ readonly store: NoydbStore;
147
+ /** Vault name same value passed to `db.openVault(name)`. */
148
+ readonly vault: string;
149
+ /** User id same value passed to `createNoydb({ user })`. */
150
+ readonly userId: string;
112
151
  }
113
152
 
114
- export { type EnrollPasswordOptions, PASSWORD_DEFAULT_MIN_LENGTH, PASSWORD_PBKDF2_ITERATIONS, PasswordInvalidError, PasswordTooWeakError, type VerifyPasswordSlotOptions, enrollPasswordAuthenticator, unwrapKekWithPassword, verifyPasswordSlot };
153
+ export { type EnrollPasswordOptions, PASSWORD_DEFAULT_MIN_LENGTH, PASSWORD_PBKDF2_ITERATIONS, PasswordInvalidError, PasswordTooWeakError, type VerifyPasswordSlotOptions, enrollPasswordAuthenticator, unwrapDeksWithPassword, verifyPasswordSlot };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { UnlockedKeyring, EnrollAuthenticatorOptions, KeyringAuthenticator } from '@noy-db/hub';
1
+ import { NoydbStore, UnlockedKeyring, EnrollAuthenticatorOptions, KeyringAuthenticator } from '@noy-db/hub';
2
2
 
3
3
  /**
4
4
  * **@noy-db/on-password** — tier-2 daily-password authenticator slot.
@@ -14,19 +14,34 @@ import { UnlockedKeyring, EnrollAuthenticatorOptions, KeyringAuthenticator } fro
14
14
  * phrase format (issue #7); the password is validated against a
15
15
  * length / regex rule the developer chooses.
16
16
  * - **Different storage** — the phrase derives the KEK; the password
17
- * derives a wrapping key that wraps the SAME KEK in its own
18
- * keyring slot (LUKS pattern).
17
+ * derives a wrapping key that wraps the SAME DEK SET in its own
18
+ * keyring slot (LUKS-like multi-slot, wrap-DEKs variant).
19
19
  *
20
- * The slot is added to the keyring via `db.enrollAuthenticator`; the
21
- * KEK is recovered via `db.unlockViaAuthenticator` — both routes hit
22
- * the policy gate engine (issue #9).
20
+ * ## Wrap-DEKs format (#26 Path C)
21
+ *
22
+ * Slots produced by this package use the wrap-DEKs variant of
23
+ * `KeyringAuthenticator` — they encrypt the serialized DEK set under
24
+ * a password-derived AES-GCM key, NOT the KEK. This unifies tier-2
25
+ * password slots with the tier-0 (paper recovery, `mintPaperRecoveryEntry`)
26
+ * and tier-3 (`@noy-db/on-pin`'s `wrappedKeyring`) primitives — all
27
+ * three sidestep the non-extractable-KEK constraint by wrapping the
28
+ * DEK set rather than the KEK itself.
29
+ *
30
+ * Trade-off: an `UnlockedKeyring` produced via password-slot unlock
31
+ * has `kek: null`. Sensitive operations (`enrollAuthenticator`,
32
+ * `rotatePassphrase`) require a tier-1 unlock anyway — re-enter the
33
+ * master phrase.
23
34
  *
24
35
  * @see docs/subsystems/session-tiers.md → Tier 2 — `on-password`
25
36
  *
26
37
  * @packageDocumentation
27
38
  */
28
39
 
29
- /** PBKDF2 iteration count — matches the tier-1 phrase derivation. */
40
+ /**
41
+ * PBKDF2 iteration count — matches the tier-1 phrase derivation.
42
+ * Exported as a documented invariant; the actual derivation lives
43
+ * in hub's canonical wrap-DEKs primitive (#44).
44
+ */
30
45
  declare const PASSWORD_PBKDF2_ITERATIONS = 600000;
31
46
  /** Default minimum password length. Override per-app via `enrollPasswordAuthenticator`. */
32
47
  declare const PASSWORD_DEFAULT_MIN_LENGTH = 12;
@@ -59,12 +74,18 @@ interface EnrollPasswordOptions {
59
74
  * step (this function) from the persistence step (the hub) keeps the
60
75
  * package small and lets the hub's policy gate run between the two.
61
76
  *
77
+ * The slot uses the **wrap-DEKs** variant of `KeyringAuthenticator`:
78
+ * the DEK set is serialized to JSON and encrypted with AES-GCM under
79
+ * a PBKDF2-derived key. No requirement on `keyring.kek` — works with
80
+ * tier-1 unlocked keyrings; throws if `keyring.deks` is empty.
81
+ *
62
82
  * Usage:
63
83
  *
64
84
  * ```ts
65
85
  * import { enrollPasswordAuthenticator } from '@noy-db/on-password'
66
86
  *
67
- * const slot = await enrollPasswordAuthenticator(unlocked, {
87
+ * const keyring = await db.getKeyring('acme')
88
+ * const slot = await enrollPasswordAuthenticator(keyring, {
68
89
  * password: 'daily-password-2026',
69
90
  * minLength: 14,
70
91
  * })
@@ -75,40 +96,58 @@ interface EnrollPasswordOptions {
75
96
  */
76
97
  declare function enrollPasswordAuthenticator(keyring: UnlockedKeyring, options: EnrollPasswordOptions): Promise<EnrollAuthenticatorOptions>;
77
98
  /**
78
- * Recover the KEK from a password slot's `wrapped_kek` ciphertext.
79
- * Returns the unwrapped KEK as a non-extractable `CryptoKey` ready for
80
- * AES-KW unwrap of DEKs. Used inside the verify callback passed to
81
- * `db.unlockViaAuthenticator`.
99
+ * Recover the DEK set from a wrap-DEKs password slot. Returns the raw
100
+ * DEK map; the hub-friendly verifier {@link verifyPasswordSlot} wraps
101
+ * this and produces a full `UnlockedKeyring`.
82
102
  *
83
- * @throws {@link PasswordInvalidError} when the password is wrong.
103
+ * @throws {@link PasswordInvalidError} when the password is wrong or
104
+ * the slot is not a wrap-DEKs slot (e.g. a legacy wrap-KEK password
105
+ * slot from before pre.8 — those need re-enrollment).
84
106
  */
85
- declare function unwrapKekWithPassword(slot: KeyringAuthenticator, password: string): Promise<CryptoKey>;
107
+ declare function unwrapDeksWithPassword(slot: KeyringAuthenticator, password: string): Promise<Map<string, CryptoKey>>;
86
108
  /**
87
109
  * Hub-friendly verify callback. Pass to `db.unlockViaAuthenticator`:
88
110
  *
89
111
  * ```ts
112
+ * import { verifyPasswordSlot } from '@noy-db/on-password'
113
+ *
90
114
  * const unlocked = await db.unlockViaAuthenticator('acme', 'password-daily',
91
- * (slot) => verifyPasswordSlot(slot, 'daily-password-2026', { adapter: store, vault: 'acme', userId: 'alice' }),
115
+ * (slot) => verifyPasswordSlot(slot, 'daily-password-2026',
116
+ * { store, vault: 'acme', userId: 'alice' }),
92
117
  * )
93
118
  * ```
94
119
  *
95
- * The callback re-loads the keyring file via the supplied adapter,
96
- * unwraps every DEK with the recovered KEK, and returns the
97
- * `UnlockedKeyring` the hub installs in its keyring cache.
120
+ * Unwraps the DEK set with the supplied password and returns an
121
+ * `UnlockedKeyring` the hub installs in its keyring cache. The
122
+ * returned keyring has `kek: null` sensitive operations (enrol new
123
+ * slot, rotate phrase) require a tier-1 unlock from the master phrase.
124
+ *
125
+ * The `{ store, vault, userId }` shape works in both contexts:
126
+ * - **Warm re-unlock** — db is alive; consumer already has identity
127
+ * in memory but pays one cheap `_keyring/<userId>` read.
128
+ * - **Cold-start** (`createNoydb({ getKeyring: ... })`) — consumer
129
+ * does NOT have a keyring yet; the verifier loads identity from
130
+ * disk before returning the unlocked keyring. This is the
131
+ * primary cold-start tier-2 path (Niwat tier-2b uses this).
132
+ *
133
+ * Identity fields (userId, displayName, role, permissions,
134
+ * authenticators, salt, capability bits, per-keyring policy) are read
135
+ * directly from the `_keyring/<userId>` envelope's plaintext header —
136
+ * those fields are not encrypted because the sync engine + grant flow
137
+ * need them without a key.
98
138
  *
99
- * @throws {@link PasswordInvalidError} when the password is wrong.
139
+ * @throws {@link PasswordInvalidError} when the password is wrong or
140
+ * the keyring file is missing.
100
141
  */
101
142
  declare function verifyPasswordSlot(slot: KeyringAuthenticator, password: string, options: VerifyPasswordSlotOptions): Promise<UnlockedKeyring>;
102
143
  /** Adapter shape required by {@link verifyPasswordSlot}. */
103
144
  interface VerifyPasswordSlotOptions {
104
- /**
105
- * Caller-supplied "given the recovered KEK, build the
106
- * `UnlockedKeyring`" routine. The hub provides the standard
107
- * implementation in {@link buildUnlockedKeyringFromKek}; consumers
108
- * with non-standard storage (e.g. encrypted browser-extension
109
- * stores) can pass their own.
110
- */
111
- readonly materialize: (kek: CryptoKey) => Promise<UnlockedKeyring>;
145
+ /** The vault's NoydbStore — used to load `_keyring/<userId>`. */
146
+ readonly store: NoydbStore;
147
+ /** Vault name same value passed to `db.openVault(name)`. */
148
+ readonly vault: string;
149
+ /** User id same value passed to `createNoydb({ user })`. */
150
+ readonly userId: string;
112
151
  }
113
152
 
114
- export { type EnrollPasswordOptions, PASSWORD_DEFAULT_MIN_LENGTH, PASSWORD_PBKDF2_ITERATIONS, PasswordInvalidError, PasswordTooWeakError, type VerifyPasswordSlotOptions, enrollPasswordAuthenticator, unwrapKekWithPassword, verifyPasswordSlot };
153
+ export { type EnrollPasswordOptions, PASSWORD_DEFAULT_MIN_LENGTH, PASSWORD_PBKDF2_ITERATIONS, PasswordInvalidError, PasswordTooWeakError, type VerifyPasswordSlotOptions, enrollPasswordAuthenticator, unwrapDeksWithPassword, verifyPasswordSlot };
package/dist/index.js CHANGED
@@ -1,8 +1,11 @@
1
1
  // src/index.ts
2
+ import {
3
+ base64ToBuffer,
4
+ mintWrappedDeksBlob,
5
+ unwrapDeksFromBlob
6
+ } from "@noy-db/hub";
2
7
  var PASSWORD_PBKDF2_ITERATIONS = 6e5;
3
8
  var PASSWORD_DEFAULT_MIN_LENGTH = 12;
4
- var SALT_BYTES = 32;
5
- var subtle = globalThis.crypto.subtle;
6
9
  var PasswordTooWeakError = class extends Error {
7
10
  code = "PASSWORD_TOO_WEAK";
8
11
  minLength;
@@ -32,84 +35,73 @@ async function enrollPasswordAuthenticator(keyring, options) {
32
35
  `Password does not match the configured pattern: ${options.pattern.toString()}.`
33
36
  );
34
37
  }
35
- if (!keyring.kek) {
38
+ if (keyring.deks.size === 0) {
36
39
  throw new Error(
37
- "enrollPasswordAuthenticator: the supplied keyring has no KEK in memory. Tier-3 quick-resume keyrings cannot enrol new tier-2 slots; re-authenticate at tier 1 first."
40
+ "enrollPasswordAuthenticator: the supplied keyring has no DEKs in memory. Re-authenticate at tier 1 first."
38
41
  );
39
42
  }
40
- const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
41
- const wrappingKey = await derivePasswordWrappingKey(options.password, salt);
42
- const wrapped = await subtle.wrapKey("raw", keyring.kek, wrappingKey, "AES-KW");
43
+ const blob = await mintWrappedDeksBlob(keyring.deks, options.password);
43
44
  return {
44
45
  id: options.id ?? "password-daily",
45
46
  method: "password",
46
- wrapped_kek: bytesToBase64(new Uint8Array(wrapped)),
47
+ wrapKind: "deks",
48
+ wrapped_deks: blob.wrappedDeks,
49
+ iv: blob.iv,
47
50
  meta: {
48
- salt: bytesToBase64(salt),
51
+ salt: blob.salt,
49
52
  minLength,
50
53
  ...options.pattern !== void 0 ? { pattern: options.pattern.source } : {}
51
54
  },
52
55
  enrolled_via_tier: options.enrolledViaTier ?? 1
53
56
  };
54
57
  }
55
- async function unwrapKekWithPassword(slot, password) {
58
+ async function unwrapDeksWithPassword(slot, password) {
59
+ if (slot.wrapKind !== "deks") {
60
+ throw new PasswordInvalidError(
61
+ "Password slot is not a wrap-DEKs slot. Pre-pre.8 wrap-KEK password slots are no longer supported \u2014 re-enrol via enrollPasswordAuthenticator."
62
+ );
63
+ }
56
64
  const meta = slot.meta;
57
65
  if (typeof meta.salt !== "string") {
58
66
  throw new PasswordInvalidError(
59
67
  "Password slot is missing the per-slot salt \u2014 keyring may be corrupted."
60
68
  );
61
69
  }
62
- const salt = base64ToBytes(meta.salt);
63
- const wrappingKey = await derivePasswordWrappingKey(password, salt);
64
70
  try {
65
- return await subtle.unwrapKey(
66
- "raw",
67
- base64ToBytes(slot.wrapped_kek),
68
- wrappingKey,
69
- "AES-KW",
70
- { name: "AES-KW", length: 256 },
71
- false,
72
- ["wrapKey", "unwrapKey"]
71
+ return await unwrapDeksFromBlob(
72
+ { salt: meta.salt, iv: slot.iv, wrappedDeks: slot.wrapped_deks },
73
+ password
73
74
  );
74
75
  } catch {
75
76
  throw new PasswordInvalidError();
76
77
  }
77
78
  }
78
79
  async function verifyPasswordSlot(slot, password, options) {
79
- const kek = await unwrapKekWithPassword(slot, password);
80
- return options.materialize(kek);
81
- }
82
- async function derivePasswordWrappingKey(password, salt) {
83
- const ikm = await subtle.importKey(
84
- "raw",
85
- new TextEncoder().encode(password),
86
- "PBKDF2",
87
- false,
88
- ["deriveKey"]
89
- );
90
- return subtle.deriveKey(
91
- {
92
- name: "PBKDF2",
93
- salt,
94
- iterations: PASSWORD_PBKDF2_ITERATIONS,
95
- hash: "SHA-256"
96
- },
97
- ikm,
98
- { name: "AES-KW", length: 256 },
99
- false,
100
- ["wrapKey", "unwrapKey"]
101
- );
102
- }
103
- function bytesToBase64(bytes) {
104
- let s = "";
105
- for (const b of bytes) s += String.fromCharCode(b);
106
- return btoa(s);
107
- }
108
- function base64ToBytes(b64) {
109
- const s = atob(b64);
110
- const out = new Uint8Array(s.length);
111
- for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
112
- return out;
80
+ const deks = await unwrapDeksWithPassword(slot, password);
81
+ const env = await options.store.get(options.vault, "_keyring", options.userId);
82
+ if (!env) {
83
+ throw new PasswordInvalidError(
84
+ `verifyPasswordSlot: no keyring found at "${options.vault}/_keyring/${options.userId}". Verify the vault and userId are correct.`
85
+ );
86
+ }
87
+ const file = JSON.parse(env._data);
88
+ const salt = new Uint8Array(base64ToBuffer(file.salt));
89
+ return {
90
+ userId: file.user_id,
91
+ displayName: file.display_name,
92
+ role: file.role,
93
+ permissions: file.permissions,
94
+ authenticators: file.authenticators ?? [],
95
+ salt,
96
+ ...file.export_capability !== void 0 && { exportCapability: file.export_capability },
97
+ ...file.import_capability !== void 0 && { importCapability: file.import_capability },
98
+ ...file.policy !== void 0 && { policy: file.policy },
99
+ deks,
100
+ // Wrap-DEKs unlock cannot recover the KEK. Sensitive ops route
101
+ // through tier-1 via re-entry of the master phrase. Matches the
102
+ // existing tier-3 (`@noy-db/on-pin`) pattern.
103
+ kek: null
104
+ };
113
105
  }
114
106
  export {
115
107
  PASSWORD_DEFAULT_MIN_LENGTH,
@@ -117,7 +109,7 @@ export {
117
109
  PasswordInvalidError,
118
110
  PasswordTooWeakError,
119
111
  enrollPasswordAuthenticator,
120
- unwrapKekWithPassword,
112
+ unwrapDeksWithPassword,
121
113
  verifyPasswordSlot
122
114
  };
123
115
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/on-password** — tier-2 daily-password authenticator slot.\n *\n * The user's tier-1 *phrase* is the rarely-typed master that derives\n * the KEK. This package adds a SEPARATE secret — a daily-typed\n * password — as a tier-2 slot in the multi-slot keyring. The two\n * credentials have:\n *\n * - **Different lifecycles** — the phrase rotates yearly; the password\n * rotates per the developer's policy.\n * - **Different strength rules** — the phrase is validated against the\n * phrase format (issue #7); the password is validated against a\n * length / regex rule the developer chooses.\n * - **Different storage** — the phrase derives the KEK; the password\n * derives a wrapping key that wraps the SAME KEK in its own\n * keyring slot (LUKS pattern).\n *\n * The slot is added to the keyring via `db.enrollAuthenticator`; the\n * KEK is recovered via `db.unlockViaAuthenticator` — both routes hit\n * the policy gate engine (issue #9).\n *\n * @see docs/subsystems/session-tiers.md → Tier 2 — `on-password`\n *\n * @packageDocumentation\n */\nimport type {\n EnrollAuthenticatorOptions,\n KeyringAuthenticator,\n UnlockedKeyring,\n} from '@noy-db/hub'\n\n/** PBKDF2 iteration count — matches the tier-1 phrase derivation. */\nexport const PASSWORD_PBKDF2_ITERATIONS = 600_000\n\n/** Default minimum password length. Override per-app via `enrollPasswordAuthenticator`. */\nexport const PASSWORD_DEFAULT_MIN_LENGTH = 12\n\n/** Per-slot salt size. */\nconst SALT_BYTES = 32\n\nconst subtle = globalThis.crypto.subtle\n\n// ─── Errors ────────────────────────────────────────────────────────────\n\nexport class PasswordTooWeakError extends Error {\n readonly code = 'PASSWORD_TOO_WEAK' as const\n readonly minLength: number\n constructor(minLength: number, message?: string) {\n super(\n message ??\n `Password must be at least ${String(minLength)} characters. ` +\n 'For accounts with a separate tier-1 phrase, prefer a longer password ' +\n 'or pair with TOTP/email-OTP via @noy-db/on-totp or @noy-db/on-email-otp.',\n )\n this.name = 'PasswordTooWeakError'\n this.minLength = minLength\n }\n}\n\nexport class PasswordInvalidError extends Error {\n readonly code = 'PASSWORD_INVALID' as const\n constructor(message = 'Password does not unlock this slot.') {\n super(message)\n this.name = 'PasswordInvalidError'\n }\n}\n\n// ─── Public API ────────────────────────────────────────────────────────\n\n/** Options for {@link enrollPasswordAuthenticator}. */\nexport interface EnrollPasswordOptions {\n /** Slot id. Default: `'password-daily'`. */\n readonly id?: string\n /** Daily password the user will type. Distinct from the tier-1 phrase. */\n readonly password: string\n /** Minimum length. Default 12. */\n readonly minLength?: number\n /** Optional regex the password must satisfy in addition to length. */\n readonly pattern?: RegExp\n /** Tier the active session held when enrolling. Default 1. */\n readonly enrolledViaTier?: 1 | 2\n}\n\n/**\n * Build the keyring slot for a tier-2 password authenticator. Returns\n * an `EnrollAuthenticatorOptions` value the caller hands to\n * `db.enrollAuthenticator(vault, slot)` — separating the cryptographic\n * step (this function) from the persistence step (the hub) keeps the\n * package small and lets the hub's policy gate run between the two.\n *\n * Usage:\n *\n * ```ts\n * import { enrollPasswordAuthenticator } from '@noy-db/on-password'\n *\n * const slot = await enrollPasswordAuthenticator(unlocked, {\n * password: 'daily-password-2026',\n * minLength: 14,\n * })\n * await db.enrollAuthenticator('acme', slot, {\n * factors: [{ kind: 'totp' }],\n * })\n * ```\n */\nexport async function enrollPasswordAuthenticator(\n keyring: UnlockedKeyring,\n options: EnrollPasswordOptions,\n): Promise<EnrollAuthenticatorOptions> {\n const minLength = options.minLength ?? PASSWORD_DEFAULT_MIN_LENGTH\n if (options.password.length < minLength) {\n throw new PasswordTooWeakError(minLength)\n }\n if (options.pattern && !options.pattern.test(options.password)) {\n throw new PasswordTooWeakError(\n minLength,\n `Password does not match the configured pattern: ${options.pattern.toString()}.`,\n )\n }\n\n if (!keyring.kek) {\n throw new Error(\n 'enrollPasswordAuthenticator: the supplied keyring has no KEK in memory. ' +\n 'Tier-3 quick-resume keyrings cannot enrol new tier-2 slots; re-authenticate at tier 1 first.',\n )\n }\n\n const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES))\n const wrappingKey = await derivePasswordWrappingKey(options.password, salt)\n const wrapped = await subtle.wrapKey('raw', keyring.kek, wrappingKey, 'AES-KW')\n\n return {\n id: options.id ?? 'password-daily',\n method: 'password',\n wrapped_kek: bytesToBase64(new Uint8Array(wrapped)),\n meta: {\n salt: bytesToBase64(salt),\n minLength,\n ...(options.pattern !== undefined ? { pattern: options.pattern.source } : {}),\n },\n enrolled_via_tier: options.enrolledViaTier ?? 1,\n }\n}\n\n/**\n * Recover the KEK from a password slot's `wrapped_kek` ciphertext.\n * Returns the unwrapped KEK as a non-extractable `CryptoKey` ready for\n * AES-KW unwrap of DEKs. Used inside the verify callback passed to\n * `db.unlockViaAuthenticator`.\n *\n * @throws {@link PasswordInvalidError} when the password is wrong.\n */\nexport async function unwrapKekWithPassword(\n slot: KeyringAuthenticator,\n password: string,\n): Promise<CryptoKey> {\n const meta = slot.meta as { salt?: unknown }\n if (typeof meta.salt !== 'string') {\n throw new PasswordInvalidError(\n 'Password slot is missing the per-slot salt — keyring may be corrupted.',\n )\n }\n const salt = base64ToBytes(meta.salt)\n const wrappingKey = await derivePasswordWrappingKey(password, salt)\n try {\n return await subtle.unwrapKey(\n 'raw',\n base64ToBytes(slot.wrapped_kek) as BufferSource,\n wrappingKey,\n 'AES-KW',\n { name: 'AES-KW', length: 256 },\n false,\n ['wrapKey', 'unwrapKey'],\n )\n } catch {\n throw new PasswordInvalidError()\n }\n}\n\n/**\n * Hub-friendly verify callback. Pass to `db.unlockViaAuthenticator`:\n *\n * ```ts\n * const unlocked = await db.unlockViaAuthenticator('acme', 'password-daily',\n * (slot) => verifyPasswordSlot(slot, 'daily-password-2026', { adapter: store, vault: 'acme', userId: 'alice' }),\n * )\n * ```\n *\n * The callback re-loads the keyring file via the supplied adapter,\n * unwraps every DEK with the recovered KEK, and returns the\n * `UnlockedKeyring` the hub installs in its keyring cache.\n *\n * @throws {@link PasswordInvalidError} when the password is wrong.\n */\nexport async function verifyPasswordSlot(\n slot: KeyringAuthenticator,\n password: string,\n options: VerifyPasswordSlotOptions,\n): Promise<UnlockedKeyring> {\n const kek = await unwrapKekWithPassword(slot, password)\n return options.materialize(kek)\n}\n\n/** Adapter shape required by {@link verifyPasswordSlot}. */\nexport interface VerifyPasswordSlotOptions {\n /**\n * Caller-supplied \"given the recovered KEK, build the\n * `UnlockedKeyring`\" routine. The hub provides the standard\n * implementation in {@link buildUnlockedKeyringFromKek}; consumers\n * with non-standard storage (e.g. encrypted browser-extension\n * stores) can pass their own.\n */\n readonly materialize: (kek: CryptoKey) => Promise<UnlockedKeyring>\n}\n\n// ─── Helpers ───────────────────────────────────────────────────────────\n\nasync function derivePasswordWrappingKey(\n password: string,\n salt: Uint8Array,\n): Promise<CryptoKey> {\n const ikm = await subtle.importKey(\n 'raw',\n new TextEncoder().encode(password),\n 'PBKDF2',\n false,\n ['deriveKey'],\n )\n return subtle.deriveKey(\n {\n name: 'PBKDF2',\n salt: salt as BufferSource,\n iterations: PASSWORD_PBKDF2_ITERATIONS,\n hash: 'SHA-256',\n },\n ikm,\n { name: 'AES-KW', length: 256 },\n false,\n ['wrapKey', 'unwrapKey'],\n )\n}\n\nfunction bytesToBase64(bytes: Uint8Array): string {\n let s = ''\n for (const b of bytes) s += String.fromCharCode(b)\n return btoa(s)\n}\n\nfunction base64ToBytes(b64: string): Uint8Array {\n const s = atob(b64)\n const out = new Uint8Array(s.length)\n for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i)\n return out\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/on-password** — tier-2 daily-password authenticator slot.\n *\n * The user's tier-1 *phrase* is the rarely-typed master that derives\n * the KEK. This package adds a SEPARATE secret — a daily-typed\n * password — as a tier-2 slot in the multi-slot keyring. The two\n * credentials have:\n *\n * - **Different lifecycles** — the phrase rotates yearly; the password\n * rotates per the developer's policy.\n * - **Different strength rules** — the phrase is validated against the\n * phrase format (issue #7); the password is validated against a\n * length / regex rule the developer chooses.\n * - **Different storage** — the phrase derives the KEK; the password\n * derives a wrapping key that wraps the SAME DEK SET in its own\n * keyring slot (LUKS-like multi-slot, wrap-DEKs variant).\n *\n * ## Wrap-DEKs format (#26 Path C)\n *\n * Slots produced by this package use the wrap-DEKs variant of\n * `KeyringAuthenticator` — they encrypt the serialized DEK set under\n * a password-derived AES-GCM key, NOT the KEK. This unifies tier-2\n * password slots with the tier-0 (paper recovery, `mintPaperRecoveryEntry`)\n * and tier-3 (`@noy-db/on-pin`'s `wrappedKeyring`) primitives — all\n * three sidestep the non-extractable-KEK constraint by wrapping the\n * DEK set rather than the KEK itself.\n *\n * Trade-off: an `UnlockedKeyring` produced via password-slot unlock\n * has `kek: null`. Sensitive operations (`enrollAuthenticator`,\n * `rotatePassphrase`) require a tier-1 unlock anyway — re-enter the\n * master phrase.\n *\n * @see docs/subsystems/session-tiers.md → Tier 2 — `on-password`\n *\n * @packageDocumentation\n */\nimport {\n base64ToBuffer,\n mintWrappedDeksBlob,\n unwrapDeksFromBlob,\n type EnrollAuthenticatorOptions,\n type KeyringAuthenticator,\n type KeyringFile,\n type NoydbStore,\n type UnlockedKeyring,\n} from '@noy-db/hub'\n\n/**\n * PBKDF2 iteration count — matches the tier-1 phrase derivation.\n * Exported as a documented invariant; the actual derivation lives\n * in hub's canonical wrap-DEKs primitive (#44).\n */\nexport const PASSWORD_PBKDF2_ITERATIONS = 600_000\n\n/** Default minimum password length. Override per-app via `enrollPasswordAuthenticator`. */\nexport const PASSWORD_DEFAULT_MIN_LENGTH = 12\n\n// ─── Errors ────────────────────────────────────────────────────────────\n\nexport class PasswordTooWeakError extends Error {\n readonly code = 'PASSWORD_TOO_WEAK' as const\n readonly minLength: number\n constructor(minLength: number, message?: string) {\n super(\n message ??\n `Password must be at least ${String(minLength)} characters. ` +\n 'For accounts with a separate tier-1 phrase, prefer a longer password ' +\n 'or pair with TOTP/email-OTP via @noy-db/on-totp or @noy-db/on-email-otp.',\n )\n this.name = 'PasswordTooWeakError'\n this.minLength = minLength\n }\n}\n\nexport class PasswordInvalidError extends Error {\n readonly code = 'PASSWORD_INVALID' as const\n constructor(message = 'Password does not unlock this slot.') {\n super(message)\n this.name = 'PasswordInvalidError'\n }\n}\n\n// ─── Public API ────────────────────────────────────────────────────────\n\n/** Options for {@link enrollPasswordAuthenticator}. */\nexport interface EnrollPasswordOptions {\n /** Slot id. Default: `'password-daily'`. */\n readonly id?: string\n /** Daily password the user will type. Distinct from the tier-1 phrase. */\n readonly password: string\n /** Minimum length. Default 12. */\n readonly minLength?: number\n /** Optional regex the password must satisfy in addition to length. */\n readonly pattern?: RegExp\n /** Tier the active session held when enrolling. Default 1. */\n readonly enrolledViaTier?: 1 | 2\n}\n\n/**\n * Build the keyring slot for a tier-2 password authenticator. Returns\n * an `EnrollAuthenticatorOptions` value the caller hands to\n * `db.enrollAuthenticator(vault, slot)` — separating the cryptographic\n * step (this function) from the persistence step (the hub) keeps the\n * package small and lets the hub's policy gate run between the two.\n *\n * The slot uses the **wrap-DEKs** variant of `KeyringAuthenticator`:\n * the DEK set is serialized to JSON and encrypted with AES-GCM under\n * a PBKDF2-derived key. No requirement on `keyring.kek` — works with\n * tier-1 unlocked keyrings; throws if `keyring.deks` is empty.\n *\n * Usage:\n *\n * ```ts\n * import { enrollPasswordAuthenticator } from '@noy-db/on-password'\n *\n * const keyring = await db.getKeyring('acme')\n * const slot = await enrollPasswordAuthenticator(keyring, {\n * password: 'daily-password-2026',\n * minLength: 14,\n * })\n * await db.enrollAuthenticator('acme', slot, {\n * factors: [{ kind: 'totp' }],\n * })\n * ```\n */\nexport async function enrollPasswordAuthenticator(\n keyring: UnlockedKeyring,\n options: EnrollPasswordOptions,\n): Promise<EnrollAuthenticatorOptions> {\n const minLength = options.minLength ?? PASSWORD_DEFAULT_MIN_LENGTH\n if (options.password.length < minLength) {\n throw new PasswordTooWeakError(minLength)\n }\n if (options.pattern && !options.pattern.test(options.password)) {\n throw new PasswordTooWeakError(\n minLength,\n `Password does not match the configured pattern: ${options.pattern.toString()}.`,\n )\n }\n\n if (keyring.deks.size === 0) {\n throw new Error(\n 'enrollPasswordAuthenticator: the supplied keyring has no DEKs in memory. ' +\n 'Re-authenticate at tier 1 first.',\n )\n }\n\n // Delegate the wrap-DEKs crypto to the canonical hub primitive (#44).\n // The slot envelope still stores `salt` inside `meta` for backward\n // compatibility with the pre-#44 slot format; the issue defers\n // moving it to top-level for parity with PaperRecoveryEntry.\n const blob = await mintWrappedDeksBlob(keyring.deks, options.password)\n\n return {\n id: options.id ?? 'password-daily',\n method: 'password',\n wrapKind: 'deks',\n wrapped_deks: blob.wrappedDeks,\n iv: blob.iv,\n meta: {\n salt: blob.salt,\n minLength,\n ...(options.pattern !== undefined ? { pattern: options.pattern.source } : {}),\n },\n enrolled_via_tier: options.enrolledViaTier ?? 1,\n }\n}\n\n/**\n * Recover the DEK set from a wrap-DEKs password slot. Returns the raw\n * DEK map; the hub-friendly verifier {@link verifyPasswordSlot} wraps\n * this and produces a full `UnlockedKeyring`.\n *\n * @throws {@link PasswordInvalidError} when the password is wrong or\n * the slot is not a wrap-DEKs slot (e.g. a legacy wrap-KEK password\n * slot from before pre.8 — those need re-enrollment).\n */\nexport async function unwrapDeksWithPassword(\n slot: KeyringAuthenticator,\n password: string,\n): Promise<Map<string, CryptoKey>> {\n if (slot.wrapKind !== 'deks') {\n throw new PasswordInvalidError(\n 'Password slot is not a wrap-DEKs slot. Pre-pre.8 wrap-KEK password ' +\n 'slots are no longer supported — re-enrol via enrollPasswordAuthenticator.',\n )\n }\n\n const meta = slot.meta as { salt?: unknown }\n if (typeof meta.salt !== 'string') {\n throw new PasswordInvalidError(\n 'Password slot is missing the per-slot salt — keyring may be corrupted.',\n )\n }\n\n // Delegate to the canonical wrap-DEKs primitive (#44). The blob's\n // three fields (salt / iv / wrappedDeks) are reconstructed from\n // the slot's split layout: salt lives in meta, iv + wrappedDeks\n // at top level. Pre-#44, this code re-implemented the same crypto\n // inline.\n try {\n return await unwrapDeksFromBlob(\n { salt: meta.salt, iv: slot.iv, wrappedDeks: slot.wrapped_deks },\n password,\n )\n } catch {\n throw new PasswordInvalidError()\n }\n}\n\n/**\n * Hub-friendly verify callback. Pass to `db.unlockViaAuthenticator`:\n *\n * ```ts\n * import { verifyPasswordSlot } from '@noy-db/on-password'\n *\n * const unlocked = await db.unlockViaAuthenticator('acme', 'password-daily',\n * (slot) => verifyPasswordSlot(slot, 'daily-password-2026',\n * { store, vault: 'acme', userId: 'alice' }),\n * )\n * ```\n *\n * Unwraps the DEK set with the supplied password and returns an\n * `UnlockedKeyring` the hub installs in its keyring cache. The\n * returned keyring has `kek: null` — sensitive operations (enrol new\n * slot, rotate phrase) require a tier-1 unlock from the master phrase.\n *\n * The `{ store, vault, userId }` shape works in both contexts:\n * - **Warm re-unlock** — db is alive; consumer already has identity\n * in memory but pays one cheap `_keyring/<userId>` read.\n * - **Cold-start** (`createNoydb({ getKeyring: ... })`) — consumer\n * does NOT have a keyring yet; the verifier loads identity from\n * disk before returning the unlocked keyring. This is the\n * primary cold-start tier-2 path (Niwat tier-2b uses this).\n *\n * Identity fields (userId, displayName, role, permissions,\n * authenticators, salt, capability bits, per-keyring policy) are read\n * directly from the `_keyring/<userId>` envelope's plaintext header —\n * those fields are not encrypted because the sync engine + grant flow\n * need them without a key.\n *\n * @throws {@link PasswordInvalidError} when the password is wrong or\n * the keyring file is missing.\n */\nexport async function verifyPasswordSlot(\n slot: KeyringAuthenticator,\n password: string,\n options: VerifyPasswordSlotOptions,\n): Promise<UnlockedKeyring> {\n const deks = await unwrapDeksWithPassword(slot, password)\n\n const env = await options.store.get(options.vault, '_keyring', options.userId)\n if (!env) {\n throw new PasswordInvalidError(\n `verifyPasswordSlot: no keyring found at \"${options.vault}/_keyring/${options.userId}\". ` +\n 'Verify the vault and userId are correct.',\n )\n }\n const file = JSON.parse(env._data) as KeyringFile\n const salt = new Uint8Array(base64ToBuffer(file.salt))\n\n return {\n userId: file.user_id,\n displayName: file.display_name,\n role: file.role,\n permissions: file.permissions,\n authenticators: file.authenticators ?? [],\n salt,\n ...(file.export_capability !== undefined && { exportCapability: file.export_capability }),\n ...(file.import_capability !== undefined && { importCapability: file.import_capability }),\n ...(file.policy !== undefined && { policy: file.policy }),\n deks,\n // Wrap-DEKs unlock cannot recover the KEK. Sensitive ops route\n // through tier-1 via re-entry of the master phrase. Matches the\n // existing tier-3 (`@noy-db/on-pin`) pattern.\n kek: null,\n }\n}\n\n/** Adapter shape required by {@link verifyPasswordSlot}. */\nexport interface VerifyPasswordSlotOptions {\n /** The vault's NoydbStore — used to load `_keyring/<userId>`. */\n readonly store: NoydbStore\n /** Vault name — same value passed to `db.openVault(name)`. */\n readonly vault: string\n /** User id — same value passed to `createNoydb({ user })`. */\n readonly userId: string\n}\n\n// All wrap-DEKs crypto helpers (PBKDF2 derivation, base64\n// codecs) moved to hub's `team/wrapped-deks.ts` in #44 — both this\n// package and `mintPaperRecoveryEntry` now share one implementation.\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noy-db/on-password",
3
- "version": "0.1.0-pre.7",
3
+ "version": "0.1.0-pre.8",
4
4
  "description": "Tier-2 daily-password authenticator slot for noy-db. PBKDF2-SHA256 600K wraps the KEK in its own keyring slot, distinct from the rarely-typed tier-1 phrase. Pair with @noy-db/on-email-otp or @noy-db/on-totp for the SaaS UX.",
5
5
  "license": "MIT",
6
6
  "author": "vLannaAi <vicio@lanna.ai>",
@@ -39,10 +39,10 @@
39
39
  "node": ">=18.0.0"
40
40
  },
41
41
  "peerDependencies": {
42
- "@noy-db/hub": "0.1.0-pre.7"
42
+ "@noy-db/hub": "0.1.0-pre.8"
43
43
  },
44
44
  "devDependencies": {
45
- "@noy-db/hub": "0.1.0-pre.7"
45
+ "@noy-db/hub": "0.1.0-pre.8"
46
46
  },
47
47
  "keywords": [
48
48
  "noy-db",