Files
SeedPass/js/packages/core/test/recoverySplit.test.ts
T
thePR0M3TH3ANandClaude Opus 5 7f095cba86 feat(agent): port job profiles and recovery split; fix Shamir's sub-threshold secrecy
Recovery split is Shamir over GF(257), one polynomial per secret byte, shares
encoded as sprec1:<label>:<threshold>:<total>:<index>:<digest>:<payload>.
Verified interoperable in both directions on real shares, including multi-byte
UTF-8 and payloads containing colons: Python recovers TS-produced shares and
TS recovers Python-produced ones. Confirmed end to end by splitting a live
parent seed 3-of-5 with the TS binary and recovering it in Python.

**Python's coefficients were derived from the secret, and that is now fixed.**
`_coef` HMAC'd the secret to produce every polynomial coefficient, which made
the entire share set a deterministic function of it. Shamir's defining
property is that any sub-threshold set of shares is information-theoretically
independent of the secret; deriving coefficients from the secret destroys it,
because one share becomes an offline verifier — guess a secret, re-run the
split, compare. Demonstrated recovering "hunter2" from one share of five at
threshold three, before changing anything. It also meant re-splitting produced
byte-identical shares, so a leaked share could never be rotated out without
changing the secret.

The determinism was load-bearing in the wrong way: coefficients were
recomputed inside the per-share loop, so they HAD to be reproducible or the
points would not lie on one polynomial. Fixed by hoisting them out and drawing
from `secrets`. The share token format is unchanged and `recover_secret` is
untouched — recovery is Lagrange interpolation over (x, y) pairs and never
recomputes coefficients — so old shares keep recovering and both versions
interoperate. Zero interop cost for a real security property.

TypeScript never had the defect and its test suite pins it: reintroducing
secret-derived coefficients turns three tests red, including the guessing
attack. Coefficients use rejection sampling because 257 does not divide 2^16,
and a modulo draw would bias them.

Job profiles port straight across, with policy_stamp as the security-relevant
field: sha256 of the canonical agent_policy.json at creation, so a policy
tightened afterwards shows as a mismatch instead of leaving the job running
under rules nobody reviewed it against. The canonical form matches Python's
`sort_keys=True, separators=(",",":"), ensure_ascii=True` — including the
ensure_ascii escaping, which is easy to miss and would otherwise make a
non-ASCII policy hash differently in the two implementations. Verified
byte-identical stamps for both an empty policy and one with non-ASCII values.
An unreadable policy hashes to its own distinct value rather than to the empty
policy, so jobs read as stale rather than silently current.

Recovery drills use the same HMAC-chained log design as the audit log, with
tests that an edited record, a removed record and an unsigned appended record
are all detected.

Every Python API endpoint now has a TypeScript equivalent: UNPORTED_PREFIXES
is empty. The 501 mechanism stays, because the honest answer for a dropped
subsystem is 501 naming the feature rather than 404.

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

188 lines
7.8 KiB
TypeScript

/**
* Threshold secret sharing.
*
* The load-bearing test is `sub-threshold shares reveal nothing`. Shamir's
* whole value is that fewer than `threshold` shares are information-
* theoretically independent of the secret — and Python originally derived the
* polynomial coefficients by HMAC-ing the secret, which destroyed exactly
* that: any single share became an offline verifier (guess a secret, re-run
* the split, compare). That is fixed there and was never done here, and this
* file is what keeps it fixed.
*/
import { describe, expect, it } from "vitest";
import {
splitSecret,
recoverSecret,
parseShare,
RecoveryError,
SHARE_PREFIX,
} from "@seedpass/core";
const SECRET = "correct horse battery staple";
describe("split and recover", () => {
it("recovers from exactly the threshold, in any combination", () => {
const shares = splitSecret(SECRET, { totalShares: 5, threshold: 3 });
expect(shares).toHaveLength(5);
expect(recoverSecret(shares.slice(0, 3))).toBe(SECRET);
expect(recoverSecret([shares[0]!, shares[2]!, shares[4]!])).toBe(SECRET);
expect(recoverSecret([shares[4]!, shares[1]!, shares[3]!])).toBe(SECRET);
});
it("recovers from more than the threshold", () => {
const shares = splitSecret(SECRET, { totalShares: 5, threshold: 3 });
expect(recoverSecret(shares)).toBe(SECRET);
});
it("refuses fewer than the threshold rather than returning rubbish", () => {
const shares = splitSecret(SECRET, { totalShares: 5, threshold: 3 });
expect(() => recoverSecret(shares.slice(0, 2))).toThrow(/insufficient_shares/);
});
it("round-trips multi-byte UTF-8", () => {
// The secret is split per BYTE, so a multi-byte character spans several
// polynomials; a boundary bug shows up as mojibake rather than an error.
const unicode = "seed phrase — ünïcodé ✓ 日本語";
const shares = splitSecret(unicode, { totalShares: 3, threshold: 2 });
expect(recoverSecret(shares.slice(0, 2))).toBe(unicode);
});
it("round-trips a secret containing the field's edge byte values", () => {
// 0x00 and 0xff are the ends of the byte range that embeds into GF(257).
const edge = "ÿþ";
const shares = splitSecret(edge, { totalShares: 3, threshold: 2 });
expect(recoverSecret(shares.slice(0, 2))).toBe(edge);
});
it("supports a 2-of-2 split and the maximum share count", () => {
expect(recoverSecret(splitSecret(SECRET, { totalShares: 2, threshold: 2 }))).toBe(SECRET);
const many = splitSecret(SECRET, { totalShares: 32, threshold: 2 });
expect(many).toHaveLength(32);
expect(recoverSecret([many[0]!, many[31]!])).toBe(SECRET);
});
});
describe("sub-threshold shares reveal nothing", () => {
it("does not let one share confirm a guessed secret", () => {
// The attack that worked against the original Python: hold one share of
// five, guess the secret, re-run the split, compare. It recovered
// "hunter2". With random coefficients the shares are no longer a function
// of the secret alone, so re-splitting the RIGHT secret does not
// reproduce the stolen share.
const stolen = splitSecret("hunter2", { totalShares: 5, threshold: 3 })[0]!;
for (const guess of ["password", "hunter1", "hunter2", "letmein"]) {
const produced = splitSecret(guess, { totalShares: 5, threshold: 3 })[0]!;
expect(produced).not.toBe(stolen);
}
});
it("re-splitting the same secret produces different shares", () => {
// Deterministic shares could never be rotated: a leaked share would stay
// valid for as long as the secret did.
const a = splitSecret(SECRET, { totalShares: 5, threshold: 3 });
const b = splitSecret(SECRET, { totalShares: 5, threshold: 3 });
expect(a).not.toEqual(b);
// But both sets still recover the same secret, and sets must not be mixed
// — they lie on different polynomials.
expect(recoverSecret(a.slice(0, 3))).toBe(SECRET);
expect(recoverSecret(b.slice(0, 3))).toBe(SECRET);
});
it("produces different share values across positions for one secret", () => {
// A constant-value share would mean the polynomial was degenerate.
const shares = splitSecret(SECRET, { totalShares: 5, threshold: 3 });
const payloads = new Set(shares.map((s) => s.split(":")[6]));
expect(payloads.size).toBe(5);
});
});
describe("input validation", () => {
it("rejects a threshold below 2", () => {
// A threshold of 1 means every share is the whole secret.
expect(() => splitSecret(SECRET, { totalShares: 3, threshold: 1 })).toThrow(
/threshold_must_be_at_least_2/,
);
});
it("rejects fewer shares than the threshold", () => {
// Otherwise the secret would be unrecoverable the moment it was split.
expect(() => splitSecret(SECRET, { totalShares: 2, threshold: 3 })).toThrow(
/total_shares_must_be_gte_threshold/,
);
});
it("rejects an empty secret and too many shares", () => {
expect(() => splitSecret("", { totalShares: 3, threshold: 2 })).toThrow(/secret_required/);
expect(() => splitSecret(SECRET, { totalShares: 33, threshold: 2 })).toThrow(
/total_shares_too_large/,
);
});
});
describe("share integrity", () => {
it("detects a tampered payload via the checksum", () => {
const share = splitSecret(SECRET, { totalShares: 3, threshold: 2 })[0]!;
const parts = share.split(":");
// Flip a character in the payload; the digest no longer matches.
parts[6] = parts[6]!.slice(0, -2) + (parts[6]!.endsWith("A") ? "B" : "A") + "=";
expect(() => parseShare(parts.join(":"))).toThrow(RecoveryError);
});
it("rejects a share that is not one of ours", () => {
expect(() => parseShare("nope:default:2:3:1:abc:AAAA")).toThrow(/invalid_share_format/);
expect(() => parseShare("sprec1:default:2:3")).toThrow(/invalid_share_format/);
expect(() => parseShare(`${SHARE_PREFIX}:l:x:3:1:abc:AAAA`)).toThrow(
/invalid_share_metadata/,
);
});
it("refuses to mix shares from different splits", () => {
// Same secret, two independent splits: the points lie on different
// polynomials, so combining them would interpolate to nonsense.
const a = splitSecret(SECRET, { totalShares: 5, threshold: 3 });
const b = splitSecret(SECRET, { totalShares: 5, threshold: 3 });
// Distinct indices, so the duplicate-index guard does not mask this.
expect(() => recoverSecret([a[0]!, a[1]!, b[2]!])).toThrow();
});
it("refuses the same share twice", () => {
const shares = splitSecret(SECRET, { totalShares: 5, threshold: 3 });
// Two copies of one point make the interpolation singular; without this
// guard the result would be silently wrong rather than an error.
expect(() => recoverSecret([shares[0]!, shares[0]!, shares[1]!])).toThrow(
/duplicate_share_index/,
);
});
it("refuses shares whose metadata disagrees", () => {
const a = splitSecret(SECRET, { totalShares: 5, threshold: 3, label: "one" });
const b = splitSecret(SECRET, { totalShares: 5, threshold: 3, label: "two" });
expect(() => recoverSecret([a[0]!, a[1]!, b[2]!])).toThrow(/share_label_mismatch/);
});
it("refuses an empty share list", () => {
expect(() => recoverSecret([])).toThrow(/shares_required/);
});
});
describe("token shape", () => {
it("matches the documented format", () => {
const shares = splitSecret(SECRET, { totalShares: 4, threshold: 2, label: "backup" });
for (const [i, share] of shares.entries()) {
const parts = share.split(":");
expect(parts[0]).toBe(SHARE_PREFIX);
expect(parts[1]).toBe("backup");
expect(parts[2]).toBe("2");
expect(parts[3]).toBe("4");
expect(parts[4]).toBe(String(i + 1));
expect(parts[5]).toMatch(/^[0-9a-f]{16}$/);
}
});
it("falls back to the default label", () => {
const share = splitSecret(SECRET, { totalShares: 2, threshold: 2, label: " " })[0]!;
expect(share.split(":")[1]).toBe("default");
});
});