Files
SeedPass/js/packages/core/test/highRiskPartition.test.ts
T
thePR0M3TH3ANandClaude Opus 5 51855ddea6 Validate high-risk partition records before deriving secrets from them
The first real finding from the new lint gate, and the one that justified
turning it on.

`decryptPartition` proves a partition file is AUTHENTIC — it decrypts under
the partition key and Fernet carries an HMAC, so nobody without the factor
wrote it. It proves nothing about a record's SHAPE: it checks only that each
value is an object. Both hydration paths then asserted the result straight
into `Entry` and handed it to materializeSecret.

That was the one place in the vault where a record reached secret derivation
without passing the schema. Every entry arriving through the index goes
through parseVaultIndex; the partition path did not — and the partition is
where the ssh keys, pgp keys and seeds live. Python writes these files and
validates nothing, so a shape divergence between the implementations, a
renamed field or a kind added on one side, would not have raised anything
here. It would have derived from whatever fields it found and returned a
confidently wrong secret, which is worse than an error by a wide margin.

`parsePartitionRecord` validates per RECORD rather than per file, so one odd
record fails one entry instead of locking the user out of every secret in the
partition. The error names the entry and nothing else, because the record IS
the secret — asserted, along with the fact that a record of an unknown kind
is still carried through untouched per spec §8.2, which validation must not
quietly have turned into a refusal.

Typing the API's hydration path properly removed the round trip it used to
make — Entry to Record and back — so the fix subtracts assertions rather than
trading one for another.

Also clears the twelve dead imports the gate found. One of them was not dead:
the `cause` binding in decryptPartition is discarded on purpose, because the
reason a Fernet decrypt failed distinguishes "wrong key" from "corrupt blob"
and surfacing that hands a caller an oracle for probing the factor. It is now
`catch {}` with that written down, which says it better than an ignored name.

Chained assertions: 44 -> 42. Tests: 689 passing.

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

276 lines
11 KiB
TypeScript

/**
* High-risk partition file format.
*
* The load-bearing tests are the interop ones: the fixtures were produced by
* Python, so "TS reads them" proves the format is genuinely shared rather
* than merely self-consistent. A partition only either implementation can
* open would be worse than none — the entries are SSH keys, PGP keys and
* seeds, and a user who cannot open them has lost them.
*/
import { describe, expect, it } from "vitest";
import { base64 } from "@scure/base";
import {
decryptPartition,
encryptPartition,
buildPartitionEnvelope,
unwrapPartitionKey,
generatePartitionKey,
partitionKeyTag,
partitionStub,
isPartitionStub,
parsePartitionRecord,
HighRiskError,
HIGH_RISK_KINDS,
parseVaultIndex,
addSshKeyEntry,
addNostrKeyEntry,
addSeedEntry,
addPgpKeyEntry,
addManagedAccountEntry,
type VaultIndex,
} from "@seedpass/core";
import { highRiskFixture as fx, mnemonics } from "@seedpass/test-vectors";
const MNEMONIC = mnemonics["abandon12"]!;
describe("interop with Python", () => {
it("derives the same partition key tag", () => {
expect(partitionKeyTag(fx.partition_key)).toBe(fx.tag);
});
it("unwraps an envelope Python wrapped", async () => {
expect(await unwrapPartitionKey(fx.envelope, fx.factor)).toBe(fx.partition_key);
});
it("reads a partition file Python wrote", async () => {
const entries = await decryptPartition(base64.decode(fx.partition_file_b64), fx.tag);
expect(entries).toEqual(fx.entries);
});
it("writes a partition file that round-trips", async () => {
// Byte-identical output is impossible — the Fernet IV is random — so the
// check is that the payload survives, with the canonical JSON shape both
// implementations serialize.
const blob = await encryptPartition(fx.entries, fx.tag, { updatedAt: 1700000000 });
expect(await decryptPartition(blob, fx.tag)).toEqual(fx.entries);
});
});
describe("the factor is what protects the partition", () => {
it("refuses the wrong factor with Python's reason string", async () => {
await expect(unwrapPartitionKey(fx.envelope, "not-the-factor")).rejects.toMatchObject({
reason: "high_risk_factor_invalid",
});
});
it("refuses to decrypt a partition under the wrong tag", async () => {
const blob = base64.decode(fx.partition_file_b64);
const wrongTag = partitionKeyTag(generatePartitionKey());
await expect(decryptPartition(blob, wrongTag)).rejects.toMatchObject({
reason: "invalid_partition_key_tag",
});
});
it("gives a different tag for every generated key", () => {
const tags = new Set(Array.from({ length: 16 }, () => partitionKeyTag(generatePartitionKey())));
expect(tags.size).toBe(16);
});
it("rejects an envelope of an unknown version rather than guessing", async () => {
await expect(
unwrapPartitionKey({ ...fx.envelope, version: 99 }, fx.factor),
).rejects.toMatchObject({ reason: "unsupported_partition_envelope_version" });
});
it("rejects a truncated envelope", async () => {
for (const missing of ["salt_b64", "wrapped_partition_key"]) {
const envelope = { ...fx.envelope, [missing]: "" };
await expect(unwrapPartitionKey(envelope, fx.factor)).rejects.toBeInstanceOf(HighRiskError);
}
});
it("round-trips a freshly built envelope", async () => {
const key = generatePartitionKey();
// Low iteration count purely to keep the test quick; production uses the
// PARTITION_KDF_ITERATIONS default.
const envelope = await buildPartitionEnvelope(key, "correct horse", { iterations: 1000 });
expect(await unwrapPartitionKey(envelope, "correct horse")).toBe(key);
await expect(unwrapPartitionKey(envelope, "wrong horse")).rejects.toBeInstanceOf(
HighRiskError,
);
});
});
describe("index stubs", () => {
const entry = {
kind: "ssh",
type: "ssh",
label: "prod-server",
index: 3,
notes: "deployment key",
archived: false,
modified_ts: 1700000000,
secret: "THE-ACTUAL-KEY-MATERIAL",
};
it("keeps nothing secret in the stub", () => {
const stub = partitionStub("3", entry, "ssh", 1700000000);
// A stub is what stays in the vault index, which is readable with the
// master password alone. It must disclose that a high-risk entry exists
// and nothing more — otherwise moving the entry bought nothing.
const serialized = JSON.stringify(stub);
expect(serialized).not.toContain("THE-ACTUAL-KEY-MATERIAL");
expect(serialized).not.toContain("deployment key");
expect(stub["label"]).toBe("prod-server");
expect(stub["kind"]).toBe("ssh");
expect(stub["partition"]).toBe("high_risk");
expect(stub["partition_ref"]).toBe("3");
});
it("is recognizable as a stub", () => {
expect(isPartitionStub(partitionStub("3", entry, "ssh", 1700000000))).toBe(true);
expect(isPartitionStub(entry)).toBe(false);
expect(isPartitionStub({})).toBe(false);
});
it("matches the shape Python leaves behind", () => {
// Python's migrate writes exactly these keys; a listing that reads one
// implementation's stub must not find the other's malformed.
const stub = partitionStub("3", entry, "ssh", 1700000000);
expect(Object.keys(stub).sort()).toEqual(
["archived", "index", "kind", "label", "modified_ts", "partition", "partition_ref", "type"],
);
});
});
describe("a stub still parses as part of the index", () => {
/**
* The stub is what the index KEEPS after migration, so if it does not parse
* the vault does not open. This was broken for `seed`: seedEntrySchema
* requires `word_count` and the stub carries only kind, index, label,
* archived and the partition pointers — deliberately, so a locked index
* discloses that a high-risk entry exists and nothing about it.
*
* The consequence was not subtle. `agent high-risk migrate` on any vault
* holding a seed entry built an index saveVault refused to write — after
* writePartition had already copied the secret across. And Python builds
* the byte-identical stub while validating nothing on save, so a vault
* Python had migrated could not be opened by this implementation at all.
*
* Every high-risk kind is covered rather than the one that happened to be
* in the fixture: `ssh` passed throughout, which is exactly why nothing
* caught this. A future kind with a required field would break it again.
*/
const build: Record<string, (index: VaultIndex) => string> = {
ssh: (i) => addSshKeyEntry(i, "a-key", {}),
nostr: (i) => addNostrKeyEntry(i, "a-key", {}),
seed: (i) => addSeedEntry(i, "a-seed", {}),
pgp: (i) => addPgpKeyEntry(i, "a-key", {}),
managed_account: (i) => addManagedAccountEntry(i, "an-account", MNEMONIC, {}),
};
for (const kind of HIGH_RISK_KINDS) {
it(`round-trips a ${kind} stub through parseVaultIndex`, () => {
const index = { schema_version: 4, entries: {} } as VaultIndex;
const id = build[kind]!(index);
const entries = index.entries as unknown as Record<string, Record<string, unknown>>;
entries[id] = partitionStub(id, entries[id]!, kind, 1_700_000_000);
// Through JSON, the way saveVault validates it.
const parsed = parseVaultIndex(JSON.parse(JSON.stringify(index)));
const stub = (parsed.entries as unknown as Record<string, Record<string, unknown>>)[id]!;
expect(isPartitionStub(stub)).toBe(true);
expect(stub["label"]).toBe(entries[id]!["label"]);
// The substance is gone — that is the point of the stub.
expect(JSON.stringify(stub)).not.toContain("secret");
});
}
it("does not let a NON-stub entry skip its own schema", () => {
// The stub shape is tried first, so it must be pinned to the partition
// marker. A malformed seed entry without it still has to fail.
const index = { schema_version: 4, entries: {} } as VaultIndex;
const id = addSeedEntry(index, "a-seed", {});
const entries = index.entries as unknown as Record<string, Record<string, unknown>>;
delete entries[id]!["word_count"];
expect(() => parseVaultIndex(JSON.parse(JSON.stringify(index)))).toThrow();
});
});
describe("a partition record is validated before it becomes a secret", () => {
/**
* decryptPartition proves the file is AUTHENTIC — it decrypts under the
* partition key and Fernet carries an HMAC. It says nothing about a
* record's SHAPE, and callers used to assert the result straight into
* `Entry` and hand it to materializeSecret. That was the one path in the
* vault reaching secret derivation without passing the schema, and it is
* where the ssh keys, pgp keys and seeds live.
*/
it("returns a well-formed record as a typed entry", () => {
const record = {
type: "ssh",
kind: "ssh",
label: "prod-server",
index: 0,
archived: false,
notes: "",
};
const parsed = parsePartitionRecord("7", record);
expect(parsed.kind).toBe("ssh");
expect(parsed.label).toBe("prod-server");
});
it("refuses a record that does not match its own kind", () => {
// A seed entry without word_count. Python writes these files too and
// validates nothing, so a shape divergence between the implementations
// arrives here — and deriving from it would return a confidently wrong
// secret, which is worse than an error.
expect(() =>
parsePartitionRecord("7", {
type: "seed",
kind: "seed",
label: "cold-wallet",
index: 0,
}),
).toThrow(HighRiskError);
try {
parsePartitionRecord("7", { type: "seed", kind: "seed", label: "x", index: 0 });
} catch (e) {
expect((e as HighRiskError).reason).toBe("invalid_partition_record");
}
});
it("never puts the record in the error message", () => {
// The record IS the secret. An error that quotes it writes it to whatever
// is reading stderr or an HTTP response body.
try {
parsePartitionRecord("7", {
type: "seed",
kind: "seed",
label: "cold-wallet",
index: 0,
secret_material: "abandon abandon abandon",
});
expect.unreachable("should have refused");
} catch (e) {
const text = String((e as Error).message) + String((e as Error).stack ?? "");
expect(text).not.toContain("abandon");
expect(text).not.toContain("cold-wallet");
expect(text).toContain("entry 7");
}
});
it("still carries a record of an unknown kind through", () => {
// Spec §8.2: a record written by a newer build or another application is
// carried untouched, never dropped. Validation must not have quietly
// turned that into a refusal.
const parsed = parsePartitionRecord("9", {
kind: "some_future_kind",
label: "from a newer build",
whatever: 1,
});
expect(parsed.kind).toBe("some_future_kind");
});
});