fix(derive): the profile config's password policy is the base again (M-2)

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
This commit is contained in:
thePR0M3TH3ANandClaude Opus 5 committed 2026-08-18 21:38:19 -04:00
1 parent f3c3d73c3d
commit 063fae191f
8 files changed
+376 -19

No files matched your search

+7
View File
@@ -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 };
}
+19
View File
@@ -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<string, unknown> {
};
}
/**
* 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<string, unknown>,
): PasswordPolicy {
return passwordPolicyFromRecord(config);
}
export async function loadConfig(
profileDir: string,
mnemonic: string,
+45 -4
View File
@@ -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<OpenedVault> {
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<PasswordPolicy> {
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<ReadAccess> {
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<ReadAccess> {
},
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 <n>", "password length", "24")
.option("--index <n>", "derivation index", "0")
.option("--gen-version <v>", "generation version (1 or 2)", "1")
+15 -5
View File
@@ -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}` };
}
+34 -9
View File
@@ -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<boolean> {
});
// 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<void> {
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<void> {
// 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<void> {
async function exportDocument(s: Session, id: string, entry: Entry): Promise<void> {
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<void> {
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} ` +