mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-06 12:52:04 +00:00
cross-impl: prove Python/TS profile interop, fix two compatibility bugs
Fixtures show both implementations compute the same values. This adds the
missing half: proof they can read each other's on-disk profiles and
backups, using each side's real code paths.
scripts/cross_impl_check.py (12 checks, wired into CI):
A. Python creates a profile -> TS opens the index, derives every secret
identically, and unlocks it with the master password alone
B. TS creates a profile -> Python decrypts parent_seed.enc and the index,
recovers the same seed, derives the same secrets
C. Portable backups round-trip both directions (Python verifies the
checksum of a TS-written export)
D. Both implementations reject a bad-checksum mnemonic
Two real compatibility bugs it caught, both fixed:
- TS-created profiles were unopenable by Python. Python only honors the
recorded parent-seed KDF parameters when kdf.name is exactly "pbkdf2";
TS wrote "pbkdf2-sha256" with an empty salt and 100k iterations, so
Python fell back to its config defaults (200k, fingerprint salt) and
derived a different key. TS now writes exactly what
PasswordManager._build_seed_kdf_config does, and its own unlock path
honors the recorded salt and iteration count.
- TS accepted invalid BIP-39 mnemonics. Seed derivation takes any string,
so a typo'd phrase silently produced a *different* vault -- unopenable
by Python and unrecoverable by the user. Mnemonics are now validated
wherever they enter (profile creation and SEEDPASS_MNEMONIC), with an
error that says why it matters.
Also: docs/typescript_cutover_plan.md records the gate list for promoting
TS to main and retiring Python to legacy/reference -- P0 gates 1-4 are
green, 5-7 (feature parity tail, packaging, migration/rollback) are not.
The Python tree stays in CI after cutover as the compatibility oracle.
Regression tests for both bugs. 151 core + 46 CLI tests green,
12/12 cross-implementation checks green.
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
ddde7c5e52
commit
0f8ce35c47
8 files changed
+730
-11
No files matched your search
@@ -16,16 +16,31 @@ import process from "node:process";
|
||||
import { base64url } from "@scure/base";
|
||||
import {
|
||||
generateFingerprint,
|
||||
assertValidMnemonic,
|
||||
deriveKeyFromPassword,
|
||||
deriveKeyFromPasswordArgon2,
|
||||
deriveIndexKeyBytes,
|
||||
decryptPayload,
|
||||
encryptV3,
|
||||
parseEncryptedFile,
|
||||
sha256Hex,
|
||||
hexToBytes,
|
||||
utf8,
|
||||
type KdfConfig,
|
||||
} from "@seedpass/core";
|
||||
|
||||
/**
|
||||
* Python's ConfigManager default. Profile KDF metadata must match what the
|
||||
* Python app writes, or Python falls back to its config defaults and derives
|
||||
* a different key — making TS-created profiles unreadable there.
|
||||
*/
|
||||
export const DEFAULT_PBKDF2_ITERATIONS = 200_000;
|
||||
|
||||
/** Python's seed-KDF salt: sha256(fingerprint)[:16]. */
|
||||
function seedKdfSalt(fingerprint: string): Uint8Array {
|
||||
return hexToBytes(sha256Hex(utf8(fingerprint))).slice(0, 16);
|
||||
}
|
||||
|
||||
export const INDEX_FILENAME = "seedpass_entries_db.json.enc";
|
||||
export const CONFIG_FILENAME = "seedpass_config.json.enc";
|
||||
export const PARENT_SEED_FILENAME = "parent_seed.enc";
|
||||
@@ -72,6 +87,11 @@ export class AppDir {
|
||||
|
||||
/** Create a profile from a mnemonic; returns its fingerprint. */
|
||||
async createProfile(mnemonic: string, password: string, name?: string): Promise<string> {
|
||||
// Reject typo'd phrases here: an invalid mnemonic still derives *a* seed,
|
||||
// so accepting one creates a vault that cannot be recovered or opened by
|
||||
// any other implementation.
|
||||
assertValidMnemonic(mnemonic, "parent seed");
|
||||
if (!password) throw new Error("a master password is required");
|
||||
const fingerprint = generateFingerprint(mnemonic);
|
||||
const data = await this.readFingerprints();
|
||||
if (data.fingerprints.includes(fingerprint)) {
|
||||
@@ -80,14 +100,19 @@ export class AppDir {
|
||||
const dir = this.profileDir(fingerprint);
|
||||
await mkdir(dir, { recursive: true, mode: 0o700 });
|
||||
|
||||
// parent_seed.enc: kdf/ct wrapper around a V3 blob under the password key
|
||||
const seedKey = base64url.decode(deriveKeyFromPassword(password, fingerprint));
|
||||
// parent_seed.enc: kdf/ct wrapper around a V3 blob under the password key.
|
||||
// Shape and parameters mirror PasswordManager._build_seed_kdf_config so
|
||||
// the Python implementation can open this profile.
|
||||
const salt = seedKdfSalt(fingerprint);
|
||||
const seedKey = base64url.decode(
|
||||
deriveKeyFromPassword(password, salt, DEFAULT_PBKDF2_ITERATIONS),
|
||||
);
|
||||
const ct = await encryptV3(seedKey, utf8(mnemonic));
|
||||
const kdf: KdfConfig = {
|
||||
name: "pbkdf2-sha256",
|
||||
name: "pbkdf2",
|
||||
version: 1,
|
||||
params: { iterations: 100000 },
|
||||
salt_b64: "",
|
||||
params: { iterations: DEFAULT_PBKDF2_ITERATIONS },
|
||||
salt_b64: Buffer.from(salt).toString("base64"),
|
||||
};
|
||||
const wrapper = JSON.stringify({
|
||||
kdf,
|
||||
@@ -141,12 +166,27 @@ export class AppDir {
|
||||
const blob = new Uint8Array(await readFile(path));
|
||||
const { kdf, ciphertext } = parseEncryptedFile(blob);
|
||||
|
||||
// Mirrors PasswordManager._derive_seed_key: argon2 when the metadata says
|
||||
// so, otherwise PBKDF2 with the recorded salt (falling back to the
|
||||
// fingerprint-derived salt) and iteration count, then legacy counts.
|
||||
const candidates: Uint8Array[] = [];
|
||||
if (kdf.name === "argon2id" && kdf.salt_b64) {
|
||||
if (kdf.name.startsWith("argon2") && kdf.salt_b64) {
|
||||
candidates.push(base64url.decode(deriveKeyFromPasswordArgon2(password, kdf)));
|
||||
}
|
||||
const recordedSalt = kdf.salt_b64
|
||||
? new Uint8Array(Buffer.from(kdf.salt_b64, "base64"))
|
||||
: seedKdfSalt(fingerprint);
|
||||
const iterations = Number((kdf.params as { iterations?: number }).iterations ?? 0);
|
||||
for (const iters of [...(iterations ? [iterations] : []), 100_000, 50_000]) {
|
||||
const iterationCandidates = [
|
||||
...(iterations ? [iterations] : []),
|
||||
DEFAULT_PBKDF2_ITERATIONS,
|
||||
100_000,
|
||||
50_000,
|
||||
];
|
||||
for (const iters of iterationCandidates) {
|
||||
candidates.push(base64url.decode(deriveKeyFromPassword(password, recordedSalt, iters)));
|
||||
// Legacy files may predate the recorded salt; the fingerprint string
|
||||
// path derives the same bytes, but keep it explicit for clarity.
|
||||
candidates.push(base64url.decode(deriveKeyFromPassword(password, fingerprint, iters)));
|
||||
}
|
||||
let lastError: unknown;
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
importBackup,
|
||||
exportBackup,
|
||||
generateFingerprint,
|
||||
assertValidMnemonic,
|
||||
modifyEntry,
|
||||
archiveEntry,
|
||||
restoreEntry,
|
||||
@@ -87,7 +88,12 @@ async function currentFingerprint(app: AppDir, opts: GlobalOpts): Promise<string
|
||||
*/
|
||||
async function resolveMnemonic(app: AppDir, opts: GlobalOpts): Promise<string> {
|
||||
const env = process.env["SEEDPASS_MNEMONIC"];
|
||||
if (env) return env;
|
||||
if (env) {
|
||||
// Catch a typo'd phrase at the boundary rather than deriving a silently
|
||||
// different vault from it.
|
||||
assertValidMnemonic(env, "SEEDPASS_MNEMONIC");
|
||||
return env;
|
||||
}
|
||||
// A process that authenticates with a scoped token has declared itself a
|
||||
// constrained principal: it must never escalate to owner access by
|
||||
// pulling the mnemonic from the unlocked agent.
|
||||
|
||||
Reference in new issue
Block a user