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
This commit is contained in:
thePR0M3TH3ANandClaude Opus 5 committed 2026-08-19 10:41:45 -04:00
1 parent 727ddeff62
commit 7f095cba86
11 files changed
+1401 -32

No files matched your search

+137 -9
View File
@@ -39,6 +39,8 @@ import {
removeLink,
restoreEntry,
isPartitionStub,
splitSecret,
recoverSecret,
buildSemanticRecords,
searchSemanticRecords,
semanticManifest,
@@ -58,20 +60,30 @@ import { entryMetadata, refFor, resolveEntry } from "../refs.js";
import { materializeSecret } from "../secrets.js";
import { createIndexBackup } from "../backups.js";
import { factorConfigured, tagForFactor, readPartition, partitionPath } from "../highRisk.js";
import {
createJobProfile,
currentPolicyStamp,
listJobProfiles,
revokeJobProfile,
checkJobProfiles,
} from "../jobProfiles.js";
import {
recordRecoveryDrill,
listRecoveryDrills,
verifyRecoveryDrills,
} from "../recoveryDrills.js";
import { HttpError, type ApiRequest, type ApiResponse, type ApiServer } from "./server.js";
/**
* Features that exist in the Python API but are deliberately not ported.
* Route prefixes that exist in the Python API but are not implemented here.
*
* These hang off subsystems the TypeScript port does not implement at all
* (see capabilities().not_yet_ported). They answer 501 with the reason rather
* than 404, because a 404 reads as "you typed the path wrong" and would send
* an integrator hunting for a spelling mistake that is not there.
* Currently EMPTY — every Python endpoint now has a TypeScript equivalent.
* The mechanism is kept because the honest answer for an unimplemented
* subsystem is 501 naming the feature, not 404: a 404 reads as "you typed the
* path wrong" and sends an integrator hunting for a spelling mistake that is
* not there.
*/
export const UNPORTED_PREFIXES: Array<{ prefix: string; feature: string }> = [
{ prefix: "/api/v1/agent/job-profiles", feature: "agent job profiles" },
{ prefix: "/api/v1/agent/recovery", feature: "agent recovery split" },
];
export const UNPORTED_PREFIXES: Array<{ prefix: string; feature: string }> = [];
export interface ApiContext {
appDir: AppDir;
@@ -1021,6 +1033,122 @@ export function registerRoutes(server: ApiServer, ctx: ApiContext): void {
return { json: { results: hits.map((h) => ({ ...h, ref: refFor(String(h.entry_id)) })) } };
});
// ---------------------------------------------------------- job profiles
server.route("GET", "/api/v1/agent/job-profiles", async () => {
return { json: await listJobProfiles(ctx.appDir.root) };
});
server.route("POST", "/api/v1/agent/job-profiles", async (req) => {
const body = bodyObject(req);
try {
const record = await createJobProfile(ctx.appDir.root, {
jobId: str(body["id"] ?? body["job_id"], "id"),
fingerprint: String(body["fingerprint"] ?? ctx.fingerprint),
query: str(body["query"], "query"),
...(typeof body["auth_broker"] === "string" && { authBroker: body["auth_broker"] }),
...(typeof body["schedule"] === "string" && { schedule: body["schedule"] }),
...(typeof body["description"] === "string" && { description: body["description"] }),
policyStamp: await currentPolicyStamp(ctx.appDir.root),
leaseOnly: Boolean(body["lease_only"]),
leaseTtl: optInt(body["lease_ttl"], "lease_ttl") ?? 0,
leaseUses: optInt(body["lease_uses"], "lease_uses") ?? 0,
reveal: Boolean(body["reveal"]),
});
return { status: 201, json: record };
} catch (e) {
// job_exists / job_id_required are caller errors, not server faults.
throw new HttpError(400, (e as Error).message);
}
});
server.route("DELETE", "/api/v1/agent/job-profiles/:job_id", async (req) => {
const jobId = req.params["job_id"]!;
if (!(await revokeJobProfile(ctx.appDir.root, jobId))) {
throw new HttpError(404, `no active job profile ${jobId}`);
}
return { json: { status: "ok", revoked: jobId } };
});
server.route("GET", "/api/v1/agent/job-profiles/check", async () => {
const checks = await checkJobProfiles(
ctx.appDir.root,
await currentPolicyStamp(ctx.appDir.root),
);
return {
json: {
checks,
// Surfaced as a field rather than a status code: the request
// succeeded, and it is the ANSWER that is the warning.
stale: checks.filter((c) => !c.policy_current).map((c) => c.id),
},
};
});
// -------------------------------------------------------- recovery split
server.route(
"POST",
"/api/v1/agent/recovery/split",
async (req) => {
// The thing being split is typically a parent seed, so this is gated
// like every other plaintext route and returns shares exactly once.
await requirePassword(ctx, req);
const body = bodyObject(req);
try {
const shares = splitSecret(str(body["secret"], "secret"), {
totalShares: optInt(body["total_shares"], "total_shares") ?? 0,
threshold: optInt(body["threshold"], "threshold") ?? 0,
...(typeof body["label"] === "string" && { label: body["label"] }),
});
return { json: { shares } };
} catch (e) {
throw new HttpError(400, (e as Error).message);
}
},
{ requiresPassword: true },
);
server.route(
"POST",
"/api/v1/agent/recovery/recover",
async (req) => {
await requirePassword(ctx, req);
const body = bodyObject(req);
const shares = body["shares"];
if (!Array.isArray(shares)) throw new HttpError(400, "shares must be an array");
try {
return { json: { secret: recoverSecret(shares.map((s) => String(s))) } };
} catch (e) {
// Reason strings are the contract here (insufficient_shares,
// invalid_share_checksum, ...), so they pass through unchanged.
throw new HttpError(400, (e as Error).message);
}
},
{ requiresPassword: true },
);
server.route("POST", "/api/v1/agent/recovery/drill", async (req) => {
const body = bodyObject(req);
const record = await recordRecoveryDrill(ctx.appDir.root, {
fingerprint: ctx.fingerprint,
backupPath: str(body["backup_path"], "backup_path"),
simulated: Boolean(body["simulated"]),
expectedMaxAgeDays: optInt(body["expected_max_age_days"], "expected_max_age_days") ?? null,
now: ctx.now(),
});
return { json: record };
});
server.route("GET", "/api/v1/agent/recovery/drills", async (req) => {
const limit = optInt(req.query.get("limit") ?? undefined, "limit") ?? 20;
return { json: await listRecoveryDrills(ctx.appDir.root, { limit }) };
});
server.route("POST", "/api/v1/agent/recovery/drills/verify", async () => {
return { json: await verifyRecoveryDrills(ctx.appDir.root) };
});
server.route("POST", "/api/v1/shutdown", async () => {
ctx.requestShutdown();
return { json: { status: "shutting down" } };
+22 -3
View File
@@ -114,6 +114,28 @@ export function capabilities(): Record<string, unknown> {
// that must ALSO be otherwise authorized.
semantics: "one-shot (or N-use) authorization, consumed at action time",
},
recovery: {
commands: [
"agent recovery split/recover/drill/drill-list/drill-verify",
"POST /api/v1/agent/recovery/{split,recover,drill,drills/verify}",
],
scheme: "Shamir over GF(257), one polynomial per secret byte",
share_format: "sprec1:<label>:<threshold>:<total>:<index>:<digest>:<payload>",
// Coefficients are drawn at random. Deriving them from the secret (as
// an earlier Python version did) makes any single share an offline
// verifier for guessing it, destroying the sub-threshold secrecy that
// is the entire point.
coefficients: "random per split; sub-threshold shares reveal nothing",
interop: "shares interoperate with the Python implementation",
drills: "HMAC-chained log; drill-verify detects edits, drops and inserts",
},
job_profiles: {
commands: ["agent job-profile create/list/revoke/check"],
// The stamp is sha256 of the canonical agent policy, so a policy
// tightened after a job was created shows up as a mismatch rather than
// letting the job run under rules nobody reviewed it against.
policy_binding: "sha256 of the canonical agent_policy.json at creation",
},
audit: {
commands: ["agent audit-verify", "agent audit-tail"],
chain: "HMAC-SHA256(prev_sig + canonical_payload), keyed by KEY_INDEX",
@@ -131,9 +153,6 @@ export function capabilities(): Record<string, unknown> {
// Python-derived state, recomputed on load. Carried through verbatim by
// the TS port so a Python profile round-trips, but not computed here.
"index0/atlas (preserved verbatim on read/write, never recomputed)",
// Deliberate: agent job profiles and recovery split. The session
// agent's scoped tokens cover the automation cases these serve.
"agent job profiles and recovery split",
// Deliberate: the TS interactive mode follows Python's legacy v1 menu
// tree. See docs/tui_v2_cutover_decision.md.
"Python's v2/v3 Textual TUIs (interactive mode follows the legacy v1 menus)",
+262
View File
@@ -0,0 +1,262 @@
/**
* Agent job profiles — parity with src/seedpass/core/agent_job.py.
*
* A job profile is a named, stored description of an unattended retrieval:
* which profile, which entry query, where the credential comes from, and
* under which policy. It is configuration, not a credential — running one
* still requires everything an interactive run requires.
*
* The one security-relevant field is `policy_stamp`: a hash of the policy in
* force when the profile was created. `checkJobProfiles` reports profiles
* whose stamp no longer matches, so a policy that was tightened after a job
* was set up does not leave the job quietly running under the old rules.
*/
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { sha256Hex, utf8 } from "@seedpass/core";
import { atomicWrite, withVaultLock } from "./vaultFile.js";
export const JOB_STORE_VERSION = 1;
export const JOB_STORE_FILENAME = "agent_jobs.json";
export interface JobProfile {
id: string;
fingerprint: string;
query: string;
auth_broker: string;
broker_service: string;
broker_account: string;
broker_command: string | null;
policy_binding: string;
policy_stamp: string;
schedule: string;
description: string;
host_binding: string;
lease_only: boolean;
lease_ttl: number;
lease_uses: number;
reveal: boolean;
created_at_utc: string;
revoked_at_utc: string | null;
}
interface JobStore {
version: number;
jobs: JobProfile[];
}
export function jobStorePath(appRoot: string): string {
return join(appRoot, JOB_STORE_FILENAME);
}
function emptyStore(): JobStore {
return { version: JOB_STORE_VERSION, jobs: [] };
}
async function loadStore(path: string): Promise<JobStore> {
if (!existsSync(path)) return emptyStore();
try {
const data = JSON.parse(await readFile(path, "utf8"));
if (typeof data !== "object" || data === null || !Array.isArray(data.jobs)) {
return emptyStore();
}
return data as JobStore;
} catch {
// A corrupt store reads as empty: no profile resolves, which fails closed.
return emptyStore();
}
}
async function mutateStore<T>(path: string, fn: (store: JobStore) => T): Promise<T> {
return withVaultLock(path, async () => {
const store = await loadStore(path);
const result = fn(store);
await atomicWrite(path, utf8(JSON.stringify(store, null, 2)));
return result;
});
}
export async function listJobProfiles(
appRoot: string,
options: { includeRevoked?: boolean } = {},
): Promise<JobProfile[]> {
const jobs = (await loadStore(jobStorePath(appRoot))).jobs;
if (options.includeRevoked) return jobs;
return jobs.filter((j) => !j.revoked_at_utc);
}
export async function getJobProfile(
appRoot: string,
jobId: string,
): Promise<JobProfile | null> {
const target = String(jobId).trim();
if (!target) return null;
const jobs = (await loadStore(jobStorePath(appRoot))).jobs;
return jobs.find((j) => String(j.id) === target) ?? null;
}
export interface CreateJobProfileInput {
jobId: string;
fingerprint: string;
query: string;
authBroker?: string;
brokerService?: string;
brokerAccount?: string;
brokerCommand?: string | null;
policyBinding?: string;
policyStamp?: string;
schedule?: string;
description?: string;
hostBinding?: string;
leaseOnly?: boolean;
leaseTtl?: number;
leaseUses?: number;
reveal?: boolean;
now?: number;
}
export async function createJobProfile(
appRoot: string,
input: CreateJobProfileInput,
): Promise<JobProfile> {
const jobId = String(input.jobId).trim();
if (!jobId) throw new Error("job_id_required");
if (await getJobProfile(appRoot, jobId)) throw new Error("job_exists");
const record: JobProfile = {
id: jobId,
fingerprint: String(input.fingerprint).trim(),
query: String(input.query),
auth_broker: String(input.authBroker ?? "env").trim().toLowerCase(),
broker_service: String(input.brokerService ?? "seedpass").trim(),
broker_account: String(input.brokerAccount ?? "").trim(),
// Empty means "not set", not an empty command, matching Python's
// `str(...) or None`.
broker_command: String(input.brokerCommand ?? "").trim() || null,
policy_binding: String(input.policyBinding ?? "").trim() || "default",
policy_stamp: String(input.policyStamp ?? "").trim(),
schedule: String(input.schedule ?? "").trim(),
description: String(input.description ?? "").trim(),
host_binding: String(input.hostBinding ?? "").trim(),
lease_only: Boolean(input.leaseOnly ?? false),
lease_ttl: Number(input.leaseTtl ?? 0),
lease_uses: Number(input.leaseUses ?? 0),
reveal: Boolean(input.reveal ?? false),
created_at_utc: new Date(input.now ?? Date.now()).toISOString(),
revoked_at_utc: null,
};
await mutateStore(jobStorePath(appRoot), (store) => {
store.version ??= JOB_STORE_VERSION;
store.jobs.push(record);
});
return record;
}
export async function revokeJobProfile(
appRoot: string,
jobId: string,
options: { now?: number } = {},
): Promise<boolean> {
const target = String(jobId).trim();
if (!target) return false;
const stamp = new Date(options.now ?? Date.now()).toISOString();
return mutateStore(jobStorePath(appRoot), (store) => {
for (const job of store.jobs) {
if (String(job.id) === target && !job.revoked_at_utc) {
job.revoked_at_utc = stamp;
return true;
}
}
return false;
});
}
export interface JobProfileCheck {
id: string;
fingerprint: string;
/** False when the policy has changed since this profile was created. */
policy_current: boolean;
stored_policy_stamp: string;
current_policy_stamp: string;
}
/**
* Report profiles whose recorded policy stamp no longer matches.
*
* A job created under a permissive policy keeps its stamp; if the policy is
* later tightened, the mismatch is the signal that the job should be
* reviewed rather than left running under assumptions that no longer hold.
* Revoked profiles are excluded — they are not running.
*/
export async function checkJobProfiles(
appRoot: string,
currentPolicyStamp: string,
): Promise<JobProfileCheck[]> {
const jobs = await listJobProfiles(appRoot);
return jobs.map((job) => ({
id: job.id,
fingerprint: job.fingerprint,
policy_current: job.policy_stamp === currentPolicyStamp,
stored_policy_stamp: job.policy_stamp,
current_policy_stamp: currentPolicyStamp,
}));
}
export const POLICY_FILENAME = "agent_policy.json";
export function policyPath(appRoot: string): string {
return join(appRoot, POLICY_FILENAME);
}
/**
* Canonical JSON matching Python's
* `json.dumps(x, sort_keys=True, separators=(",",":"), ensure_ascii=True)`.
*
* `ensure_ascii` is the part that is easy to miss: Python escapes every
* non-ASCII character as \uXXXX, JSON.stringify does not. A policy with a
* non-ASCII value would otherwise hash differently in the two
* implementations, and the stamp would appear to have changed when nothing
* had.
*/
function canonicalAscii(value: unknown): string {
if (value === null || typeof value !== "object") {
const encoded = JSON.stringify(value);
if (typeof value !== "string") return encoded;
return encoded.replace(/[\u007f-\uffff]/g, (c) =>
`\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`,
);
}
if (Array.isArray(value)) return `[${value.map(canonicalAscii).join(",")}]`;
const keys = Object.keys(value as Record<string, unknown>).sort();
return `{${keys
.map(
(k) => `${canonicalAscii(k)}:${canonicalAscii((value as Record<string, unknown>)[k])}`,
)
.join(",")}}`;
}
/**
* Hash of the agent policy currently in force.
*
* Reads the same `agent_policy.json` Python writes, so a policy managed from
* either implementation produces the same stamp. An absent file hashes the
* empty policy rather than throwing: a job created with no policy in place is
* legitimate, and it will show as stale the moment one appears — which is the
* correct signal.
*/
export async function currentPolicyStamp(appRoot: string): Promise<string> {
let policy: unknown = {};
const path = policyPath(appRoot);
if (existsSync(path)) {
try {
policy = JSON.parse(await readFile(path, "utf8"));
} catch {
// An unreadable policy is not "no policy": treat it as its own state so
// every job reads as stale rather than silently matching the empty one.
return sha256Hex(utf8("seedpass:unreadable-policy"));
}
}
return sha256Hex(utf8(canonicalAscii(policy)));
}
+223
View File
@@ -27,6 +27,8 @@ import {
exportBackup,
findDerivationCollisions,
HIGH_RISK_KINDS,
splitSecret,
recoverSecret,
isPartitionStub,
partitionStub,
type Entry,
@@ -188,6 +190,18 @@ import {
revokeApproval,
VALID_APPROVAL_ACTIONS,
} from "./approvals.js";
import {
createJobProfile,
currentPolicyStamp,
listJobProfiles,
revokeJobProfile,
checkJobProfiles,
} from "./jobProfiles.js";
import {
recordRecoveryDrill,
listRecoveryDrills,
verifyRecoveryDrills,
} from "./recoveryDrills.js";
import { runTui } from "./tui/app.js";
export interface ProgramIo {
@@ -2065,6 +2079,215 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
io.out(JSON.stringify({ status: "ok", revoked: approvalId }));
});
// ------------------------------------------------------- recovery split
const recovery = agent
.command("recovery")
.description("threshold split of a secret, and backup drills");
recovery
.command("split")
.description(
"split SEEDPASS_RECOVERY_SECRET into shares (any --threshold of which " +
"recover it); shares are printed once and never stored",
)
.requiredOption("--total <n>", "how many shares to produce")
.requiredOption("--threshold <n>", "how many are needed to recover")
.option("--label <label>", "label recorded in each share", "default")
.action(async (o: { total: string; threshold: string; label: string }) => {
// Never from argv: the secret being split is typically a parent seed.
const secret = process.env["SEEDPASS_RECOVERY_SECRET"];
if (!secret) {
throw new Error(
"set SEEDPASS_RECOVERY_SECRET in the environment (never pass the " +
"secret to split on the command line)",
);
}
const shares = splitSecret(secret, {
totalShares: parseIntOption(o.total, "--total", { min: 2, max: 32 }),
threshold: parseIntOption(o.threshold, "--threshold", { min: 2 }),
label: o.label,
});
io.out(JSON.stringify({ shares }, null, 2));
io.err(
`Distribute these ${shares.length} shares separately. Any ` +
`${o.threshold} of them reconstruct the secret; fewer reveal ` +
`nothing. SeedPass keeps no copy.`,
);
});
recovery
.command("recover")
.description("reconstruct a secret from shares (reads SEEDPASS_RECOVERY_SHARES, newline separated)")
.action(async () => {
const raw = process.env["SEEDPASS_RECOVERY_SHARES"];
if (!raw) {
throw new Error(
"set SEEDPASS_RECOVERY_SHARES to the shares, one per line (never " +
"pass them on the command line)",
);
}
const shares = raw.split("\n").map((s) => s.trim()).filter(Boolean);
// The recovered value is the secret itself, so it obeys the same egress
// rule as `vault reveal-parent-seed`.
if (!process.stdout.isTTY) {
throw new Error(
"refusing to print a recovered secret into a pipe (a log, a " +
"transcript, an agent's context); run this on a terminal",
);
}
io.out(recoverSecret(shares));
});
recovery
.command("drill <backupPath>")
.description("record that a backup was checked; the log is HMAC-chained")
.option("--max-age-days <n>", "warn if the backup is older than this")
.option("--simulated", "mark the drill as a rehearsal")
.action(async (backupPath: string, o: { maxAgeDays?: string; simulated?: boolean }) => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const fp = await currentFingerprint(app, opts);
const record = await recordRecoveryDrill(app.root, {
fingerprint: fp,
backupPath: resolveHome(backupPath),
simulated: Boolean(o.simulated),
...(o.maxAgeDays !== undefined && {
expectedMaxAgeDays: parseIntOption(o.maxAgeDays, "--max-age-days", { min: 0 }),
}),
});
io.out(JSON.stringify(record, null, 2));
if (record.status !== "ok") {
io.err(
record.backup_exists
? `warning: that backup is ${record.backup_age_days} days old.`
: `warning: no backup found at ${record.backup_path}.`,
);
}
});
recovery
.command("drill-list")
.description("show recorded drills")
.option("-n <count>", "how many", "20")
.action(async (o: { n: string }) => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
io.out(
JSON.stringify(
await listRecoveryDrills(app.root, {
limit: parseIntOption(o.n, "-n", { min: 1 }),
}),
null,
2,
),
);
});
recovery
.command("drill-verify")
.description("verify the drill log's HMAC chain")
.action(async () => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const result = await verifyRecoveryDrills(app.root);
io.out(JSON.stringify(result, null, 2));
// A broken chain means the history was edited; a script needs to know.
if (!result.valid) process.exitCode = 1;
});
// --------------------------------------------------------- job profiles
const jobs = agent
.command("job-profile")
.description("stored descriptions of unattended retrievals");
jobs
.command("create <jobId>")
.description("create a job profile")
.requiredOption("--query <query>", "entry reference or search text")
.option("--auth-broker <broker>", "env|keyring|command", "env")
.option("--broker-service <name>", "keyring service", "seedpass")
.option("--broker-account <name>", "keyring account", "")
.option("--broker-command <cmd>", "command that prints the credential")
.option("--policy-binding <name>", "policy this job runs under", "default")
.option("--schedule <spec>", "when it is expected to run", "")
.option("--description <text>", "what it is for", "")
.option("--host-binding <host>", "host it is expected to run on", "")
.option("--lease-only", "restrict to lease delivery")
.option("--lease-ttl <seconds>", "lease lifetime", "0")
.option("--lease-uses <n>", "lease uses", "0")
.option("--reveal", "permit plaintext reveal")
.action(async (jobId: string, o: Record<string, string | boolean | undefined>) => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const fp = await currentFingerprint(app, opts);
const record = await createJobProfile(app.root, {
jobId,
fingerprint: fp,
query: String(o["query"]),
authBroker: String(o["authBroker"]),
brokerService: String(o["brokerService"]),
brokerAccount: String(o["brokerAccount"]),
brokerCommand: o["brokerCommand"] === undefined ? null : String(o["brokerCommand"]),
policyBinding: String(o["policyBinding"]),
// The stamp binds the profile to the policy in force now, so a later
// policy change shows up as a mismatch rather than passing silently.
policyStamp: await currentPolicyStamp(app.root),
schedule: String(o["schedule"]),
description: String(o["description"]),
hostBinding: String(o["hostBinding"]),
leaseOnly: Boolean(o["leaseOnly"]),
leaseTtl: parseIntOption(String(o["leaseTtl"]), "--lease-ttl", { min: 0 }),
leaseUses: parseIntOption(String(o["leaseUses"]), "--lease-uses", { min: 0 }),
reveal: Boolean(o["reveal"]),
});
io.out(JSON.stringify(record, null, 2));
});
jobs
.command("list")
.description("list job profiles")
.option("--include-revoked", "also show revoked ones")
.action(async (o: { includeRevoked?: boolean }) => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
io.out(
JSON.stringify(
await listJobProfiles(app.root, {
...(o.includeRevoked !== undefined && { includeRevoked: o.includeRevoked }),
}),
null,
2,
),
);
});
jobs
.command("revoke <jobId>")
.description("revoke a job profile")
.action(async (jobId: string) => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
if (!(await revokeJobProfile(app.root, jobId))) {
throw new Error(`no active job profile ${jobId}`);
}
io.out(JSON.stringify({ status: "ok", revoked: jobId }));
});
jobs
.command("check")
.description("report profiles whose policy has changed since they were created")
.action(async () => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const checks = await checkJobProfiles(app.root, await currentPolicyStamp(app.root));
io.out(JSON.stringify(checks, null, 2));
// A stale binding means the job may be running under rules that no
// longer apply — worth a non-zero exit for a scheduled check.
if (checks.some((c) => !c.policy_current)) process.exitCode = 1;
});
const util = program.command("util").description("utility commands");
util
+221
View File
@@ -0,0 +1,221 @@
/**
* Recovery drills — parity with the drill half of
* src/seedpass/core/agent_recovery.py.
*
* A drill records that someone checked a backup is present and current. The
* log is HMAC-chained the way the audit log is: each record signs
* `previous_signature || canonical_record`, so removing or reordering an
* entry breaks every signature after it. That matters because the value of a
* drill log is entirely in it being an honest history — one you can silently
* edit records nothing.
*
* The drill key is generated once and kept at 0600. It authenticates the
* chain against tampering by anyone who cannot read it; it is not a secret
* about the vault, and losing it invalidates the history rather than
* exposing anything.
*/
import { randomBytes } from "node:crypto";
import { appendFile, readFile, stat } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { hmacSha256Hex, utf8 } from "@seedpass/core";
import { atomicWrite } from "./vaultFile.js";
export const DRILL_KEY_FILENAME = "agent_recovery_drill.key";
export const DRILL_LOG_FILENAME = "agent_recovery_drills.log";
/** Genesis link, matching Python's 64 zeroes. */
const GENESIS_SIG = "0".repeat(64);
export interface DrillRecord {
timestamp_utc: string;
fingerprint: string;
backup_path: string;
backup_exists: boolean;
backup_size: number;
backup_age_days: number | null;
expected_max_age_days: number | null;
stale: boolean;
simulated: boolean;
status: string;
sig?: string;
}
export function drillKeyPath(appRoot: string): string {
return join(appRoot, DRILL_KEY_FILENAME);
}
export function drillLogPath(appRoot: string): string {
return join(appRoot, DRILL_LOG_FILENAME);
}
async function loadDrillKey(appRoot: string): Promise<Uint8Array> {
const path = drillKeyPath(appRoot);
if (existsSync(path)) return new Uint8Array(await readFile(path));
const key = randomBytes(32);
await atomicWrite(path, new Uint8Array(key));
return new Uint8Array(key);
}
/**
* Canonical JSON with sorted keys and no whitespace.
*
* The signature covers this exact string, so it must match Python's
* `json.dumps(..., sort_keys=True, separators=(",", ":"))` byte for byte or
* neither implementation can verify the other's chain.
*/
function canonical(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
const keys = Object.keys(value as Record<string, unknown>).sort();
return `{${keys
.map((k) => `${JSON.stringify(k)}:${canonical((value as Record<string, unknown>)[k])}`)
.join(",")}}`;
}
function sign(key: Uint8Array, previousSig: string, record: DrillRecord): string {
return hmacSha256Hex(key, utf8(`${previousSig}${canonical(record)}`));
}
async function readLines(path: string): Promise<string[]> {
if (!existsSync(path)) return [];
const text = await readFile(path, "utf8");
return text.split("\n").filter((line) => line.trim().length > 0);
}
async function latestSig(path: string): Promise<string> {
const lines = await readLines(path);
if (lines.length === 0) return GENESIS_SIG;
try {
const payload = JSON.parse(lines[lines.length - 1]!) as { sig?: string };
return String(payload.sig ?? GENESIS_SIG);
} catch {
return GENESIS_SIG;
}
}
/** Record a drill against `backupPath`. */
export async function recordRecoveryDrill(
appRoot: string,
options: {
fingerprint: string;
backupPath: string;
simulated: boolean;
expectedMaxAgeDays?: number | null;
now?: number;
},
): Promise<DrillRecord> {
const now = options.now ?? Date.now();
let exists = false;
let size = 0;
let modifiedMs = 0;
try {
const info = await stat(options.backupPath);
exists = true;
size = info.size;
modifiedMs = info.mtimeMs;
} catch {
// A missing backup is the finding, not an error: that is what a drill is
// for. It reports status "warning" below.
}
const ageDays = exists ? Math.floor((now - modifiedMs) / 86_400_000) : null;
const expectedMaxAgeDays = options.expectedMaxAgeDays ?? null;
const stale =
ageDays !== null && expectedMaxAgeDays !== null && ageDays > expectedMaxAgeDays;
const record: DrillRecord = {
timestamp_utc: new Date(now).toISOString(),
fingerprint: String(options.fingerprint),
backup_path: options.backupPath,
backup_exists: exists,
backup_size: size,
backup_age_days: ageDays,
expected_max_age_days: expectedMaxAgeDays,
stale,
simulated: Boolean(options.simulated),
status: exists && !stale ? "ok" : "warning",
};
const path = drillLogPath(appRoot);
const key = await loadDrillKey(appRoot);
const sig = sign(key, await latestSig(path), record);
const full: DrillRecord = { ...record, sig };
// Append rather than rewrite: a chained log that gets rewritten wholesale
// is one lost write away from losing its history.
await appendFile(path, `${JSON.stringify(full, Object.keys(full).sort())}\n`, {
encoding: "utf8",
mode: 0o600,
});
return full;
}
export async function listRecoveryDrills(
appRoot: string,
options: { limit?: number } = {},
): Promise<DrillRecord[]> {
const limit = options.limit ?? 20;
const lines = await readLines(drillLogPath(appRoot));
const out: DrillRecord[] = [];
for (const line of lines.slice(-limit)) {
try {
const payload = JSON.parse(line);
if (typeof payload === "object" && payload !== null) out.push(payload as DrillRecord);
} catch {
// Skip unparseable lines here; verify() is what reports them.
}
}
return out;
}
export interface DrillVerification {
valid: boolean;
checked: number;
errors: string[];
}
/** Verify the chain, naming every line that fails and why. */
export async function verifyRecoveryDrills(
appRoot: string,
options: { limit?: number } = {},
): Promise<DrillVerification> {
const path = drillLogPath(appRoot);
if (!existsSync(path)) return { valid: true, checked: 0, errors: [] };
let lines = await readLines(path);
if (lines.length === 0) return { valid: true, checked: 0, errors: [] };
const limit = options.limit ?? 200;
if (limit > 0) lines = lines.slice(-limit);
const key = await loadDrillKey(appRoot);
let previousSig = GENESIS_SIG;
const errors: string[] = [];
let checked = 0;
for (const [index, line] of lines.entries()) {
let record: unknown;
try {
record = JSON.parse(line);
} catch {
errors.push(`invalid_json_line:${index}`);
continue;
}
if (typeof record !== "object" || record === null) {
errors.push(`invalid_record_line:${index}`);
continue;
}
const body = { ...(record as Record<string, unknown>) };
const sig = String(body["sig"] ?? "");
if (!sig) {
errors.push(`missing_sig_line:${index}`);
continue;
}
delete body["sig"];
if (sign(key, previousSig, body as unknown as DrillRecord) !== sig) {
errors.push(`sig_mismatch_line:${index}`);
}
// Chain forward on the RECORDED signature, not the computed one: a single
// tampered record must break every link after it, not just its own.
previousSig = sig;
checked++;
}
return { valid: errors.length === 0, checked, errors };
}
+10 -15
View File
@@ -28,6 +28,7 @@ import {
} from "@seedpass/core";
import { setFactor, tagForFactor } from "../src/highRisk.js";
import {
UNPORTED_PREFIXES,
ApiServer,
registerRoutes,
buildContext,
@@ -597,25 +598,19 @@ describe("high-risk partition over the API", () => {
});
});
describe("the unported surface answers honestly", () => {
it("returns 501 with the reason, not 404", async () => {
for (const [method, path, feature] of [
["GET", "/api/v1/agent/job-profiles", "agent job profiles"],
["POST", "/api/v1/agent/recovery/split", "agent recovery split"],
] as const) {
const res = await call(method, path);
// 404 would read as "you typed the path wrong" and send an integrator
// hunting for a spelling mistake that is not there.
expect(res.status).toBe(501);
expect(res.json.detail).toContain(feature);
}
});
it("still returns 404 for a genuinely unknown path", async () => {
describe("path handling", () => {
it("returns 404 for a genuinely unknown path", async () => {
expect((await call("GET", "/api/v1/no-such-thing")).status).toBe(404);
});
it("distinguishes a wrong method from a wrong path", async () => {
expect((await call("DELETE", "/api/v1/stats")).status).toBe(405);
});
it("has no remaining 501 surface", async () => {
// Every Python endpoint now has an equivalent here. If a subsystem is
// ever dropped again it should answer 501 naming the feature rather than
// 404, which is why the mechanism stays.
expect(UNPORTED_PREFIXES).toEqual([]);
});
});
+264
View File
@@ -0,0 +1,264 @@
/**
* Job profiles and recovery drills.
*
* Both are records rather than credentials, so the interesting properties are
* about honesty: a job profile must show when the policy it was created under
* has changed, and a drill log must be unable to be edited quietly — a
* tamper-evident history you can rewrite records nothing at all.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { mkdtemp, readFile, writeFile, stat, appendFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
createJobProfile,
listJobProfiles,
getJobProfile,
revokeJobProfile,
checkJobProfiles,
currentPolicyStamp,
policyPath,
} from "../src/jobProfiles.js";
import {
recordRecoveryDrill,
listRecoveryDrills,
verifyRecoveryDrills,
drillLogPath,
drillKeyPath,
} from "../src/recoveryDrills.js";
let appDir: string;
beforeAll(async () => {
appDir = await mkdtemp(join(tmpdir(), "seedpass-jobs-"));
});
afterAll(() => {});
describe("job profiles", () => {
it("creates, reads back and revokes", async () => {
const record = await createJobProfile(appDir, {
jobId: "nightly-deploy",
fingerprint: "ABCDEF0123456789",
query: "sp://entry/3",
schedule: "0 2 * * *",
description: "deploy key for CI",
});
expect(record.id).toBe("nightly-deploy");
expect(record.revoked_at_utc).toBeNull();
// Defaults match Python's, including broker_command being null rather
// than an empty string.
expect(record.auth_broker).toBe("env");
expect(record.broker_command).toBeNull();
expect(record.policy_binding).toBe("default");
expect(await getJobProfile(appDir, "nightly-deploy")).toEqual(record);
expect(await listJobProfiles(appDir)).toHaveLength(1);
expect(await revokeJobProfile(appDir, "nightly-deploy")).toBe(true);
// Revoking twice is not an error but changes nothing.
expect(await revokeJobProfile(appDir, "nightly-deploy")).toBe(false);
expect(await listJobProfiles(appDir)).toHaveLength(0);
expect(await listJobProfiles(appDir, { includeRevoked: true })).toHaveLength(1);
});
it("refuses a duplicate id", async () => {
await createJobProfile(appDir, { jobId: "dup", fingerprint: "A".repeat(16), query: "x" });
// Silently replacing would let a second, different job inherit the
// first's identity and audit history.
await expect(
createJobProfile(appDir, { jobId: "dup", fingerprint: "A".repeat(16), query: "y" }),
).rejects.toThrow(/job_exists/);
});
it("refuses an empty id", async () => {
await expect(
createJobProfile(appDir, { jobId: " ", fingerprint: "A".repeat(16), query: "x" }),
).rejects.toThrow(/job_id_required/);
});
it("stores the file at 0600", async () => {
expect((await stat(join(appDir, "agent_jobs.json"))).mode & 0o777).toBe(0o600);
});
});
describe("policy binding", () => {
it("flags a profile whose policy changed after it was created", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-policy-"));
await writeFile(policyPath(dir), JSON.stringify({ approvals: { require_for: [] } }));
const stampBefore = await currentPolicyStamp(dir);
await createJobProfile(dir, {
jobId: "bound",
fingerprint: "A".repeat(16),
query: "x",
policyStamp: stampBefore,
});
expect((await checkJobProfiles(dir, await currentPolicyStamp(dir)))[0]!.policy_current).toBe(
true,
);
// Tighten the policy. The job is now running under rules it was never
// reviewed against, which is precisely what the stamp exists to surface.
await writeFile(
policyPath(dir),
JSON.stringify({ approvals: { require_for: ["export"] } }),
);
const checks = await checkJobProfiles(dir, await currentPolicyStamp(dir));
expect(checks[0]!.policy_current).toBe(false);
expect(checks[0]!.stored_policy_stamp).toBe(stampBefore);
});
it("treats an unreadable policy as its own state, not as no policy", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-badpolicy-"));
const empty = await currentPolicyStamp(dir);
await writeFile(policyPath(dir), "{ not json");
const broken = await currentPolicyStamp(dir);
// If a corrupt file hashed as `{}` every job would read as current while
// the real policy was unknown.
expect(broken).not.toBe(empty);
});
it("excludes revoked profiles from the check", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-revoked-"));
await createJobProfile(dir, {
jobId: "gone",
fingerprint: "A".repeat(16),
query: "x",
policyStamp: "stale",
});
await revokeJobProfile(dir, "gone");
// A revoked job is not running, so a stale binding on it is not a finding.
expect(await checkJobProfiles(dir, await currentPolicyStamp(dir))).toEqual([]);
});
});
describe("recovery drills", () => {
it("records a present backup as ok and a missing one as a warning", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-drill-"));
const backup = join(dir, "vault.seedpass");
await writeFile(backup, "backup contents");
const ok = await recordRecoveryDrill(dir, {
fingerprint: "A".repeat(16),
backupPath: backup,
simulated: false,
});
expect(ok.status).toBe("ok");
expect(ok.backup_exists).toBe(true);
expect(ok.backup_size).toBe("backup contents".length);
// A missing backup is the FINDING, not an error — that is what a drill is
// for, so it records rather than throws.
const missing = await recordRecoveryDrill(dir, {
fingerprint: "A".repeat(16),
backupPath: join(dir, "not-there"),
simulated: false,
});
expect(missing.status).toBe("warning");
expect(missing.backup_exists).toBe(false);
expect(missing.backup_age_days).toBeNull();
});
it("flags a backup older than the expected maximum age", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-stale-"));
const backup = join(dir, "old.seedpass");
await writeFile(backup, "x");
// Ask as if it were 40 days later.
const record = await recordRecoveryDrill(dir, {
fingerprint: "A".repeat(16),
backupPath: backup,
simulated: false,
expectedMaxAgeDays: 30,
// Half a day past the boundary, so flooring is unambiguous: the file
// was written a few milliseconds before `now` was computed, and an
// exact multiple would floor to 39.
now: Date.now() + 40 * 86_400_000 + 43_200_000,
});
expect(record.backup_age_days).toBe(40);
expect(record.stale).toBe(true);
expect(record.status).toBe("warning");
});
it("chains records so the log cannot be edited quietly", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-chain-"));
const backup = join(dir, "b");
await writeFile(backup, "x");
for (let i = 0; i < 4; i++) {
await recordRecoveryDrill(dir, {
fingerprint: "A".repeat(16),
backupPath: backup,
simulated: true,
});
}
expect(await verifyRecoveryDrills(dir)).toEqual({ valid: true, checked: 4, errors: [] });
expect(await listRecoveryDrills(dir)).toHaveLength(4);
// Edit one record in place, keeping its signature.
const lines = (await readFile(drillLogPath(dir), "utf8")).trim().split("\n");
const tampered = JSON.parse(lines[1]!);
// Change something that genuinely differs from what was signed. All four
// records are otherwise identical, so flipping `status` to the value it
// already held would "tamper" with nothing and prove nothing.
expect(tampered.simulated).toBe(true);
tampered.simulated = false;
lines[1] = JSON.stringify(tampered);
await writeFile(drillLogPath(dir), lines.join("\n") + "\n");
const verified = await verifyRecoveryDrills(dir);
expect(verified.valid).toBe(false);
expect(verified.errors).toContain("sig_mismatch_line:1");
});
it("detects a removed record, not just an altered one", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-drop-"));
const backup = join(dir, "b");
await writeFile(backup, "x");
for (let i = 0; i < 3; i++) {
await recordRecoveryDrill(dir, {
fingerprint: "A".repeat(16),
backupPath: backup,
simulated: true,
});
}
const lines = (await readFile(drillLogPath(dir), "utf8")).trim().split("\n");
// Drop the middle record. Each signature covers the previous one, so the
// survivor after the gap no longer verifies.
await writeFile(drillLogPath(dir), [lines[0], lines[2]].join("\n") + "\n");
expect((await verifyRecoveryDrills(dir)).valid).toBe(false);
});
it("reports an appended record that was never signed", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-append-"));
const backup = join(dir, "b");
await writeFile(backup, "x");
await recordRecoveryDrill(dir, {
fingerprint: "A".repeat(16),
backupPath: backup,
simulated: true,
});
await appendFile(drillLogPath(dir), JSON.stringify({ status: "ok" }) + "\n");
const verified = await verifyRecoveryDrills(dir);
expect(verified.valid).toBe(false);
expect(verified.errors).toContain("missing_sig_line:1");
});
it("verifies an absent log as trivially valid", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-nolog-"));
expect(await verifyRecoveryDrills(dir)).toEqual({ valid: true, checked: 0, errors: [] });
});
it("keeps the key and log at 0600", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-modes-"));
const backup = join(dir, "b");
await writeFile(backup, "x");
await recordRecoveryDrill(dir, {
fingerprint: "A".repeat(16),
backupPath: backup,
simulated: true,
});
expect((await stat(drillKeyPath(dir))).mode & 0o777).toBe(0o600);
expect((await stat(drillLogPath(dir))).mode & 0o777).toBe(0o600);
});
});
+10
View File
@@ -102,6 +102,16 @@ export {
isPartitionStub,
type PartitionEnvelope,
} from "./vault/highRiskPartition.js";
export {
splitSecret,
recoverSecret,
parseShare,
RecoveryError,
SHARE_PREFIX,
RECOVERY_PRIME,
MAX_TOTAL_SHARES,
type ParsedShare,
} from "./vault/recoverySplit.js";
export { encodeQr, renderQrText, EC_LEVELS, type QrMatrix, type EcLevel } from "./util/qr.js";
export {
buildSemanticRecords,
+219
View File
@@ -0,0 +1,219 @@
/**
* Threshold secret sharing — parity with src/seedpass/core/agent_recovery.py.
*
* Shamir over GF(257), one polynomial per secret byte, shares encoded as
* `sprec1:<label>:<threshold>:<total>:<index>:<digest>:<payload>`. The prime
* is 257 rather than 256 because 256 is not prime; a secret byte is 0..255 so
* it embeds injectively, and a share value needs two bytes because it can be
* 256.
*
* COEFFICIENTS MUST BE RANDOM. Python originally derived every coefficient by
* HMAC-ing the secret, which made the whole share set a deterministic
* function of it — and Shamir's defining property is that a sub-threshold set
* of shares is information-theoretically independent of the secret. Deriving
* them from the secret turns any single share into an offline verifier:
* guess a secret, re-run the split, compare. That is fixed in Python and was
* never done here. The share format is identical either way, and recovery is
* pure Lagrange interpolation that never recomputes coefficients, so shares
* from either implementation and either version interoperate.
*/
import { sha256 } from "@noble/hashes/sha2.js";
import { base64urlnopad } from "@scure/base";
import { bytesToHex, utf8 } from "../util/bytes.js";
export const SHARE_PREFIX = "sprec1";
/** GF(257). Share values need two bytes because 256 is representable. */
export const RECOVERY_PRIME = 257;
export const MAX_TOTAL_SHARES = 32;
export class RecoveryError extends Error {
constructor(readonly reason: string) {
super(reason);
this.name = "RecoveryError";
}
}
export interface ParsedShare {
label: string;
threshold: number;
total: number;
index: number;
values: number[];
}
/** Modular inverse in GF(257) by Fermat's little theorem. */
function modInv(value: number): number {
let result = 1;
let base = ((value % RECOVERY_PRIME) + RECOVERY_PRIME) % RECOVERY_PRIME;
// 257 is prime, so a^(p-2) == a^-1.
let exponent = RECOVERY_PRIME - 2;
while (exponent > 0) {
if (exponent & 1) result = (result * base) % RECOVERY_PRIME;
base = (base * base) % RECOVERY_PRIME;
exponent >>= 1;
}
return result;
}
function evaluate(secretByte: number, coefficients: number[], x: number): number {
let total = secretByte % RECOVERY_PRIME;
let power = x % RECOVERY_PRIME;
for (const coefficient of coefficients) {
total = (total + coefficient * power) % RECOVERY_PRIME;
power = (power * x) % RECOVERY_PRIME;
}
return total;
}
/**
* Python encodes the payload with `base64.urlsafe_b64encode`, which PADS.
* @scure's base64url also pads; base64urlnopad does not. Getting this wrong
* produces a token Python rejects on checksum, so it is pinned here.
*/
function encodePayload(raw: Uint8Array): string {
const unpadded = base64urlnopad.encode(raw);
const remainder = unpadded.length % 4;
return remainder === 0 ? unpadded : unpadded + "=".repeat(4 - remainder);
}
function decodePayload(payload: string): Uint8Array {
return base64urlnopad.decode(payload.replace(/=+$/, ""));
}
/** Split `secret` into `totalShares` shares, any `threshold` of which recover it. */
export function splitSecret(
secret: string,
options: { totalShares: number; threshold: number; label?: string },
): string[] {
const { totalShares, threshold } = options;
const secretBytes = utf8(secret);
// Reason strings match Python's so callers can branch identically.
if (threshold < 2) throw new RecoveryError("threshold_must_be_at_least_2");
if (totalShares < threshold) throw new RecoveryError("total_shares_must_be_gte_threshold");
if (totalShares > MAX_TOTAL_SHARES) throw new RecoveryError("total_shares_too_large");
if (secretBytes.length === 0) throw new RecoveryError("secret_required");
const label = (options.label ?? "default").trim() || "default";
// One random polynomial per byte, drawn ONCE. Drawing per share would put
// the points on different polynomials and recovery would silently return
// rubbish rather than fail.
const coefficients: number[][] = [];
for (let i = 0; i < secretBytes.length; i++) {
const row: number[] = [];
for (let p = 1; p < threshold; p++) row.push(randomBelowPrime());
coefficients.push(row);
}
const shares: string[] = [];
for (let x = 1; x <= totalShares; x++) {
const raw = new Uint8Array(secretBytes.length * 2);
for (let i = 0; i < secretBytes.length; i++) {
const value = evaluate(secretBytes[i]!, coefficients[i]!, x);
raw[i * 2] = (value >> 8) & 0xff;
raw[i * 2 + 1] = value & 0xff;
}
const digest = bytesToHex(sha256(raw)).slice(0, 16);
shares.push(
`${SHARE_PREFIX}:${label}:${threshold}:${totalShares}:${x}:${digest}:${encodePayload(raw)}`,
);
}
return shares;
}
/**
* Uniform in [0, 257) without modulo bias.
*
* 257 does not divide 2^16, so taking a 16-bit draw mod 257 would favour the
* low values. Rejection sampling keeps the distribution flat, which matters:
* biased coefficients leak information about the polynomial.
*/
function randomBelowPrime(): number {
const limit = Math.floor(65536 / RECOVERY_PRIME) * RECOVERY_PRIME;
const buffer = new Uint16Array(1);
for (;;) {
globalThis.crypto.getRandomValues(buffer);
if (buffer[0]! < limit) return buffer[0]! % RECOVERY_PRIME;
}
}
export function parseShare(token: string): ParsedShare {
// maxsplit 6 in Python, so the payload may itself contain colons.
const parts = String(token).split(":");
if (parts.length < 7 || parts[0] !== SHARE_PREFIX) {
throw new RecoveryError("invalid_share_format");
}
const [, label, thresholdRaw, totalRaw, indexRaw, digest] = parts;
const payload = parts.slice(6).join(":");
const threshold = Number(thresholdRaw);
const total = Number(totalRaw);
const index = Number(indexRaw);
if (!Number.isInteger(threshold) || !Number.isInteger(total) || !Number.isInteger(index)) {
throw new RecoveryError("invalid_share_metadata");
}
let raw: Uint8Array;
try {
raw = decodePayload(payload);
} catch {
throw new RecoveryError("invalid_share_payload");
}
if (bytesToHex(sha256(raw)).slice(0, 16) !== digest) {
throw new RecoveryError("invalid_share_checksum");
}
if (raw.length % 2 !== 0) throw new RecoveryError("invalid_share_payload");
const values: number[] = [];
for (let i = 0; i < raw.length; i += 2) {
const value = (raw[i]! << 8) | raw[i + 1]!;
if (value < 0 || value >= RECOVERY_PRIME) throw new RecoveryError("invalid_share_value");
values.push(value);
}
return { label: label!, threshold, total, index, values };
}
/** Recover the secret from at least `threshold` shares. */
export function recoverSecret(tokens: string[]): string {
if (tokens.length === 0) throw new RecoveryError("shares_required");
const parsed = tokens.map(parseShare);
const first = parsed[0]!;
const { label, threshold } = first;
const expectedLength = first.values.length;
const seen = new Set<number>();
for (const share of parsed) {
if (share.label !== label) throw new RecoveryError("share_label_mismatch");
if (share.threshold !== threshold) throw new RecoveryError("share_threshold_mismatch");
if (share.values.length !== expectedLength) throw new RecoveryError("share_length_mismatch");
// Two shares at one x are the same point; using both would make the
// interpolation singular rather than merely wrong.
if (seen.has(share.index)) throw new RecoveryError("duplicate_share_index");
seen.add(share.index);
}
if (parsed.length < threshold) throw new RecoveryError("insufficient_shares");
const used = parsed.slice(0, threshold);
const xs = used.map((s) => ((s.index % RECOVERY_PRIME) + RECOVERY_PRIME) % RECOVERY_PRIME);
const out = new Uint8Array(expectedLength);
for (let byteIndex = 0; byteIndex < expectedLength; byteIndex++) {
const ys = used.map((s) => s.values[byteIndex]! % RECOVERY_PRIME);
let secretValue = 0;
for (let i = 0; i < threshold; i++) {
let numerator = 1;
let denominator = 1;
for (let j = 0; j < threshold; j++) {
if (i === j) continue;
numerator = (numerator * (((-xs[j]! % RECOVERY_PRIME) + RECOVERY_PRIME) % RECOVERY_PRIME)) % RECOVERY_PRIME;
denominator =
(denominator * (((xs[i]! - xs[j]!) % RECOVERY_PRIME) + RECOVERY_PRIME)) % RECOVERY_PRIME;
}
const basis = (numerator * modInv(denominator)) % RECOVERY_PRIME;
secretValue = (secretValue + ys[i]! * basis) % RECOVERY_PRIME;
}
// 256 is a legal field element but not a byte: it means these shares do
// not describe a valid secret (wrong set, or tampering that survived the
// per-share checksum).
if (secretValue < 0 || secretValue > 255) {
throw new RecoveryError("recovered_secret_out_of_range");
}
out[byteIndex] = secretValue;
}
return new TextDecoder("utf-8", { fatal: true }).decode(out);
}
Binary file not shown.
+33 -5
View File
@@ -5,6 +5,7 @@ import hashlib
import hmac
import json
import os
import secrets
import time
from datetime import datetime, timezone
from pathlib import Path
@@ -25,7 +26,28 @@ def _mod_inv(value: int) -> int:
def _coef(secret: bytes, label: str, byte_idx: int, power: int) -> int:
# Deterministic coefficient derivation for reproducible share generation.
"""Legacy coefficient derivation. Retained only to explain why it is gone.
.. warning::
This derived every polynomial coefficient by HMAC-ing the SECRET, which
made the whole share set a deterministic function of the secret. Shamir
sharing's defining property is that any sub-threshold set of shares is
information-theoretically independent of the secret; deriving the
coefficients from the secret destroys it, because a single share then
becomes an offline verifier: an attacker guesses a secret, re-runs the
split, and compares. Demonstrated recovering "hunter2" from one share
of five with a threshold of three.
It also meant re-splitting produced byte-identical shares, so a leaked
share could never be rotated out without changing the secret itself.
``split_secret`` now draws coefficients from ``secrets`` instead. The
share TOKEN FORMAT is unchanged and ``recover_secret`` is untouched --
recovery is Lagrange interpolation over (x, y) pairs and never
recomputes coefficients -- so shares from either version interoperate
and old shares keep recovering.
"""
msg = f"{label}:{byte_idx}:{power}".encode("utf-8")
digest = hmac.new(secret, msg, hashlib.sha256).digest()
return int.from_bytes(digest[:2], "big") % PRIME
@@ -86,14 +108,20 @@ def split_secret(
raise ValueError("secret_required")
label_value = str(label).strip() or "default"
# One random polynomial per secret byte, drawn ONCE and then evaluated at
# every x. The coefficients must be the same across shares or the points
# do not lie on a single polynomial and recovery fails -- which is why the
# old code could get away with recomputing them inside this loop only
# because they were deterministic.
per_byte_coeffs = [
[secrets.randbelow(PRIME) for _ in range(threshold - 1)] for _ in secret_bytes
]
shares: list[str] = []
for x in range(1, total_shares + 1):
vals: list[int] = []
for idx, b in enumerate(secret_bytes):
coeffs = [
_coef(secret_bytes, label_value, idx, p) for p in range(1, threshold)
]
vals.append(_poly_eval_at_x(int(b), coeffs, x))
vals.append(_poly_eval_at_x(int(b), per_byte_coeffs[idx], x))
raw = b"".join(int(v).to_bytes(2, "big") for v in vals)
digest = hashlib.sha256(raw).hexdigest()[:16]
payload = base64.urlsafe_b64encode(raw).decode("ascii")