mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-03 19:34:45 +00:00
The last unported derivation, and the one the plan flagged as highest risk. It turned out to be fully reproducible: the key material is deterministic, EdDSA signatures are deterministic (RFC 8032), and the packet layout is fixed -- so the armored blocks match exactly, with no OpenPGP dependency. core/derive/pgp.ts emits the packets directly: new-format headers, the secret-key packet (unencrypted MPI plus 16-bit checksum), the user-id packet, and the self-certification signature with PGPy's exact subpacket set and order, wrapped in ASCII armor with a CRC-24 footer. Verified against PGPy across two seeds, three indices and three user-id shapes -- armored private key, armored public key, and fingerprint. RSA is deliberately unsupported: PyCryptodome's seeded prime search is not reproducible, so the TS port refuses the key type rather than silently deriving a different key. Two things worth recording from the build: the v4 fingerprint reproduces from first principles (SHA-1 over the public-key packet), which is what made byte-parity look feasible before committing to it; and the only mismatch in the first run was packet framing -- PGPy uses new-format headers where I had emitted old-format. CLI: `entry add pgp`, reveal (armored private key), `entry pgp-public`. Cross-impl phases A/B now cover a pgp entry in both directions, completing coverage of every creatable entry kind. 193 core + 47 CLI tests green; 30/30 cross-implementation checks green. Co-Authored-By: Claude Fable 5 <[email protected]>
102 lines
3.4 KiB
TypeScript
102 lines
3.4 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,
|
|
derivePgpKey,
|
|
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": {
|
|
const key = derivePgpKey(mnemonic, entry.index, {
|
|
userId: entry.user_id,
|
|
keyType: entry.key_type,
|
|
});
|
|
return {
|
|
value: key.privateKeyArmored,
|
|
descriptor: `PGP private key for ${entry.label}`,
|
|
};
|
|
}
|
|
default: {
|
|
const kind: string = (entry as { kind: string }).kind;
|
|
throw new Error(`unsupported entry kind: ${kind}`);
|
|
}
|
|
}
|
|
}
|