@noy-db/on-password 0.2.0-pre.3 → 0.2.0-pre.31

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.d.ts CHANGED
@@ -11,13 +11,13 @@ import { NoydbStore, UnlockedKeyring, EnrollAuthenticatorOptions, SlotRewrapCere
11
11
  * - **Different lifecycles** — the phrase rotates yearly; the password
12
12
  * rotates per the developer's policy.
13
13
  * - **Different strength rules** — the phrase is validated against the
14
- * phrase format (issue #7); the password is validated against a
14
+ * phrase format; 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
17
  * derives a wrapping key that wraps the SAME DEK SET in its own
18
18
  * keyring slot (LUKS-like multi-slot, wrap-DEKs variant).
19
19
  *
20
- * ## Wrap-DEKs format (#26 Path C)
20
+ * ## Wrap-DEKs format (Path C)
21
21
  *
22
22
  * Slots produced by this package use the wrap-DEKs variant of
23
23
  * `KeyringAuthenticator` — they encrypt the serialized DEK set under
@@ -40,7 +40,7 @@ import { NoydbStore, UnlockedKeyring, EnrollAuthenticatorOptions, SlotRewrapCere
40
40
  /**
41
41
  * PBKDF2 iteration count — matches the tier-1 phrase derivation.
42
42
  * Exported as a documented invariant; the actual derivation lives
43
- * in hub's canonical wrap-DEKs primitive (#44).
43
+ * in hub's canonical wrap-DEKs primitive.
44
44
  */
45
45
  declare const PASSWORD_PBKDF2_ITERATIONS = 600000;
46
46
  /** Default minimum password length. Override per-app via `enrollPasswordAuthenticator`. */
@@ -102,7 +102,7 @@ declare function enrollPasswordAuthenticator(keyring: UnlockedKeyring, options:
102
102
  *
103
103
  * @throws {@link PasswordInvalidError} when the password is wrong or
104
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).
105
+ * legacy wrap-KEK slots — those need re-enrollment).
106
106
  */
107
107
  declare function unwrapDeksWithPassword(slot: KeyringAuthenticator, password: string): Promise<Map<string, CryptoKey>>;
108
108
  /**
@@ -151,10 +151,10 @@ interface VerifyPasswordSlotOptions {
151
151
  }
152
152
  /**
153
153
  * `SlotRewrapCeremony` factory for password slots — the password parallel
154
- * to `webAuthnSlotRewrapCeremony` (#56). Used by hub's
154
+ * to `webAuthnSlotRewrapCeremony`. Used by hub's
155
155
  * `rotatePassphrase({ slotCeremonies: { [slotId]: passwordSlotRewrapCeremony(pwd) } })`
156
156
  * to preserve a tier-2 password enrollment across a tier-1 phrase
157
- * rotation without forcing the user to re-enroll the password (#96).
157
+ * rotation without forcing the user to re-enroll the password.
158
158
  *
159
159
  * Returns a closure capturing the password so the result matches
160
160
  * hub's `SlotRewrapCeremony` signature `(ctx) => Promise<EnrollAuthenticatorOptions>`
@@ -189,7 +189,7 @@ interface VerifyPasswordSlotOptions {
189
189
  * `id` + `method` + `wrapKind` to prevent slot-type swap mid-
190
190
  * rotation.
191
191
  *
192
- * Pre-pre.8 wrap-KEK password slots (legacy) cannot be rewrapped via
192
+ * Legacy wrap-KEK password slots cannot be rewrapped via
193
193
  * this ceremony — they must be re-enrolled fresh via
194
194
  * {@link enrollPasswordAuthenticator}. The validation throws
195
195
  * {@link ValidationError} for those.
@@ -199,8 +199,7 @@ interface VerifyPasswordSlotOptions {
199
199
  * @throws {PasswordTooWeakError} when the supplied password fails the
200
200
  * strength rules carried in `oldSlot.meta` (minLength, pattern).
201
201
  *
202
- * @see #56 webAuthnSlotRewrapCeremony — the WebAuthn parallel.
203
- * @see #29 — the slotCeremonies plumbing this fills in.
202
+ * @see webAuthnSlotRewrapCeremony — the WebAuthn parallel.
204
203
  */
205
204
  declare function passwordSlotRewrapCeremony(password: string): SlotRewrapCeremony;
206
205
 
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/on-password** — tier-2 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 password the user\n * types at each unlock — as a tier-2 slot in the multi-slot keyring.\n * The two 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 ValidationError,\n type EnrollAuthenticatorOptions,\n type KeyringAuthenticator,\n type KeyringFile,\n type NoydbStore,\n type SlotRewrapCeremony,\n type SlotRewrapContext,\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'`. */\n readonly id?: string\n /** Password the user will type at unlock. 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: 'strong-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',\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',\n * (slot) => verifyPasswordSlot(slot, 'strong-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/**\n * `SlotRewrapCeremony` factory for password slots — the password parallel\n * to `webAuthnSlotRewrapCeremony` (#56). Used by hub's\n * `rotatePassphrase({ slotCeremonies: { [slotId]: passwordSlotRewrapCeremony(pwd) } })`\n * to preserve a tier-2 password enrollment across a tier-1 phrase\n * rotation without forcing the user to re-enroll the password (#96).\n *\n * Returns a closure capturing the password so the result matches\n * hub's `SlotRewrapCeremony` signature `(ctx) => Promise<EnrollAuthenticatorOptions>`\n * directly. Pass into `slotCeremonies` keyed by the existing slot id:\n *\n * ```ts\n * import { passwordSlotRewrapCeremony } from '@noy-db/on-password'\n *\n * await db.rotatePassphrase('acme', {\n * oldPassphrase: oldPhrase,\n * newPassphrase: newPhrase,\n * slotCeremonies: {\n * 'password': passwordSlotRewrapCeremony('strong-password-2026'),\n * },\n * })\n * ```\n *\n * The password itself is unaffected by phrase rotation — what needs to\n * change is the **wrapped DEK set**: the encrypted blob the slot's\n * `wrapped_deks` field holds. After rotation, the old blob still has\n * the old DEKs (now stale because rotatePassphrase rewrapped them\n * under a fresh KEK); the new blob must hold the freshly rewrapped\n * `ctx.newDeks`.\n *\n * Single ceremony, one operation:\n * 1. Validate `oldSlot.method === 'password'` and `oldSlot.wrapKind === 'deks'`.\n * 2. Re-mint the wrap-DEKs blob via {@link enrollPasswordAuthenticator}\n * using the supplied password against `ctx.newDeks`.\n * 3. Return `EnrollAuthenticatorOptions` preserving `oldSlot.id`,\n * `method: 'password'`, and the strength config (`minLength`,\n * `pattern`) carried in the old slot's `meta`. Hub validates\n * `id` + `method` + `wrapKind` to prevent slot-type swap mid-\n * rotation.\n *\n * Pre-pre.8 wrap-KEK password slots (legacy) cannot be rewrapped via\n * this ceremony — they must be re-enrolled fresh via\n * {@link enrollPasswordAuthenticator}. The validation throws\n * {@link ValidationError} for those.\n *\n * @throws {ValidationError} when `oldSlot.method !== 'password'` or\n * `oldSlot.wrapKind !== 'deks'`.\n * @throws {PasswordTooWeakError} when the supplied password fails the\n * strength rules carried in `oldSlot.meta` (minLength, pattern).\n *\n * @see #56 webAuthnSlotRewrapCeremony — the WebAuthn parallel.\n * @see #29 — the slotCeremonies plumbing this fills in.\n */\nexport function passwordSlotRewrapCeremony(password: string): SlotRewrapCeremony {\n return async (ctx: SlotRewrapContext): Promise<EnrollAuthenticatorOptions> => {\n if (ctx.oldSlot.method !== 'password') {\n throw new ValidationError(\n `passwordSlotRewrapCeremony: oldSlot.method is \"${ctx.oldSlot.method}\"; expected \"password\". ` +\n 'This ceremony only handles password slots — pair other methods with their own helpers.',\n )\n }\n if (ctx.oldSlot.wrapKind !== 'deks') {\n throw new ValidationError(\n 'passwordSlotRewrapCeremony: oldSlot is a wrap-KEK slot; expected wrap-DEKs. ' +\n 'Password slots use the wrap-DEKs variant since #26 Path C; legacy wrap-KEK ' +\n 'password slots are not supported here — re-enrol via enrollPasswordAuthenticator.',\n )\n }\n\n // Preserve the strength config the slot was originally enrolled with —\n // a rotation should not silently downgrade minLength or drop a pattern.\n const oldMeta = ctx.oldSlot.meta as { minLength?: unknown; pattern?: unknown }\n const minLength = typeof oldMeta.minLength === 'number' ? oldMeta.minLength : undefined\n const pattern = typeof oldMeta.pattern === 'string' ? new RegExp(oldMeta.pattern) : undefined\n\n return enrollPasswordAuthenticator(\n // enrollPasswordAuthenticator only reads `keyring.deks`, so a\n // synthetic minimal keyring is enough. The freshly-rewrapped DEKs\n // come from hub via `ctx.newDeks`.\n { deks: ctx.newDeks } as UnlockedKeyring,\n {\n id: ctx.oldSlot.id,\n password,\n ...(minLength !== undefined && { minLength }),\n ...(pattern !== undefined && { pattern }),\n // Carry the original tier so hub's anti-slot-swap guard plus\n // the rotate-recover preservation rule keep the slot's\n // provenance honest.\n enrolledViaTier: ctx.oldSlot.enrolled_via_tier,\n },\n )\n }\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":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/on-password** — tier-2 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 password the user\n * types at each unlock — as a tier-2 slot in the multi-slot keyring.\n * The two 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; 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 (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 ValidationError,\n type EnrollAuthenticatorOptions,\n type KeyringAuthenticator,\n type KeyringFile,\n type NoydbStore,\n type SlotRewrapCeremony,\n type SlotRewrapContext,\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.\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'`. */\n readonly id?: string\n /** Password the user will type at unlock. 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: 'strong-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.\n // The slot envelope still stores `salt` inside `meta` for backward\n // compatibility with the legacy slot format; moving it to top-level\n // for parity with PaperRecoveryEntry is deferred.\n const blob = await mintWrappedDeksBlob(keyring.deks, options.password)\n\n return {\n id: options.id ?? 'password',\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 * legacy wrap-KEK slots — 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. 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. Previously, 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',\n * (slot) => verifyPasswordSlot(slot, 'strong-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/**\n * `SlotRewrapCeremony` factory for password slots — the password parallel\n * to `webAuthnSlotRewrapCeremony`. Used by hub's\n * `rotatePassphrase({ slotCeremonies: { [slotId]: passwordSlotRewrapCeremony(pwd) } })`\n * to preserve a tier-2 password enrollment across a tier-1 phrase\n * rotation without forcing the user to re-enroll the password.\n *\n * Returns a closure capturing the password so the result matches\n * hub's `SlotRewrapCeremony` signature `(ctx) => Promise<EnrollAuthenticatorOptions>`\n * directly. Pass into `slotCeremonies` keyed by the existing slot id:\n *\n * ```ts\n * import { passwordSlotRewrapCeremony } from '@noy-db/on-password'\n *\n * await db.rotatePassphrase('acme', {\n * oldPassphrase: oldPhrase,\n * newPassphrase: newPhrase,\n * slotCeremonies: {\n * 'password': passwordSlotRewrapCeremony('strong-password-2026'),\n * },\n * })\n * ```\n *\n * The password itself is unaffected by phrase rotation — what needs to\n * change is the **wrapped DEK set**: the encrypted blob the slot's\n * `wrapped_deks` field holds. After rotation, the old blob still has\n * the old DEKs (now stale because rotatePassphrase rewrapped them\n * under a fresh KEK); the new blob must hold the freshly rewrapped\n * `ctx.newDeks`.\n *\n * Single ceremony, one operation:\n * 1. Validate `oldSlot.method === 'password'` and `oldSlot.wrapKind === 'deks'`.\n * 2. Re-mint the wrap-DEKs blob via {@link enrollPasswordAuthenticator}\n * using the supplied password against `ctx.newDeks`.\n * 3. Return `EnrollAuthenticatorOptions` preserving `oldSlot.id`,\n * `method: 'password'`, and the strength config (`minLength`,\n * `pattern`) carried in the old slot's `meta`. Hub validates\n * `id` + `method` + `wrapKind` to prevent slot-type swap mid-\n * rotation.\n *\n * Legacy wrap-KEK password slots cannot be rewrapped via\n * this ceremony — they must be re-enrolled fresh via\n * {@link enrollPasswordAuthenticator}. The validation throws\n * {@link ValidationError} for those.\n *\n * @throws {ValidationError} when `oldSlot.method !== 'password'` or\n * `oldSlot.wrapKind !== 'deks'`.\n * @throws {PasswordTooWeakError} when the supplied password fails the\n * strength rules carried in `oldSlot.meta` (minLength, pattern).\n *\n * @see webAuthnSlotRewrapCeremony — the WebAuthn parallel.\n */\nexport function passwordSlotRewrapCeremony(password: string): SlotRewrapCeremony {\n return async (ctx: SlotRewrapContext): Promise<EnrollAuthenticatorOptions> => {\n if (ctx.oldSlot.method !== 'password') {\n throw new ValidationError(\n `passwordSlotRewrapCeremony: oldSlot.method is \"${ctx.oldSlot.method}\"; expected \"password\". ` +\n 'This ceremony only handles password slots — pair other methods with their own helpers.',\n )\n }\n if (ctx.oldSlot.wrapKind !== 'deks') {\n throw new ValidationError(\n 'passwordSlotRewrapCeremony: oldSlot is a wrap-KEK slot; expected wrap-DEKs. ' +\n 'Password slots use the wrap-DEKs variant since #26 Path C; legacy wrap-KEK ' +\n 'password slots are not supported here — re-enrol via enrollPasswordAuthenticator.',\n )\n }\n\n // Preserve the strength config the slot was originally enrolled with —\n // a rotation should not silently downgrade minLength or drop a pattern.\n const oldMeta = ctx.oldSlot.meta as { minLength?: unknown; pattern?: unknown }\n const minLength = typeof oldMeta.minLength === 'number' ? oldMeta.minLength : undefined\n const pattern = typeof oldMeta.pattern === 'string' ? new RegExp(oldMeta.pattern) : undefined\n\n return enrollPasswordAuthenticator(\n // enrollPasswordAuthenticator only reads `keyring.deks`, so a\n // synthetic minimal keyring is enough. The freshly-rewrapped DEKs\n // come from hub via `ctx.newDeks`.\n { deks: ctx.newDeks } as UnlockedKeyring,\n {\n id: ctx.oldSlot.id,\n password,\n ...(minLength !== undefined && { minLength }),\n ...(pattern !== undefined && { pattern }),\n // Carry the original tier so hub's anti-slot-swap guard plus\n // the rotate-recover preservation rule keep the slot's\n // provenance honest.\n enrolledViaTier: ctx.oldSlot.enrolled_via_tier,\n },\n )\n }\n}\n\n// All wrap-DEKs crypto helpers (PBKDF2 derivation, base64\n// codecs) live in hub's `team/wrapped-deks.ts` — both this\n// package and `mintPaperRecoveryEntry` 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.2.0-pre.3",
3
+ "version": "0.2.0-pre.31",
4
4
  "description": "Tier-2 password authenticator slot for noy-db. PBKDF2-SHA256 600K wraps the DEK set 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>",
@@ -17,17 +17,10 @@
17
17
  "sideEffects": false,
18
18
  "exports": {
19
19
  ".": {
20
- "import": {
21
- "types": "./dist/index.d.ts",
22
- "default": "./dist/index.js"
23
- },
24
- "require": {
25
- "types": "./dist/index.d.cts",
26
- "default": "./dist/index.cjs"
27
- }
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
28
22
  }
29
23
  },
30
- "main": "./dist/index.cjs",
31
24
  "module": "./dist/index.js",
32
25
  "types": "./dist/index.d.ts",
33
26
  "files": [
@@ -36,13 +29,13 @@
36
29
  "LICENSE"
37
30
  ],
38
31
  "engines": {
39
- "node": ">=18.0.0"
32
+ "node": ">=22.0.0"
40
33
  },
41
34
  "peerDependencies": {
42
- "@noy-db/hub": "0.2.0-pre.3"
35
+ "@noy-db/hub": "0.2.0-pre.31"
43
36
  },
44
37
  "devDependencies": {
45
- "@noy-db/hub": "0.2.0-pre.3"
38
+ "@noy-db/hub": "0.2.0-pre.31"
46
39
  },
47
40
  "keywords": [
48
41
  "noy-db",
package/dist/index.cjs DELETED
@@ -1,177 +0,0 @@
1
- "use strict";
2
- var __defProp = Object.defineProperty;
3
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
- var __getOwnPropNames = Object.getOwnPropertyNames;
5
- var __hasOwnProp = Object.prototype.hasOwnProperty;
6
- var __export = (target, all) => {
7
- for (var name in all)
8
- __defProp(target, name, { get: all[name], enumerable: true });
9
- };
10
- var __copyProps = (to, from, except, desc) => {
11
- if (from && typeof from === "object" || typeof from === "function") {
12
- for (let key of __getOwnPropNames(from))
13
- if (!__hasOwnProp.call(to, key) && key !== except)
14
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
- }
16
- return to;
17
- };
18
- var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
-
20
- // src/index.ts
21
- var index_exports = {};
22
- __export(index_exports, {
23
- PASSWORD_DEFAULT_MIN_LENGTH: () => PASSWORD_DEFAULT_MIN_LENGTH,
24
- PASSWORD_PBKDF2_ITERATIONS: () => PASSWORD_PBKDF2_ITERATIONS,
25
- PasswordInvalidError: () => PasswordInvalidError,
26
- PasswordTooWeakError: () => PasswordTooWeakError,
27
- enrollPasswordAuthenticator: () => enrollPasswordAuthenticator,
28
- passwordSlotRewrapCeremony: () => passwordSlotRewrapCeremony,
29
- unwrapDeksWithPassword: () => unwrapDeksWithPassword,
30
- verifyPasswordSlot: () => verifyPasswordSlot
31
- });
32
- module.exports = __toCommonJS(index_exports);
33
- var import_hub = require("@noy-db/hub");
34
- var PASSWORD_PBKDF2_ITERATIONS = 6e5;
35
- var PASSWORD_DEFAULT_MIN_LENGTH = 12;
36
- var PasswordTooWeakError = class extends Error {
37
- code = "PASSWORD_TOO_WEAK";
38
- minLength;
39
- constructor(minLength, message) {
40
- super(
41
- message ?? `Password must be at least ${String(minLength)} characters. For accounts with a separate tier-1 phrase, prefer a longer password or pair with TOTP/email-OTP via @noy-db/on-totp or @noy-db/on-email-otp.`
42
- );
43
- this.name = "PasswordTooWeakError";
44
- this.minLength = minLength;
45
- }
46
- };
47
- var PasswordInvalidError = class extends Error {
48
- code = "PASSWORD_INVALID";
49
- constructor(message = "Password does not unlock this slot.") {
50
- super(message);
51
- this.name = "PasswordInvalidError";
52
- }
53
- };
54
- async function enrollPasswordAuthenticator(keyring, options) {
55
- const minLength = options.minLength ?? PASSWORD_DEFAULT_MIN_LENGTH;
56
- if (options.password.length < minLength) {
57
- throw new PasswordTooWeakError(minLength);
58
- }
59
- if (options.pattern && !options.pattern.test(options.password)) {
60
- throw new PasswordTooWeakError(
61
- minLength,
62
- `Password does not match the configured pattern: ${options.pattern.toString()}.`
63
- );
64
- }
65
- if (keyring.deks.size === 0) {
66
- throw new Error(
67
- "enrollPasswordAuthenticator: the supplied keyring has no DEKs in memory. Re-authenticate at tier 1 first."
68
- );
69
- }
70
- const blob = await (0, import_hub.mintWrappedDeksBlob)(keyring.deks, options.password);
71
- return {
72
- id: options.id ?? "password",
73
- method: "password",
74
- wrapKind: "deks",
75
- wrapped_deks: blob.wrappedDeks,
76
- iv: blob.iv,
77
- meta: {
78
- salt: blob.salt,
79
- minLength,
80
- ...options.pattern !== void 0 ? { pattern: options.pattern.source } : {}
81
- },
82
- enrolled_via_tier: options.enrolledViaTier ?? 1
83
- };
84
- }
85
- async function unwrapDeksWithPassword(slot, password) {
86
- if (slot.wrapKind !== "deks") {
87
- throw new PasswordInvalidError(
88
- "Password slot is not a wrap-DEKs slot. Pre-pre.8 wrap-KEK password slots are no longer supported \u2014 re-enrol via enrollPasswordAuthenticator."
89
- );
90
- }
91
- const meta = slot.meta;
92
- if (typeof meta.salt !== "string") {
93
- throw new PasswordInvalidError(
94
- "Password slot is missing the per-slot salt \u2014 keyring may be corrupted."
95
- );
96
- }
97
- try {
98
- return await (0, import_hub.unwrapDeksFromBlob)(
99
- { salt: meta.salt, iv: slot.iv, wrappedDeks: slot.wrapped_deks },
100
- password
101
- );
102
- } catch {
103
- throw new PasswordInvalidError();
104
- }
105
- }
106
- async function verifyPasswordSlot(slot, password, options) {
107
- const deks = await unwrapDeksWithPassword(slot, password);
108
- const env = await options.store.get(options.vault, "_keyring", options.userId);
109
- if (!env) {
110
- throw new PasswordInvalidError(
111
- `verifyPasswordSlot: no keyring found at "${options.vault}/_keyring/${options.userId}". Verify the vault and userId are correct.`
112
- );
113
- }
114
- const file = JSON.parse(env._data);
115
- const salt = new Uint8Array((0, import_hub.base64ToBuffer)(file.salt));
116
- return {
117
- userId: file.user_id,
118
- displayName: file.display_name,
119
- role: file.role,
120
- permissions: file.permissions,
121
- authenticators: file.authenticators ?? [],
122
- salt,
123
- ...file.export_capability !== void 0 && { exportCapability: file.export_capability },
124
- ...file.import_capability !== void 0 && { importCapability: file.import_capability },
125
- ...file.policy !== void 0 && { policy: file.policy },
126
- deks,
127
- // Wrap-DEKs unlock cannot recover the KEK. Sensitive ops route
128
- // through tier-1 via re-entry of the master phrase. Matches the
129
- // existing tier-3 (`@noy-db/on-pin`) pattern.
130
- kek: null
131
- };
132
- }
133
- function passwordSlotRewrapCeremony(password) {
134
- return async (ctx) => {
135
- if (ctx.oldSlot.method !== "password") {
136
- throw new import_hub.ValidationError(
137
- `passwordSlotRewrapCeremony: oldSlot.method is "${ctx.oldSlot.method}"; expected "password". This ceremony only handles password slots \u2014 pair other methods with their own helpers.`
138
- );
139
- }
140
- if (ctx.oldSlot.wrapKind !== "deks") {
141
- throw new import_hub.ValidationError(
142
- "passwordSlotRewrapCeremony: oldSlot is a wrap-KEK slot; expected wrap-DEKs. Password slots use the wrap-DEKs variant since #26 Path C; legacy wrap-KEK password slots are not supported here \u2014 re-enrol via enrollPasswordAuthenticator."
143
- );
144
- }
145
- const oldMeta = ctx.oldSlot.meta;
146
- const minLength = typeof oldMeta.minLength === "number" ? oldMeta.minLength : void 0;
147
- const pattern = typeof oldMeta.pattern === "string" ? new RegExp(oldMeta.pattern) : void 0;
148
- return enrollPasswordAuthenticator(
149
- // enrollPasswordAuthenticator only reads `keyring.deks`, so a
150
- // synthetic minimal keyring is enough. The freshly-rewrapped DEKs
151
- // come from hub via `ctx.newDeks`.
152
- { deks: ctx.newDeks },
153
- {
154
- id: ctx.oldSlot.id,
155
- password,
156
- ...minLength !== void 0 && { minLength },
157
- ...pattern !== void 0 && { pattern },
158
- // Carry the original tier so hub's anti-slot-swap guard plus
159
- // the rotate-recover preservation rule keep the slot's
160
- // provenance honest.
161
- enrolledViaTier: ctx.oldSlot.enrolled_via_tier
162
- }
163
- );
164
- };
165
- }
166
- // Annotate the CommonJS export names for ESM import in node:
167
- 0 && (module.exports = {
168
- PASSWORD_DEFAULT_MIN_LENGTH,
169
- PASSWORD_PBKDF2_ITERATIONS,
170
- PasswordInvalidError,
171
- PasswordTooWeakError,
172
- enrollPasswordAuthenticator,
173
- passwordSlotRewrapCeremony,
174
- unwrapDeksWithPassword,
175
- verifyPasswordSlot
176
- });
177
- //# sourceMappingURL=index.cjs.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/on-password** — tier-2 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 password the user\n * types at each unlock — as a tier-2 slot in the multi-slot keyring.\n * The two 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 ValidationError,\n type EnrollAuthenticatorOptions,\n type KeyringAuthenticator,\n type KeyringFile,\n type NoydbStore,\n type SlotRewrapCeremony,\n type SlotRewrapContext,\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'`. */\n readonly id?: string\n /** Password the user will type at unlock. 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: 'strong-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',\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',\n * (slot) => verifyPasswordSlot(slot, 'strong-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/**\n * `SlotRewrapCeremony` factory for password slots — the password parallel\n * to `webAuthnSlotRewrapCeremony` (#56). Used by hub's\n * `rotatePassphrase({ slotCeremonies: { [slotId]: passwordSlotRewrapCeremony(pwd) } })`\n * to preserve a tier-2 password enrollment across a tier-1 phrase\n * rotation without forcing the user to re-enroll the password (#96).\n *\n * Returns a closure capturing the password so the result matches\n * hub's `SlotRewrapCeremony` signature `(ctx) => Promise<EnrollAuthenticatorOptions>`\n * directly. Pass into `slotCeremonies` keyed by the existing slot id:\n *\n * ```ts\n * import { passwordSlotRewrapCeremony } from '@noy-db/on-password'\n *\n * await db.rotatePassphrase('acme', {\n * oldPassphrase: oldPhrase,\n * newPassphrase: newPhrase,\n * slotCeremonies: {\n * 'password': passwordSlotRewrapCeremony('strong-password-2026'),\n * },\n * })\n * ```\n *\n * The password itself is unaffected by phrase rotation — what needs to\n * change is the **wrapped DEK set**: the encrypted blob the slot's\n * `wrapped_deks` field holds. After rotation, the old blob still has\n * the old DEKs (now stale because rotatePassphrase rewrapped them\n * under a fresh KEK); the new blob must hold the freshly rewrapped\n * `ctx.newDeks`.\n *\n * Single ceremony, one operation:\n * 1. Validate `oldSlot.method === 'password'` and `oldSlot.wrapKind === 'deks'`.\n * 2. Re-mint the wrap-DEKs blob via {@link enrollPasswordAuthenticator}\n * using the supplied password against `ctx.newDeks`.\n * 3. Return `EnrollAuthenticatorOptions` preserving `oldSlot.id`,\n * `method: 'password'`, and the strength config (`minLength`,\n * `pattern`) carried in the old slot's `meta`. Hub validates\n * `id` + `method` + `wrapKind` to prevent slot-type swap mid-\n * rotation.\n *\n * Pre-pre.8 wrap-KEK password slots (legacy) cannot be rewrapped via\n * this ceremony — they must be re-enrolled fresh via\n * {@link enrollPasswordAuthenticator}. The validation throws\n * {@link ValidationError} for those.\n *\n * @throws {ValidationError} when `oldSlot.method !== 'password'` or\n * `oldSlot.wrapKind !== 'deks'`.\n * @throws {PasswordTooWeakError} when the supplied password fails the\n * strength rules carried in `oldSlot.meta` (minLength, pattern).\n *\n * @see #56 webAuthnSlotRewrapCeremony — the WebAuthn parallel.\n * @see #29 — the slotCeremonies plumbing this fills in.\n */\nexport function passwordSlotRewrapCeremony(password: string): SlotRewrapCeremony {\n return async (ctx: SlotRewrapContext): Promise<EnrollAuthenticatorOptions> => {\n if (ctx.oldSlot.method !== 'password') {\n throw new ValidationError(\n `passwordSlotRewrapCeremony: oldSlot.method is \"${ctx.oldSlot.method}\"; expected \"password\". ` +\n 'This ceremony only handles password slots — pair other methods with their own helpers.',\n )\n }\n if (ctx.oldSlot.wrapKind !== 'deks') {\n throw new ValidationError(\n 'passwordSlotRewrapCeremony: oldSlot is a wrap-KEK slot; expected wrap-DEKs. ' +\n 'Password slots use the wrap-DEKs variant since #26 Path C; legacy wrap-KEK ' +\n 'password slots are not supported here — re-enrol via enrollPasswordAuthenticator.',\n )\n }\n\n // Preserve the strength config the slot was originally enrolled with —\n // a rotation should not silently downgrade minLength or drop a pattern.\n const oldMeta = ctx.oldSlot.meta as { minLength?: unknown; pattern?: unknown }\n const minLength = typeof oldMeta.minLength === 'number' ? oldMeta.minLength : undefined\n const pattern = typeof oldMeta.pattern === 'string' ? new RegExp(oldMeta.pattern) : undefined\n\n return enrollPasswordAuthenticator(\n // enrollPasswordAuthenticator only reads `keyring.deks`, so a\n // synthetic minimal keyring is enough. The freshly-rewrapped DEKs\n // come from hub via `ctx.newDeks`.\n { deks: ctx.newDeks } as UnlockedKeyring,\n {\n id: ctx.oldSlot.id,\n password,\n ...(minLength !== undefined && { minLength }),\n ...(pattern !== undefined && { pattern }),\n // Carry the original tier so hub's anti-slot-swap guard plus\n // the rotate-recover preservation rule keep the slot's\n // provenance honest.\n enrolledViaTier: ctx.oldSlot.enrolled_via_tier,\n },\n )\n }\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 DELETED
@@ -1,207 +0,0 @@
1
- import { NoydbStore, UnlockedKeyring, EnrollAuthenticatorOptions, SlotRewrapCeremony, KeyringAuthenticator } from '@noy-db/hub';
2
-
3
- /**
4
- * **@noy-db/on-password** — tier-2 password authenticator slot.
5
- *
6
- * The user's tier-1 *phrase* is the rarely-typed master that derives
7
- * the KEK. This package adds a SEPARATE secret — a password the user
8
- * types at each unlock — as a tier-2 slot in the multi-slot keyring.
9
- * The two credentials have:
10
- *
11
- * - **Different lifecycles** — the phrase rotates yearly; the password
12
- * rotates per the developer's policy.
13
- * - **Different strength rules** — the phrase is validated against the
14
- * phrase format (issue #7); the password is validated against a
15
- * length / regex rule the developer chooses.
16
- * - **Different storage** — the phrase derives the KEK; the password
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
- *
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.
34
- *
35
- * @see docs/subsystems/session-tiers.md → Tier 2 — `on-password`
36
- *
37
- * @packageDocumentation
38
- */
39
-
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
- */
45
- declare const PASSWORD_PBKDF2_ITERATIONS = 600000;
46
- /** Default minimum password length. Override per-app via `enrollPasswordAuthenticator`. */
47
- declare const PASSWORD_DEFAULT_MIN_LENGTH = 12;
48
- declare class PasswordTooWeakError extends Error {
49
- readonly code: "PASSWORD_TOO_WEAK";
50
- readonly minLength: number;
51
- constructor(minLength: number, message?: string);
52
- }
53
- declare class PasswordInvalidError extends Error {
54
- readonly code: "PASSWORD_INVALID";
55
- constructor(message?: string);
56
- }
57
- /** Options for {@link enrollPasswordAuthenticator}. */
58
- interface EnrollPasswordOptions {
59
- /** Slot id. Default: `'password'`. */
60
- readonly id?: string;
61
- /** Password the user will type at unlock. Distinct from the tier-1 phrase. */
62
- readonly password: string;
63
- /** Minimum length. Default 12. */
64
- readonly minLength?: number;
65
- /** Optional regex the password must satisfy in addition to length. */
66
- readonly pattern?: RegExp;
67
- /** Tier the active session held when enrolling. Default 1. */
68
- readonly enrolledViaTier?: 1 | 2;
69
- }
70
- /**
71
- * Build the keyring slot for a tier-2 password authenticator. Returns
72
- * an `EnrollAuthenticatorOptions` value the caller hands to
73
- * `db.enrollAuthenticator(vault, slot)` — separating the cryptographic
74
- * step (this function) from the persistence step (the hub) keeps the
75
- * package small and lets the hub's policy gate run between the two.
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
- *
82
- * Usage:
83
- *
84
- * ```ts
85
- * import { enrollPasswordAuthenticator } from '@noy-db/on-password'
86
- *
87
- * const keyring = await db.getKeyring('acme')
88
- * const slot = await enrollPasswordAuthenticator(keyring, {
89
- * password: 'strong-password-2026',
90
- * minLength: 14,
91
- * })
92
- * await db.enrollAuthenticator('acme', slot, {
93
- * factors: [{ kind: 'totp' }],
94
- * })
95
- * ```
96
- */
97
- declare function enrollPasswordAuthenticator(keyring: UnlockedKeyring, options: EnrollPasswordOptions): Promise<EnrollAuthenticatorOptions>;
98
- /**
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`.
102
- *
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).
106
- */
107
- declare function unwrapDeksWithPassword(slot: KeyringAuthenticator, password: string): Promise<Map<string, CryptoKey>>;
108
- /**
109
- * Hub-friendly verify callback. Pass to `db.unlockViaAuthenticator`:
110
- *
111
- * ```ts
112
- * import { verifyPasswordSlot } from '@noy-db/on-password'
113
- *
114
- * const unlocked = await db.unlockViaAuthenticator('acme', 'password',
115
- * (slot) => verifyPasswordSlot(slot, 'strong-password-2026',
116
- * { store, vault: 'acme', userId: 'alice' }),
117
- * )
118
- * ```
119
- *
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.
138
- *
139
- * @throws {@link PasswordInvalidError} when the password is wrong or
140
- * the keyring file is missing.
141
- */
142
- declare function verifyPasswordSlot(slot: KeyringAuthenticator, password: string, options: VerifyPasswordSlotOptions): Promise<UnlockedKeyring>;
143
- /** Adapter shape required by {@link verifyPasswordSlot}. */
144
- interface VerifyPasswordSlotOptions {
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;
151
- }
152
- /**
153
- * `SlotRewrapCeremony` factory for password slots — the password parallel
154
- * to `webAuthnSlotRewrapCeremony` (#56). Used by hub's
155
- * `rotatePassphrase({ slotCeremonies: { [slotId]: passwordSlotRewrapCeremony(pwd) } })`
156
- * to preserve a tier-2 password enrollment across a tier-1 phrase
157
- * rotation without forcing the user to re-enroll the password (#96).
158
- *
159
- * Returns a closure capturing the password so the result matches
160
- * hub's `SlotRewrapCeremony` signature `(ctx) => Promise<EnrollAuthenticatorOptions>`
161
- * directly. Pass into `slotCeremonies` keyed by the existing slot id:
162
- *
163
- * ```ts
164
- * import { passwordSlotRewrapCeremony } from '@noy-db/on-password'
165
- *
166
- * await db.rotatePassphrase('acme', {
167
- * oldPassphrase: oldPhrase,
168
- * newPassphrase: newPhrase,
169
- * slotCeremonies: {
170
- * 'password': passwordSlotRewrapCeremony('strong-password-2026'),
171
- * },
172
- * })
173
- * ```
174
- *
175
- * The password itself is unaffected by phrase rotation — what needs to
176
- * change is the **wrapped DEK set**: the encrypted blob the slot's
177
- * `wrapped_deks` field holds. After rotation, the old blob still has
178
- * the old DEKs (now stale because rotatePassphrase rewrapped them
179
- * under a fresh KEK); the new blob must hold the freshly rewrapped
180
- * `ctx.newDeks`.
181
- *
182
- * Single ceremony, one operation:
183
- * 1. Validate `oldSlot.method === 'password'` and `oldSlot.wrapKind === 'deks'`.
184
- * 2. Re-mint the wrap-DEKs blob via {@link enrollPasswordAuthenticator}
185
- * using the supplied password against `ctx.newDeks`.
186
- * 3. Return `EnrollAuthenticatorOptions` preserving `oldSlot.id`,
187
- * `method: 'password'`, and the strength config (`minLength`,
188
- * `pattern`) carried in the old slot's `meta`. Hub validates
189
- * `id` + `method` + `wrapKind` to prevent slot-type swap mid-
190
- * rotation.
191
- *
192
- * Pre-pre.8 wrap-KEK password slots (legacy) cannot be rewrapped via
193
- * this ceremony — they must be re-enrolled fresh via
194
- * {@link enrollPasswordAuthenticator}. The validation throws
195
- * {@link ValidationError} for those.
196
- *
197
- * @throws {ValidationError} when `oldSlot.method !== 'password'` or
198
- * `oldSlot.wrapKind !== 'deks'`.
199
- * @throws {PasswordTooWeakError} when the supplied password fails the
200
- * strength rules carried in `oldSlot.meta` (minLength, pattern).
201
- *
202
- * @see #56 webAuthnSlotRewrapCeremony — the WebAuthn parallel.
203
- * @see #29 — the slotCeremonies plumbing this fills in.
204
- */
205
- declare function passwordSlotRewrapCeremony(password: string): SlotRewrapCeremony;
206
-
207
- export { type EnrollPasswordOptions, PASSWORD_DEFAULT_MIN_LENGTH, PASSWORD_PBKDF2_ITERATIONS, PasswordInvalidError, PasswordTooWeakError, type VerifyPasswordSlotOptions, enrollPasswordAuthenticator, passwordSlotRewrapCeremony, unwrapDeksWithPassword, verifyPasswordSlot };