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]/); + }); +});