Files
SeedPass/js/packages/core/test/highRiskPartition.test.ts
T
thePR0M3TH3ANandClaude Opus 5 5315d8fae9 Fix: migrating a seed entry produced an index that would not parse
Found while writing a test for a mutation survivor on the API's partition
gate. A high-risk STUB — what the index keeps after `agent high-risk migrate`
moves an entry's substance into the encrypted partition file — was being
validated against the full schema of the kind it stands in for. It is a
different shape on purpose: kind, index, label, archived and the partition
pointers, so an index read without the factor discloses that a high-risk
entry exists and nothing about it.

`seedEntrySchema` requires `word_count`, which a stub does not carry. So:

- `agent high-risk migrate` on any vault holding a seed entry built an index
  saveVault refused to write — correctly, that check exists so a malformed
  index never reaches disk — but only AFTER writePartition had copied the
  secret into the partition file. The migration failed while leaving a copy
  of the secrets behind.
- Worse, Python builds the byte-identical stub
  (high_risk_partition_store.py, migrate_high_risk_entries) and validates
  nothing on save, so it writes one happily. A vault Python had migrated
  could not be OPENED by this implementation at all. That is a
  cross-implementation data-compatibility break, not a TypeScript-only bug.

Fixed by modelling the stub as its own shape in the entry union rather than
by adding `word_count` to the stub, which would have made the two
implementations write different bytes and diverged sync's canonical hashing.
It is not a validation bypass: a stub holds no secret material, the
`partition: "high_risk"` literal is the discriminator, and an entry claiming
a known kind without it still faces that kind's own schema — asserted.

Only `ssh` was ever exercised, which is exactly why this survived: ssh's
schema has no required field a stub omits, and pgp's extras carry defaults.
The new tests cover all five high-risk kinds, so a future kind with a
required field breaks a test rather than a vault.

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

199 lines
8.0 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,
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();
});
});