Files
SeedPass/js/packages/cli/src/secrets.ts
T
thePR0M3TH3ANandClaude Fable 5 b4f20d417a cross-impl: full feature coverage (30 checks) and two more compatibility fixes
Expanded the cross-implementation suite from 19 to 30 checks so every
feature is exercised against the other implementation, not just the ones
that were convenient:

  G  edits (modify / archive / links) made by either side are seen by the other
  H  deterministic conflict merge produces byte-identical output on both sides
  I  an Argon2id-protected Python profile unlocks in TS
  J  the encrypted config file interoperates both directions

Entry coverage in phases A/B now spans every creatable kind, including a
policy-constrained password and an imported (non-deterministic) TOTP.

Two real bugs this surfaced, both fixed:

- Password entries carrying a `policy` block derived the WRONG password in
  TS. Python's _generate_password_for_entry merges the entry's overrides
  onto the base policy before deriving; TS ignored the block entirely, so
  any entry created with policy flags produced a different password.
  core now exposes passwordPolicyFromRecord and the CLI applies it.

- TS could not read a Python-written config file: Python wraps it in the
  kdf/ct JSON envelope (same class as the snapshot bug fixed last commit).
  loadConfig now parses the envelope; regression test included.

One behavioral divergence found and deliberately NOT papered over:
Python generates TOTP codes with pyotp defaults (6 digits / 30s) even when
the entry records different values, so an imported 8-digit/45s secret
yields different codes in each implementation -- and Python's would be
rejected by the issuing service. The suite now reports it as a [DIVERGE]
line rather than a pass or a failure, and the matrix records it as awaiting
a product decision. TS behavior is believed correct.

173 core + 47 CLI tests green; 30/30 cross-implementation checks green
with 2 documented divergences.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-14 16:31:49 -04:00

95 lines
3.3 KiB
TypeScript

/**
* Secret materialization: derive or read the plaintext for an entry.
*
* This module is the ONLY place the CLI turns an entry into a secret value.
* Callers decide what happens to it: `reveal` prints it (explicit egress),
* `use` delivers it to a sink without letting it touch stdout.
*/
import {
Bip85,
generatePassword,
passwordPolicyFromRecord,
deriveTotpSecret,
totpCodeAt,
hexToBech32,
bytesToHex,
deriveSshKeyPair,
type Entry,
type VaultIndex,
} from "@seedpass/core";
export interface MaterializedSecret {
/** The secret itself. Handle with care; never log. */
value: string;
/** What kind of thing it is, for sink labeling. */
descriptor: string;
}
export function materializeSecret(
index: VaultIndex,
id: string,
entry: Entry,
mnemonic: string,
options: { timestamp?: number } = {},
): 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.
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),
});
return { value, descriptor: `password for ${entry.label}` };
}
case "totp": {
const secret =
entry.secret ?? deriveTotpSecret(mnemonic, entry.index ?? 0);
const ts = options.timestamp ?? Math.floor(Date.now() / 1000);
const value = totpCodeAt(secret, ts, entry.period, entry.digits);
return { value, descriptor: `TOTP code for ${entry.label}` };
}
case "key_value":
return { value: entry.value, descriptor: `value of ${entry.label}` };
case "document":
return { value: entry.content, descriptor: `document ${entry.label}` };
case "nostr": {
// Nostr ENTRY keys use the default BIP-85 app 39 path (see
// EntryManager.get_nostr_key_pair) — NOT app 1237, which is only for
// the sync client identity.
const bip85 = Bip85.fromMnemonic(mnemonic);
const entropy = bip85.deriveEntropy({ index: entry.index, entropyBytes: 32 });
return {
value: hexToBech32(bytesToHex(entropy), "nsec"),
descriptor: `nsec for ${entry.label}`,
};
}
case "seed":
case "managed_account": {
const bip85 = Bip85.fromMnemonic(mnemonic);
const value = bip85.deriveMnemonic(entry.index, entry.word_count);
return { value, descriptor: `derived mnemonic for ${entry.label}` };
}
case "ssh": {
const pair = deriveSshKeyPair(mnemonic, entry.index);
// The private key is the secret; the public key is printed alongside
// only by `entry ssh-public`, which is not a secret-bearing command.
return { value: pair.privateKeyPem, descriptor: `SSH private key for ${entry.label}` };
}
case "pgp":
throw new Error(
"PGP key material is not ported yet (see the compatibility matrix)",
);
default: {
const kind: string = (entry as { kind: string }).kind;
throw new Error(`unsupported entry kind: ${kind}`);
}
}
}