Files
SeedPass/js/packages/core/test/derivationCollisions.test.ts
T
thePR0M3TH3ANandClaude Opus 5 75e370d098 feat(vault): detect shared BIP-85 derivation coordinates; bind Python imports too (L-1, L-8)
L-1 — three entry kinds derive from BIP-85 app 32 at the entry's index:

    ssh@N       -> 32 bytes of m/83696968'/32'/N'
    pgp@N       -> 32 bytes of m/83696968'/32'/N'   (the SAME 32 bytes)
    password@N  -> 64 bytes of the same, then PBKDF2

So ssh@N and pgp@N are byte-for-byte the same Ed25519 private key, and
password@N takes that key as its entropy prefix. The password stays safe (256
unknown bits remain, PBKDF2 is one-way) but this is not key separation, and
SSH keys are exactly what gets exported to a server or uploaded to a forge.

Deliberately NOT fixing the derivation itself yet. Domain-separating the three
uses is a versioned protocol change in both implementations, i.e. a new
derivation path -- and the project's own note is that "a derivation bug does
not throw, it quietly produces a secret that cannot be recovered". Adding one
immediately before the first outside review inverts the point of the review.
The v2 design is written up in TODO.md instead, ready to implement after it.

What ships is the audit's short-term recommendation, which needs no derivation
change: notice the state and say so. findDerivationCollisions /
find_derivation_collisions report cross-kind clashes at one app-32 index,
worst first, in both implementations with identical message text -- a user
moving between the two must not be told different things about one vault.
Wired into every path the state can arrive by: vault import (CLI and TUI, both
implementations), nostr restore's merge, `vault import --inspect` before
anything is written, and a new `util check-derivation` for auditing an
existing vault (exit 1 when it finds something, for scripts).

Neither implementation can create the state -- index == vault id, ids unique,
watermark prevents reuse -- so this is about imported, foreign, and merged
data, which is also why it must never be the thing that fails an import: it
reports, never refuses, and tolerates malformed input.

The lead test in each implementation does not exercise the detector at all.
It derives the keys and compares the bytes, proving the property the detector
reports. Without that, the rest only checks that a written-down rule is
applied consistently, and a derivation change could falsify the rule with
everything still green.

L-8 (Python) — while wiring the above it turned out Python's import_backup had
the same unbound-plaintext-import gap the audit found in TypeScript: the
wrapper carries a `fingerprint` and nothing compared it. Now fixed there too,
with a new ProfileMismatchError so the TUI can offer the override instead of
reporting a generic failure.

The comparison is against the fingerprint DERIVED FROM THE SEED, not the
profile directory's name. In production they are the same string, but the
question worth asking is "was this backup made from my seed?", and only the
seed answers it -- comparing directory names refused a legitimate restore of
the same seed into a freshly named profile, which test_account_roundtrip_
restore caught immediately. It is enforced only when the wrapper records
something that is actually a fingerprint: a backup carrying some other label
offers no evidence about its source seed, and refusing on no evidence blocks
restores without making anyone safer.

All guards mutation-verified on both sides.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 08:58:12 -04:00

180 lines
6.0 KiB
TypeScript

/**
* Shared BIP-85 app-32 derivation coordinates.
*
* The first test does not exercise the detector at all — it proves the
* property the detector exists to report, by deriving the keys and comparing
* the bytes. Without that, the detector is a rule someone wrote down, and a
* change to the derivation could make it wrong while every other test here
* still passed.
*/
import { describe, expect, it } from "vitest";
import {
Bip85,
deriveSshKeyPair,
derivePgpKey,
findDerivationCollisions,
bytesToHex,
type VaultIndex,
} from "@seedpass/core";
import { mnemonics } from "@seedpass/test-vectors";
const MNEMONIC = mnemonics["abandon12"]!;
function entry(kind: string, label: string, extra: Record<string, unknown> = {}) {
return {
kind,
type: kind,
label,
notes: "",
tags: [],
archived: false,
modified_ts: 1700000000,
...extra,
};
}
function vault(entries: Record<string, unknown>): VaultIndex {
return { schema_version: 4, entries } as unknown as VaultIndex;
}
describe("the property being reported is real", () => {
it("ssh@N and pgp@N are byte-for-byte the same private key", () => {
const INDEX = 7;
const ssh = deriveSshKeyPair(MNEMONIC, INDEX);
const pgp = derivePgpKey(MNEMONIC, INDEX, { userId: "x" });
// Both take 32 bytes of m/83696968'/32'/7'. Not "related" — identical.
expect(bytesToHex(ssh.privateKey)).toBe(bytesToHex(pgp.privateKey));
});
it("password@N takes that same key as its entropy prefix", () => {
const INDEX = 7;
const ssh = deriveSshKeyPair(MNEMONIC, INDEX);
const bip85 = Bip85.fromMnemonic(MNEMONIC);
// The password path asks for 64 bytes at the same coordinate; the first
// 32 are exactly the SSH/PGP private key. The password stays safe (256
// unknown bits remain, and PBKDF2 is one-way) — this is a key-separation
// failure, not a password break.
const passwordEntropy = bip85.deriveEntropy({
index: INDEX,
entropyBytes: 64,
appNo: 32,
});
expect(bytesToHex(passwordEntropy.slice(0, 32))).toBe(bytesToHex(ssh.privateKey));
});
it("different indices do not collide", () => {
expect(bytesToHex(deriveSshKeyPair(MNEMONIC, 7).privateKey)).not.toBe(
bytesToHex(deriveSshKeyPair(MNEMONIC, 8).privateKey),
);
});
});
describe("findDerivationCollisions", () => {
it("reports ssh/pgp at one index as an identical key", () => {
const found = findDerivationCollisions(
vault({
"3": entry("ssh", "deploy-key", { index: 3 }),
"4": entry("pgp", "signing-key", { index: 3 }),
}),
);
expect(found).toHaveLength(1);
expect(found[0]!.severity).toBe("identical-key");
expect(found[0]!.index).toBe(3);
expect(found[0]!.entries.map((e) => e.kind).sort()).toEqual(["pgp", "ssh"]);
expect(found[0]!.message).toContain("SAME private key");
});
it("reports a password sharing an index as an entropy prefix", () => {
const found = findDerivationCollisions(
vault({
"5": entry("ssh", "server", { index: 5 }),
"9": entry("password", "site", { index: 5, length: 16 }),
}),
);
expect(found).toHaveLength(1);
expect(found[0]!.severity).toBe("entropy-prefix");
expect(found[0]!.message).toContain("entropy prefix");
});
it("orders identical-key collisions ahead of entropy-prefix ones", () => {
const found = findDerivationCollisions(
vault({
"1": entry("ssh", "a", { index: 1 }),
"2": entry("password", "b", { index: 1, length: 16 }),
"8": entry("ssh", "c", { index: 8 }),
"9": entry("pgp", "d", { index: 8 }),
}),
);
// A caller showing only the first line must show the worse one.
expect(found[0]!.severity).toBe("identical-key");
expect(found[0]!.index).toBe(8);
});
it("uses the vault id as the derivation index when the entry carries none", () => {
// Password entries derive from their id, not from a stored index field.
const found = findDerivationCollisions(
vault({
"6": entry("password", "from-id", { length: 16 }),
"7": entry("ssh", "explicit", { index: 6 }),
}),
);
expect(found).toHaveLength(1);
expect(found[0]!.index).toBe(6);
});
it("stays quiet on a vault that cannot collide", () => {
// What the creation path actually produces: index == id, all distinct.
expect(
findDerivationCollisions(
vault({
"0": entry("password", "a", { length: 16 }),
"1": entry("ssh", "b", { index: 1 }),
"2": entry("pgp", "c", { index: 2 }),
"3": entry("totp", "d", { index: 3, period: 30, digits: 6 }),
"4": entry("nostr", "e", { index: 4 }),
}),
),
).toEqual([]);
});
it("ignores kinds that do not derive from app 32", () => {
// nostr uses app 39 and seeds derive mnemonics; sharing an index between
// those and an ssh key is not a collision.
expect(
findDerivationCollisions(
vault({
"1": entry("ssh", "a", { index: 1 }),
"2": entry("nostr", "b", { index: 1 }),
"3": entry("seed", "c", { index: 1, word_count: 12 }),
}),
),
).toEqual([]);
});
it("does not flag two entries of the same kind at one index", () => {
// That is a duplicate, which the id rules prevent — not a key-separation
// failure, and reporting it would train the user to ignore the warning.
expect(
findDerivationCollisions(
vault({
"1": entry("ssh", "a", { index: 4 }),
"2": entry("ssh", "b", { index: 4 }),
}),
),
).toEqual([]);
});
it("still reports an archived entry", () => {
// Archiving hides an entry from listings; it does not change what the
// index derives, and the key may already be deployed somewhere.
const found = findDerivationCollisions(
vault({
"1": entry("ssh", "old", { index: 1, archived: true }),
"2": entry("pgp", "new", { index: 1 }),
}),
);
expect(found).toHaveLength(1);
});
});