mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-29 00:42:08 +00:00
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
199 lines
8.0 KiB
TypeScript
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();
|
|
});
|
|
});
|