mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-04 11:52:03 +00:00
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]>
95 lines
3.3 KiB
TypeScript
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}`);
|
|
}
|
|
}
|
|
}
|