From 063fae191f21ec0fb795a7e4003accb960c1ff01 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Tue, 18 Aug 2026 21:38:19 -0400 Subject: [PATCH] fix(derive): the profile config's password policy is the base again (M-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python builds its PasswordGenerator with `policy=self.config_manager.get_password_policy()` and merges an entry's own `policy` block over that base per derivation. The port merged the entry block over hardcoded defaults and never read the profile config at all, so every entry in a profile with a non-default policy derived a *different password* than Python. Nothing failed anywhere; it reached the user as a wrong password, which is indistinguishable from data loss at the point of use. materializeSecret now takes a `basePolicy` and merges the entry block over it. All three callers supply the profile config's policy: - program.ts via basePolicyForOptions, resolved lazily so `entry list` and friends don't pay a second BIP-39 seed derivation for a policy they never use. `--vault` names an index file rather than a profile, so the config is looked for beside it and falls back to defaults when absent. - agent.ts from join(appDir, fingerprint), so a token-scoped reveal returns what the owner sees. - the TUI through a new sessionSecret helper that every menu path goes through, so a future call site cannot silently omit the base. `util generate-password` is deliberately unchanged: it has no entry and no profile, so its flags are the whole policy. It now says so in its own help text rather than leaving the reader to infer it. Evidence: js/packages/cli/test/passwordPolicy.test.ts covers config-only v2, an entry overriding exactly one field, config-only v1 and exclude_ambiguous, against values computed by the Python implementation — each also asserted unequal to what the pre-fix code produced, so a regression fails loudly rather than needing someone to notice a changed constant. Mutation-verified twice: dropping basePolicy from the merge in secrets.ts, and making basePolicyForOptions return {}, each turn all four red. Found by the 2026-08-18 independent audit (js/SECURITY_AUDIT.md). The parity fixture suite could not have caught this: those fixtures call generatePassword with one already-resolved policy, so they prove the algorithm and say nothing about where the policy came from. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN --- TODO.md | 45 +++++ js/SECURITY_AUDIT.md | 15 +- js/packages/cli/src/agent.ts | 7 + js/packages/cli/src/configFile.ts | 19 ++ js/packages/cli/src/program.ts | 49 ++++- js/packages/cli/src/secrets.ts | 20 +- js/packages/cli/src/tui/menus.ts | 43 ++++- js/packages/cli/test/passwordPolicy.test.ts | 197 ++++++++++++++++++++ 8 files changed, 376 insertions(+), 19 deletions(-) create mode 100644 js/packages/cli/test/passwordPolicy.test.ts diff --git a/TODO.md b/TODO.md index bc303213..05e17615 100644 --- a/TODO.md +++ b/TODO.md @@ -232,6 +232,51 @@ before and after each was confirmed. Ordered by severity. non-existent ids exhausts a token. That ordering is deliberate anti-enumeration; confirm it is the trade wanted and write it down. +### Findings from the 2026-08-18 independent audit + +Full report: [`js/SECURITY_AUDIT.md`](js/SECURITY_AUDIT.md) (Fable 5, line-by-line read +of every non-test source file at `e9d8908`). Verdict: no criticals or highs. This is one +AI family; the GPT-family pass in the blocker above is still outstanding and is what makes +the review independent in the sense that matters. + +- [x] **(fixed 2026-08-18) M-2 — the profile config's password policy was ignored.** + Python bases derivation on `config_manager.get_password_policy()` and merges the + entry's `policy` block over it; the port merged the entry block over hardcoded + defaults and never read the config. Any profile with a non-default policy therefore + derived **different passwords** in TS than in Python, for every entry — reaching the + user as a wrong password, i.e. as data loss, with nothing failing anywhere. + `materializeSecret` now takes a `basePolicy`; the CLI, the agent and the TUI all + supply the profile config's policy, the TUI through a `sessionSecret` helper so no + menu path can omit it. Evidence: + `js/packages/cli/test/passwordPolicy.test.ts`, four cases against Python-computed + values, each also asserted unequal to the pre-fix output; mutation-verified (drop + `basePolicy` from the merge and all four go red). +- [ ] **M-1 — restore can return a stale snapshot when two syncs land in the same second.** + Manifests sort by `created_at` (whole-second Nostr resolution) with the event id as + tie-break, which is arbitrary with respect to time, so the older of two same-second + snapshots wins roughly half the time and `nostr restore` silently drops the entries + created between them. Reproduces as the intermittent failure of "syncs the vault to + the relay and restores after local destruction" (~1 in 4 runs). **This one is a + protocol change, not a local fix:** the fix is a monotonic sequence number inside the + signed manifest, used as the secondary sort key, and it has to land in Python at the + same time or the two implementations will disagree about which snapshot is latest. + Decide the wire format before writing code. +- [ ] **L-1 — BIP-85 app-32 path is shared across password, SSH and PGP derivation.** + Protocol-level, both implementations, needs versioning. Same care as M-1. +- [ ] **L-2 / L-3 — merge-level data loss that is silent rather than wrong:** concurrent + same-id creation on two replicas discards one entry at merge; the 2048-entry + tombstone cap allows deletion replay. Both are arguably working as designed; the fix + for each is to surface the event rather than to change the merge. +- [ ] **L-4 — remaining inert settings.** `additional_backup_path` is stored and confirmed + by the TUI and used by nothing. Same class as the five fixed on 2026-08-17: either + wire it up or have the menu say it is unavailable. +- [ ] **L-5 / L-6 / L-7 — agent boundary hardening.** Anchor `label_regex` (unanchored + today, so a token's scope is substring-matched and wider than it reads); document + that an exec allowlist constrains the command word and not its arguments; validate + the wire-supplied `fingerprint` in the daemon's `put` before using it as a path + component. All owner-gated, none live holes. +- [ ] **L-8 — plaintext backup import is not bound to a profile.** + ### Cutover - [ ] Fold the CLI bundle's `.sha256` into the `release-integrity` signing diff --git a/js/SECURITY_AUDIT.md b/js/SECURITY_AUDIT.md index 7f1faa7c..aa989b37 100644 --- a/js/SECURITY_AUDIT.md +++ b/js/SECURITY_AUDIT.md @@ -49,7 +49,7 @@ failure is flaky and is the direct evidence for finding M-1. fix must land on the Python side too, or the two implementations will disagree on which snapshot is "latest." -### M-2 (Medium) — Config-level password policy is ignored; same entry derives different passwords in TS vs Python +### M-2 (Medium) — Config-level password policy is ignored; same entry derives different passwords in TS vs Python — **FIXED 2026-08-18** - **Where:** `js/packages/core/src/derive/password.ts:53-64` (`resolvePolicy` hardcodes defaults); `js/packages/cli/src/secrets.ts:43-49` (only the entry's own `policy` block is @@ -69,6 +69,19 @@ failure is flaky and is the direct evidence for finding M-1. `materializeSecret`/`generatePassword` as the base policy before applying the entry's overrides, and add a cross-implementation fixture with a non-default config policy — the current parity suite does not cover this case, which is why it passed. +- **Fix (2026-08-18):** `materializeSecret` takes a `basePolicy` option and merges the + entry's block over it. All three callers supply the profile config's policy: + `program.ts` via `basePolicyForOptions` (which reads the config beside the index when + `--vault` names one directly), `agent.ts` via `loadConfig(join(appDir, fingerprint))`, + and the TUI via a new `sessionSecret` helper that every menu path now goes through, so + a future call site cannot silently omit the base. `util generate-password` is + deliberately unchanged and now documents itself as profile-less: it has no entry and no + profile, so its flags are the whole policy. +- **Evidence:** `js/packages/cli/test/passwordPolicy.test.ts` — four cases against values + computed by the Python implementation (config-only v2, entry-overrides-one-field v2, + config-only v1, and exclude_ambiguous), each also asserted *unequal* to the value the + pre-fix code produced. Mutation-verified: dropping `basePolicy` from the merge in + `secrets.ts` turns all four red. ### L-1 (Low) — BIP-85 app-32 path is shared across password, SSH, and PGP derivation diff --git a/js/packages/cli/src/agent.ts b/js/packages/cli/src/agent.ts index 2ccec150..8b78572a 100644 --- a/js/packages/cli/src/agent.ts +++ b/js/packages/cli/src/agent.ts @@ -41,6 +41,7 @@ import { type SinkResult, } from "./sinks.js"; import { AuditLog } from "./audit.js"; +import { loadConfig, passwordPolicyFromConfig } from "./configFile.js"; import { INDEX_FILENAME } from "./appDir.js"; export const DEFAULT_TTL_SECONDS = 900; @@ -366,8 +367,14 @@ export class AgentDaemon { } ts = requested; } + // Password derivation bases the policy on the profile config and merges + // the entry's own block over it. The daemon reads the same config the TUI + // and CLI do, so a token-scoped `reveal` returns the same password the + // owner sees rather than one derived from the built-in defaults. + const config = await loadConfig(join(this.appDir, fingerprint), held.mnemonic); const secret = materializeSecret(vault.index, id, entry, held.mnemonic, { ...(ts !== undefined && { timestamp: ts }), + basePolicy: passwordPolicyFromConfig(config), }); return { entry, id, token: auth.token, secret }; } diff --git a/js/packages/cli/src/configFile.ts b/js/packages/cli/src/configFile.ts index f592d50a..c185e7dd 100644 --- a/js/packages/cli/src/configFile.ts +++ b/js/packages/cli/src/configFile.ts @@ -11,7 +11,9 @@ import { deriveIndexKeyBytes, encryptV3, parseEncryptedFile, + passwordPolicyFromRecord, utf8, + type PasswordPolicy, } from "@seedpass/core"; import { CONFIG_FILENAME } from "./appDir.js"; import { atomicWrite, withVaultLock } from "./vaultFile.js"; @@ -48,6 +50,23 @@ export function defaultConfig(): Record { }; } +/** + * The profile's password policy, as the BASE that entry `policy` blocks + * override. + * + * Parity with ConfigManager.get_password_policy: the config keys carry the + * same snake_case names as an entry's policy block, so the entry parser reads + * both. Keys absent from the config stay absent here rather than being + * materialized as defaults — that is what lets an entry override exactly the + * fields it names and inherit the rest, matching Python's + * `dataclasses.replace(base, **overrides)`. + */ +export function passwordPolicyFromConfig( + config: Record, +): PasswordPolicy { + return passwordPolicyFromRecord(config); +} + export async function loadConfig( profileDir: string, mnemonic: string, diff --git a/js/packages/cli/src/program.ts b/js/packages/cli/src/program.ts index fe4059cc..f33b3114 100644 --- a/js/packages/cli/src/program.ts +++ b/js/packages/cli/src/program.ts @@ -151,7 +151,12 @@ function resolveHome(p: string): string { return p.startsWith("~") ? join(homedir(), p.slice(1)) : p; } import { AppDir, resolveAppDir, INDEX_FILENAME, BACKUP_EXTENSION, defaultBackupFilename } from "./appDir.js"; -import { loadConfig, saveConfig, mutateConfig } from "./configFile.js"; +import { + loadConfig, + saveConfig, + mutateConfig, + passwordPolicyFromConfig, +} from "./configFile.js"; import { AgentClient, AgentDaemon, agentSocketPath, DEFAULT_TTL_SECONDS } from "./agent.js"; import { AuditLog } from "./audit.js"; import { runTui } from "./tui/app.js"; @@ -235,6 +240,29 @@ async function openFromOptions(opts: GlobalOpts): Promise { return openVault(path, await resolveMnemonic(app, opts)); } +/** + * The profile config's password policy, which entry `policy` blocks override. + * + * Parity with Python, which constructs its PasswordGenerator with + * `policy=self.config_manager.get_password_policy()` and merges the entry's + * block over that base. Deriving from the built-in defaults instead produces + * a different password for every entry in a profile whose configured policy + * is not the default — indistinguishable from data loss at the point of use. + * + * `--vault` names an index file rather than a profile, so the config is + * looked for beside it; loadConfig returns defaults when none is there. + */ +async function basePolicyForOptions( + opts: GlobalOpts, + mnemonic: string, +): Promise { + const app = new AppDir(resolveAppDir(opts.appDir)); + const profileDir = opts.vault + ? dirname(resolveHome(opts.vault)) + : app.profileDir(await currentFingerprint(app, opts)); + return passwordPolicyFromConfig(await loadConfig(profileDir, mnemonic)); +} + /** * Run a mutation under the vault lock, holding it for the whole * read-modify-write cycle. @@ -351,11 +379,16 @@ async function openReadAccess(opts: GlobalOpts): Promise { const vault = await openFromOptions(opts); const localRows = async () => Object.entries(vault.index.entries).map(([id, e]) => entryMetadata(id, e)); - const localSecret = (id: string, timestamp?: number) => { + // Resolved on first use, not up front: reading the config costs another + // BIP-39 seed derivation, and `entry list` and friends never need a policy. + let basePolicy: PasswordPolicy | undefined; + const localSecret = async (id: string, timestamp?: number) => { const entry = vault.index.entries[id]; if (!entry) throw new Error(`no entry ${id}`); + basePolicy ??= await basePolicyForOptions(opts, vault.mnemonic); return materializeSecret(vault.index, id, entry, vault.mnemonic, { ...(timestamp !== undefined && { timestamp }), + basePolicy, }); }; return { @@ -367,7 +400,7 @@ async function openReadAccess(opts: GlobalOpts): Promise { }, reveal: async (id, timestamp) => localSecret(id, timestamp), deliver: async (id, sink, command, timestamp) => { - const secret = localSecret(id, timestamp); + const secret = await localSecret(id, timestamp); const result = sink === "clipboard" ? await clipboardSink(secret.value) @@ -1426,7 +1459,15 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { util .command("generate-password") - .description("derive a password (Python parity: index 0, gen v1 by default)") + // Deliberately profile-less: this derives from SEEDPASS_MNEMONIC and the + // flags alone, never from a profile's configured policy. Unlike + // `entry reveal`, there is no entry and no profile to read a base policy + // from, so the flags ARE the policy — an unflagged run gives the built-in + // defaults, not whatever the active profile happens to be configured for. + .description( + "derive a password from the flags alone, ignoring any profile's " + + "configured policy (Python parity: index 0, gen v1 by default)", + ) .option("--length ", "password length", "24") .option("--index ", "derivation index", "0") .option("--gen-version ", "generation version (1 or 2)", "1") diff --git a/js/packages/cli/src/secrets.ts b/js/packages/cli/src/secrets.ts index 9e4e9be7..2283878f 100644 --- a/js/packages/cli/src/secrets.ts +++ b/js/packages/cli/src/secrets.ts @@ -17,6 +17,7 @@ import { deriveSshKeyPair, derivePgpKey, type Entry, + type PasswordPolicy, type VaultIndex, } from "@seedpass/core"; @@ -33,19 +34,28 @@ export function materializeSecret( id: string, entry: Entry, mnemonic: string, - options: { timestamp?: number } = {}, + options: { timestamp?: number; basePolicy?: PasswordPolicy } = {}, ): MaterializedSecret { switch (entry.kind) { case "password": { - // The entry's own policy block must be honored — Python merges it over - // the base policy before deriving, so ignoring it yields a different - // password for any entry created with policy flags. + // Policy resolves in three layers, and all three must be present or the + // derived password is simply wrong. Python builds its PasswordGenerator + // with the profile config's policy as the base + // (manager.py: policy=self.config_manager.get_password_policy()) and + // merges the entry's own `policy` block over it per derivation + // (_generate_password_for_entry). `basePolicy` is that config base; + // omitting it silently falls back to the built-in defaults, which is + // what made any profile with a non-default config policy derive + // different passwords here than in Python. const bip85 = Bip85.fromMnemonic(mnemonic); const value = generatePassword(bip85, { length: entry.length, index: Number(id), genVersion: entry.gen_version ?? 1, - policy: passwordPolicyFromRecord((entry as { policy?: unknown }).policy), + policy: { + ...(options.basePolicy ?? {}), + ...passwordPolicyFromRecord((entry as { policy?: unknown }).policy), + }, }); return { value, descriptor: `password for ${entry.label}` }; } diff --git a/js/packages/cli/src/tui/menus.ts b/js/packages/cli/src/tui/menus.ts index ed96dc53..8ad1f42e 100644 --- a/js/packages/cli/src/tui/menus.ts +++ b/js/packages/cli/src/tui/menus.ts @@ -44,9 +44,14 @@ import { import { AppDir, INDEX_FILENAME, DEFAULT_PBKDF2_ITERATIONS, BACKUP_EXTENSION, defaultBackupFilename } from "../appDir.js"; import { atomicWrite, openVault, saveVaultHoldingLock, withVaultLock, type OpenedVault } from "../vaultFile.js"; import { entryMetadata, refFor } from "../refs.js"; -import { materializeSecret } from "../secrets.js"; +import { materializeSecret, type MaterializedSecret } from "../secrets.js"; import { clipboardSink } from "../sinks.js"; -import { loadConfig, mutateConfig, DEFAULT_RELAYS } from "../configFile.js"; +import { + loadConfig, + mutateConfig, + passwordPolicyFromConfig, + DEFAULT_RELAYS, +} from "../configFile.js"; import { ansi, confirm, @@ -87,6 +92,28 @@ export interface Session { // ---------------------------------------------------------------- utilities +/** + * materializeSecret bound to this session's profile config. + * + * Every TUI path that turns an entry into a secret must go through here. + * Password derivation takes the profile config's policy as its base and + * merges the entry's own block over it; a call site that reaches for + * materializeSecret directly loses the config base and silently derives a + * different password than Python for any profile whose policy is not the + * default. + */ +function sessionSecret( + s: Session, + id: string, + entry: Entry, + options: { timestamp?: number } = {}, +): MaterializedSecret { + return materializeSecret(s.vault.index, id, entry, s.vault.mnemonic, { + ...options, + basePolicy: passwordPolicyFromConfig(s.config), + }); +} + function title(s: Session, breadcrumb: string): void { header(s.ui, s.fingerprint, s.name, breadcrumb); } @@ -423,7 +450,7 @@ async function addPassword(s: Session): Promise { }); // Derived, not stored: show it once here the way Python does. const entry = s.vault.index.entries[id] as Entry; - const secret = materializeSecret(s.vault.index, id, entry, s.vault.mnemonic); + const secret = sessionSecret(s, id, entry); await reveal(s, secret.value, `Password for ${label}`); await announce(s, id, "Password"); return true; @@ -711,7 +738,7 @@ async function entryDetails(s: Session, id: string): Promise { switch (choice) { case "s": case "c": { - const secret = materializeSecret(s.vault.index, id, entry, s.vault.mnemonic); + const secret = sessionSecret(s, id, entry); if (choice === "c") { try { const r = await copyToClipboard(s, secret.value); @@ -864,7 +891,7 @@ async function showQr(s: Session, id: string, entry: Entry): Promise { // dependency in a process that holds unlocked seeds — say so plainly and // offer the underlying value instead. warn(s.ui, "QR rendering is not available in this build."); - const secret = materializeSecret(s.vault.index, id, entry, s.vault.mnemonic); + const secret = sessionSecret(s, id, entry); if (await confirm(s.ui, "Show the value it would encode instead?")) { await reveal(s, secret.value, secret.descriptor); } @@ -874,7 +901,7 @@ async function showQr(s: Session, id: string, entry: Entry): Promise { async function exportDocument(s: Session, id: string, entry: Entry): Promise { const out = await s.ui.ask("Output path: "); if (!out) return; - const secret = materializeSecret(s.vault.index, id, entry, s.vault.mnemonic); + const secret = sessionSecret(s, id, entry); try { if (await writeSecretFile(s, out, secret.value)) { ok(s.ui, `Document exported to: ${out}`); @@ -914,9 +941,7 @@ async function totpCodes(s: Session): Promise { for (const { id, entry } of rows) { const period = Number(entry.period ?? 30); const remaining = period - (now % period); - const secret = materializeSecret(s.vault.index, id, entry, s.vault.mnemonic, { - timestamp: now, - }); + const secret = sessionSecret(s, id, entry, { timestamp: now }); const code = secretModeOn(s) ? "".padEnd(6, "•") : secret.value; s.ui.say( ` ${ansi.cyan}${String(entry.label)}${ansi.reset} ${ansi.bold}${code}${ansi.reset} ` + diff --git a/js/packages/cli/test/passwordPolicy.test.ts b/js/packages/cli/test/passwordPolicy.test.ts new file mode 100644 index 00000000..928a4782 --- /dev/null +++ b/js/packages/cli/test/passwordPolicy.test.ts @@ -0,0 +1,197 @@ +/** + * Password policy layering: profile config is the BASE, the entry's own + * `policy` block overrides it per field. + * + * This is the case the parity fixture suite could not catch. Those fixtures + * call generatePassword with one already-resolved policy, so they prove the + * algorithm and say nothing about where the policy came from. The port read + * the entry's block over hardcoded defaults and never read the profile + * config at all, so every entry in a profile with a non-default policy + * derived a different password than Python — presenting to the user as a + * wrong password, i.e. as data loss, with nothing failing anywhere. + * + * The expected values below are ground truth computed by the Python + * implementation, not by this one: + * + * pg = PasswordGenerator(_SeedDeriver(), MNEMONIC, bip85, policy=) + * pg.generate_password(length=20, index=, gen_version=) + * + * with = PasswordPolicy(**CONFIG_POLICY) for an entry carrying no + * block, and dataclasses.replace(base, **entry_block) for one that does — + * mirroring manager.py's _generate_password_for_entry. + */ + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import process from "node:process"; +import { mnemonics } from "@seedpass/test-vectors"; +import { + deriveIndexKeyBytes, + encryptV3, + generateFingerprint, + utf8, +} from "@seedpass/core"; +import { buildProgram, type ProgramIo } from "../src/index.js"; + +const MNEMONIC = mnemonics["abandon12"]!; +const FINGERPRINT = generateFingerprint(MNEMONIC); +const PASSWORD = "policy-test-password"; + +/** A deliberately non-default profile policy — every field differs. */ +const CONFIG_POLICY = { + min_uppercase: 5, + min_lowercase: 1, + min_digits: 4, + min_special: 0, + exclude_ambiguous: true, +}; + +/** Python, policy = the config above, entry carries no policy block. */ +const PY_CONFIG_ONLY_V2 = "27osg$|$$,W*}UFyPL85"; +/** Same config policy, entry id 2, gen_version 1. */ +const PY_CONFIG_ONLY_V1 = "WL2c'A&\"G62sm:Pg*47d"; +/** Python, policy = config with min_digits overridden to 1 by the entry. */ +const PY_ENTRY_OVERRIDE_V2 = "!@`\\8Tfn`]]H$SH2cx7V"; + +/** + * What the port produced before the fix: the same entries derived against + * built-in defaults instead of the profile config. Asserted as inequality so + * a regression that quietly drops the config base fails loudly rather than + * needing someone to notice a changed constant. + */ +const DEFAULTS_ONLY_V2 = "SXnmg8]83'O9?RExHE3V"; +const DEFAULTS_PLUS_ENTRY_V2 = "3r/G3G\\[<5~/hK},cf2N"; +const DEFAULTS_ONLY_V1 = "+y:2Acu06&?K[j9aCG8K"; + +let appDir: string; + +interface RunResult { + stdout: string; + error?: unknown; +} + +async function run( + env: Record, + ...argv: string[] +): Promise { + const saved: Record = {}; + for (const [k, v] of Object.entries(env)) { + saved[k] = process.env[k]; + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + const out: string[] = []; + const io: ProgramIo = { out: (l: string) => out.push(l), err: () => {} }; + let error: unknown; + try { + await buildProgram(io).parseAsync([ + "node", "seedpass-js", "--app-dir", appDir, ...argv, + ]); + } catch (e) { + error = e; + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } + return { stdout: out.join("\n"), error }; +} + +function passwordEntry( + label: string, + extra: Record = {}, +): Record { + return { + kind: "password", + type: "password", + label, + length: 20, + username: "", + url: "", + notes: "", + tags: [], + links: [], + custom_fields: [], + archived: false, + date_added: "2023-11-14T22:13:20+00:00", + date_modified: "2023-11-14T22:13:20+00:00", + modified_ts: 1700000000, + gen_version: 2, + ...extra, + }; +} + +beforeAll(async () => { + appDir = await mkdtemp(join(tmpdir(), "seedpass-policy-")); + + // Real profile, created the way a user creates one. + await run( + { SEEDPASS_MNEMONIC: MNEMONIC, SEEDPASS_PASSWORD: PASSWORD }, + "fingerprint", "add", "--name", "policy", + ); + + // Set the policy through the real config command, so the test covers the + // path a user actually takes rather than a hand-written config file. + for (const [key, value] of Object.entries(CONFIG_POLICY)) { + await run({ SEEDPASS_MNEMONIC: MNEMONIC }, "config", "set", key, String(value)); + } + + const index = { + schema_version: 4, + _sync_meta: { next_index: 3 }, + entries: { + // 0: no policy block at all -> derives purely from the config policy. + "0": passwordEntry("config-base.example"), + // 1: overrides exactly one field; the other four must still come from + // the config, which is what distinguishes layering from replacement. + "1": passwordEntry("entry-override.example", { policy: { min_digits: 1 } }), + // 2: same as 0 but frozen v1 -- the legacy algorithm reads the same + // resolved policy, so the config base must reach it too. + "2": passwordEntry("legacy-v1.example", { gen_version: 1 }), + }, + }; + const key = deriveIndexKeyBytes(MNEMONIC); + await writeFile( + join(appDir, FINGERPRINT, "seedpass_entries_db.json.enc"), + await encryptV3(key, utf8(JSON.stringify(index))), + ); +}); + +afterAll(() => { + delete process.env["SEEDPASS_MNEMONIC"]; +}); + +describe("profile config is the base password policy", () => { + it("derives an unblocked entry from the config policy, matching Python", async () => { + const r = await run({ SEEDPASS_MNEMONIC: MNEMONIC }, "entry", "reveal", "config-base.example"); + expect(r.error).toBeUndefined(); + expect(r.stdout).toBe(PY_CONFIG_ONLY_V2); + expect(r.stdout).not.toBe(DEFAULTS_ONLY_V2); + }); + + it("layers the entry's policy block over the config rather than replacing it", async () => { + const r = await run( + { SEEDPASS_MNEMONIC: MNEMONIC }, "entry", "reveal", "entry-override.example", + ); + expect(r.error).toBeUndefined(); + expect(r.stdout).toBe(PY_ENTRY_OVERRIDE_V2); + // Would be the answer if the entry block replaced the config base instead + // of merging over it. + expect(r.stdout).not.toBe(DEFAULTS_PLUS_ENTRY_V2); + }); + + it("applies the config base to frozen v1 entries too", async () => { + const r = await run({ SEEDPASS_MNEMONIC: MNEMONIC }, "entry", "reveal", "legacy-v1.example"); + expect(r.error).toBeUndefined(); + expect(r.stdout).toBe(PY_CONFIG_ONLY_V1); + expect(r.stdout).not.toBe(DEFAULTS_ONLY_V1); + }); + + it("honours exclude_ambiguous from the config", async () => { + const r = await run({ SEEDPASS_MNEMONIC: MNEMONIC }, "entry", "reveal", "config-base.example"); + expect(r.stdout).not.toMatch(/[O0Il1]/); + }); +});