Files
SeedPass/js/packages/core/test/recoverySplit.test.ts
T
thePR0M3TH3ANandClaude Opus 5 cf18ab8091 test: close the gaps mutation testing found in approvals, sinks, and a flaky test
Approvals scored 58%. Five survivors, all genuine:

  - `ttlSeconds` and `uses` validation was unguarded by any test. An approval
    issued with ttl 0 is expired the instant it exists; one with uses 0 can
    never be consumed. Either would look like a granted authorization to
    whoever asked for it and silently authorize nothing — the confusing
    failure rather than the safe one.
  - The expiry boundary (`<=` vs `<`) was unpinned. It now asserts that an
    approval expiring exactly now is refused, and that one millisecond earlier
    it still works, so the boundary is where it claims to be rather than
    approximately there. Python uses `<=`, so this is parity as well as the
    safer direction.

Approvals now 100%.

Sinks scored 67%. The survivor turned `&&` into `||` in the command-spec
parser, which makes a multi-element spec whose FIRST element contains
whitespace get tokenized — silently discarding every element after it. Losing
an argument changes which command runs, and dropping a `--dry-run` or a target
path is exactly the kind of silent change that matters for a sink being handed
a secret. Sinks now 100%.

Separately, the full suite caught a flaky test of my own: the recovery-split
tamper check rewrote the payload's tail and, depending on the payload's
padding, sometimes reproduced the original string exactly — asserting nothing.
It only failed when a randomized share happened to end the wrong way. Now it
flips a character and asserts the result actually differs before expecting the
throw, plus a second case that tampers at four positions rather than trusting
one. Five consecutive runs are clean.

The agent's mapping was also wrong: agent.ts holds the high-risk session, whose
tests live in highRisk.test.ts, so leaving that suite out scored the agent at
38% and blamed the code for a gap that was really a gap in the harness.

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

212 lines
8.9 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(":");
const payload = parts[6]!;
// Flip the first payload character to a DIFFERENT base64url character.
// An earlier version of this rewrote the tail and, depending on the
// payload's padding, sometimes reproduced the original string exactly —
// so it asserted nothing and failed only when the random share happened
// to end the wrong way. Shares are randomized, so that was intermittent.
const first = payload[0]!;
parts[6] = (first === "A" ? "B" : "A") + payload.slice(1);
expect(parts[6]).not.toBe(payload);
expect(() => parseShare(parts.join(":"))).toThrow(RecoveryError);
});
it("detects tampering wherever it lands in the payload", () => {
// The checksum covers the whole payload, so exercise several positions
// rather than trusting one.
const share = splitSecret(SECRET, { totalShares: 3, threshold: 2 })[0]!;
const parts = share.split(":");
const payload = parts[6]!;
for (const at of [0, 1, Math.floor(payload.length / 2), payload.length - 2]) {
const original = payload[at]!;
const replacement = original === "A" ? "B" : "A";
const mutated = payload.slice(0, at) + replacement + payload.slice(at + 1);
if (mutated === payload) continue;
expect(() => parseShare([...parts.slice(0, 6), mutated].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");
});
});