mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-30 01:12:17 +00:00
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
439 lines
17 KiB
TypeScript
439 lines
17 KiB
TypeScript
/**
|
|
* High-risk partition and approval gates, end to end.
|
|
*
|
|
* The partition exists so that a handful of entry kinds need a SECOND factor
|
|
* beyond the master password. Most of this file is therefore about the ways
|
|
* that guarantee could be lost: the tag reaching disk, a locked partition
|
|
* still yielding secrets, a stub deriving a plausible-but-wrong value, or a
|
|
* scoped token reaching the unlock op.
|
|
*/
|
|
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
import { mkdtemp, mkdir, readFile, writeFile, readdir, stat } from "node:fs/promises";
|
|
import { existsSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import process from "node:process";
|
|
import { mnemonics } from "@seedpass/test-vectors";
|
|
import {
|
|
generateFingerprint,
|
|
deriveIndexKeyBytes,
|
|
encryptV3,
|
|
utf8,
|
|
addSshKeyEntry,
|
|
addPasswordEntry,
|
|
partitionKeyTag,
|
|
type VaultIndex,
|
|
} from "@seedpass/core";
|
|
import {
|
|
buildProgram,
|
|
AgentDaemon,
|
|
AgentClient,
|
|
agentSocketPath,
|
|
AppDir,
|
|
INDEX_FILENAME,
|
|
type ProgramIo,
|
|
} from "../src/index.js";
|
|
import { setFactor, tagForFactor, factorConfigured, partitionPath } from "../src/highRisk.js";
|
|
import {
|
|
issueApproval,
|
|
listApprovals,
|
|
revokeApproval,
|
|
consumeApproval,
|
|
approvalRequired,
|
|
} from "../src/approvals.js";
|
|
|
|
const MNEMONIC = mnemonics["abandon12"]!;
|
|
const FINGERPRINT = generateFingerprint(MNEMONIC);
|
|
const PASSWORD = "high-risk-test-pw";
|
|
const FACTOR = "the-second-factor";
|
|
|
|
let appDir: string;
|
|
let app: AppDir;
|
|
let daemon: AgentDaemon;
|
|
|
|
async function run(
|
|
env: Record<string, string | undefined>,
|
|
...argv: string[]
|
|
): Promise<{ stdout: string; stderr: string; error?: unknown }> {
|
|
const saved: Record<string, string | undefined> = {};
|
|
for (const [k, v] of Object.entries(env)) {
|
|
saved[k] = process.env[k];
|
|
if (v === undefined) delete process.env[k];
|
|
else process.env[k] = v;
|
|
}
|
|
const out: string[] = [];
|
|
const err: string[] = [];
|
|
const io: ProgramIo = { out: (l) => out.push(l), err: (l) => err.push(l) };
|
|
let error: unknown;
|
|
try {
|
|
await buildProgram(io).parseAsync(["node", "seedpass-js", "--app-dir", appDir, ...argv]);
|
|
} catch (e) {
|
|
error = e;
|
|
} finally {
|
|
for (const [k, v] of Object.entries(saved)) {
|
|
if (v === undefined) delete process.env[k];
|
|
else process.env[k] = v;
|
|
}
|
|
}
|
|
return { stdout: out.join("\n"), stderr: err.join("\n"), error };
|
|
}
|
|
|
|
const asOwner = { SEEDPASS_MNEMONIC: MNEMONIC };
|
|
const withFactor = { ...asOwner, SEEDPASS_HIGH_RISK_FACTOR: FACTOR };
|
|
/** `vault unlock` decrypts the parent seed, so it needs the master password. */
|
|
const toUnlock = { ...asOwner, SEEDPASS_PASSWORD: PASSWORD };
|
|
|
|
beforeAll(async () => {
|
|
appDir = await mkdtemp(join(tmpdir(), "seedpass-hr-"));
|
|
app = new AppDir(appDir);
|
|
process.env["SEEDPASS_AGENT_SOCK"] = join(appDir, "agent.sock");
|
|
daemon = new AgentDaemon(agentSocketPath(appDir), 900, appDir);
|
|
await daemon.start();
|
|
|
|
await app.mutateFingerprints((d) => {
|
|
d.fingerprints.push(FINGERPRINT);
|
|
d.names[FINGERPRINT] = "hr";
|
|
d.last_used = FINGERPRINT;
|
|
});
|
|
await mkdir(app.profileDir(FINGERPRINT), { recursive: true });
|
|
await app.writeParentSeed(FINGERPRINT, MNEMONIC, PASSWORD);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await daemon.stop();
|
|
delete process.env["SEEDPASS_AGENT_SOCK"];
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Fresh vault each test: migration is destructive to the index by design.
|
|
const index = { schema_version: 4, entries: {} } as VaultIndex;
|
|
addPasswordEntry(index, "ordinary.example", 16, {});
|
|
addSshKeyEntry(index, "prod-server", { notes: "deployment key" });
|
|
await writeFile(
|
|
join(app.profileDir(FINGERPRINT), INDEX_FILENAME),
|
|
await encryptV3(deriveIndexKeyBytes(MNEMONIC), utf8(JSON.stringify(index))),
|
|
);
|
|
});
|
|
|
|
describe("the factor", () => {
|
|
it("is configured once and refuses to be silently replaced", async () => {
|
|
const first = await run(withFactor, "agent", "high-risk", "factor-set");
|
|
expect(JSON.parse(first.stdout).configured).toBe(true);
|
|
expect(factorConfigured(appDir)).toBe(true);
|
|
|
|
// Replacing it mints a new partition key, which would make an existing
|
|
// partition permanently unreadable. Losing a partition is losing secrets.
|
|
const second = await run(
|
|
{ ...asOwner, SEEDPASS_HIGH_RISK_FACTOR: "different" },
|
|
"agent", "high-risk", "factor-set",
|
|
);
|
|
expect(String((second.error as Error).message)).toContain("already configured");
|
|
});
|
|
|
|
it("is never read from the command line", async () => {
|
|
const r = await run({ ...asOwner, SEEDPASS_HIGH_RISK_FACTOR: undefined },
|
|
"agent", "high-risk", "unlock");
|
|
expect(String((r.error as Error).message)).toContain("SEEDPASS_HIGH_RISK_FACTOR");
|
|
});
|
|
|
|
it("verifies correctly and rejects a wrong one", async () => {
|
|
expect(await tagForFactor(appDir, FACTOR)).toMatch(/^[0-9a-f]{64}$/);
|
|
await expect(tagForFactor(appDir, "wrong")).rejects.toMatchObject({
|
|
reason: "high_risk_factor_invalid",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("migration and access", () => {
|
|
it("moves high-risk entries out of the index and leaves only a stub", async () => {
|
|
await run(toUnlock, "vault", "unlock");
|
|
const migrated = await run(withFactor, "agent", "high-risk", "migrate");
|
|
expect(JSON.parse(migrated.stdout).moved_count).toBe(1);
|
|
|
|
// The index no longer holds the entry's substance.
|
|
const raw = await readFile(join(app.profileDir(FINGERPRINT), INDEX_FILENAME));
|
|
const listed = JSON.parse((await run(asOwner, "entry", "list")).stdout);
|
|
const stub = listed.find((r: any) => r.label === "prod-server");
|
|
expect(stub).toBeTruthy();
|
|
expect(JSON.stringify(listed)).not.toContain("deployment key");
|
|
void raw;
|
|
|
|
// The partition file exists and is 0600 — it is the whole secret.
|
|
const path = partitionPath(app.profileDir(FINGERPRINT));
|
|
expect(existsSync(path)).toBe(true);
|
|
expect((await stat(path)).mode & 0o777).toBe(0o600);
|
|
});
|
|
|
|
it("refuses to reveal a partitioned entry while the partition is locked", async () => {
|
|
await run(toUnlock, "vault", "unlock");
|
|
await run(withFactor, "agent", "high-risk", "migrate");
|
|
await run(asOwner, "agent", "high-risk", "lock");
|
|
|
|
const r = await run(asOwner, "entry", "reveal", "prod-server");
|
|
expect(String((r.error as Error).message)).toContain("high-risk partition");
|
|
expect(r.stdout).toBe("");
|
|
});
|
|
|
|
it("reveals it again once unlocked, and matches the pre-migration secret", async () => {
|
|
await run(toUnlock, "vault", "unlock");
|
|
// Capture the secret BEFORE migrating, so this proves the round trip
|
|
// rather than merely that something came back.
|
|
const before = (await run(asOwner, "entry", "reveal", "prod-server")).stdout;
|
|
expect(before.length).toBeGreaterThan(0);
|
|
|
|
await run(withFactor, "agent", "high-risk", "migrate");
|
|
await run(withFactor, "agent", "high-risk", "unlock", "--ttl", "60");
|
|
const after = (await run(asOwner, "entry", "reveal", "prod-server")).stdout;
|
|
expect(after).toBe(before);
|
|
});
|
|
|
|
it("leaves ordinary entries alone", async () => {
|
|
await run(toUnlock, "vault", "unlock");
|
|
const before = (await run(asOwner, "entry", "reveal", "ordinary.example")).stdout;
|
|
await run(withFactor, "agent", "high-risk", "migrate");
|
|
// No unlock: a password entry must still work with the master password.
|
|
const after = (await run(asOwner, "entry", "reveal", "ordinary.example")).stdout;
|
|
expect(after).toBe(before);
|
|
});
|
|
|
|
it("is idempotent — a second migration moves nothing", async () => {
|
|
await run(toUnlock, "vault", "unlock");
|
|
await run(withFactor, "agent", "high-risk", "migrate");
|
|
const again = await run(withFactor, "agent", "high-risk", "migrate");
|
|
expect(JSON.parse(again.stdout).moved_count).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("the unlock never reaches disk", () => {
|
|
it("writes no file containing the partition key tag", async () => {
|
|
await run(toUnlock, "vault", "unlock");
|
|
await run(withFactor, "agent", "high-risk", "migrate");
|
|
await run(withFactor, "agent", "high-risk", "unlock", "--ttl", "300");
|
|
|
|
const tag = await tagForFactor(appDir, FACTOR);
|
|
expect(tag).toMatch(/^[0-9a-f]{64}$/);
|
|
|
|
// The tag IS the partition's encryption key. Python records it in
|
|
// agent_high_risk_unlock.json for the life of a session, which lets
|
|
// anything that can read the app directory open the partition with no
|
|
// factor at all — voiding the second factor the partition exists for.
|
|
// Nothing here may contain it.
|
|
for (const name of await readdir(appDir)) {
|
|
const path = join(appDir, name);
|
|
if (!(await stat(path)).isFile()) continue;
|
|
const contents = await readFile(path, "utf8").catch(() => "");
|
|
expect(contents).not.toContain(tag);
|
|
}
|
|
// And specifically: no session file exists at all.
|
|
expect(existsSync(join(appDir, "agent_high_risk_unlock.json"))).toBe(false);
|
|
});
|
|
|
|
it("drops the unlock when the vault is locked", async () => {
|
|
await run(toUnlock, "vault", "unlock");
|
|
await run(withFactor, "agent", "high-risk", "migrate");
|
|
await run(withFactor, "agent", "high-risk", "unlock", "--ttl", "300");
|
|
|
|
const client = new AgentClient(agentSocketPath(appDir));
|
|
expect((await client.highRiskStatus(FINGERPRINT)).unlocked).toBe(true);
|
|
// Locking the vault must not leave the stronger grant standing.
|
|
await client.lock(FINGERPRINT);
|
|
expect((await client.highRiskStatus(FINGERPRINT)).unlocked).toBe(false);
|
|
});
|
|
|
|
it("expires on its own clock", async () => {
|
|
await run(toUnlock, "vault", "unlock");
|
|
const client = new AgentClient(agentSocketPath(appDir));
|
|
const tag = await tagForFactor(appDir, FACTOR);
|
|
const expiresAt = await client.highRiskUnlock(FINGERPRINT, tag, 1);
|
|
expect(expiresAt).toBeGreaterThan(0);
|
|
await new Promise((r) => setTimeout(r, 1300));
|
|
expect((await client.highRiskStatus(FINGERPRINT)).unlocked).toBe(false);
|
|
expect(await client.highRiskTag(FINGERPRINT)).toBeNull();
|
|
});
|
|
|
|
it("refuses an unlock for a profile whose vault is locked", async () => {
|
|
const client = new AgentClient(agentSocketPath(appDir));
|
|
await client.lock(FINGERPRINT);
|
|
const tag = await tagForFactor(appDir, FACTOR);
|
|
// A high-risk grant over a locked vault protects nothing and would
|
|
// outlive the thing it applies to.
|
|
await expect(client.highRiskUnlock(FINGERPRINT, tag, 60)).rejects.toThrow(/not unlocked/);
|
|
});
|
|
|
|
it("rejects a malformed tag rather than storing it", async () => {
|
|
await run(toUnlock, "vault", "unlock");
|
|
const client = new AgentClient(agentSocketPath(appDir));
|
|
for (const bad of ["", "short", "z".repeat(64), "../../etc"]) {
|
|
await expect(client.highRiskUnlock(FINGERPRINT, bad, 60)).rejects.toThrow();
|
|
}
|
|
});
|
|
|
|
it("keeps the tag out of the audit log", async () => {
|
|
await run(toUnlock, "vault", "unlock");
|
|
await run(withFactor, "agent", "high-risk", "unlock", "--ttl", "60");
|
|
const tag = await tagForFactor(appDir, FACTOR);
|
|
const audit = await readFile(join(app.profileDir(FINGERPRINT), "audit.log"), "utf8");
|
|
// The audit records THAT the partition was unlocked; recording the tag
|
|
// would put the key on disk by another route.
|
|
expect(audit).toContain("high_risk_unlocked");
|
|
expect(audit).not.toContain(tag);
|
|
});
|
|
});
|
|
|
|
describe("approval gates", () => {
|
|
it("issues, lists, consumes and exhausts", async () => {
|
|
const issued = JSON.parse(
|
|
(await run(asOwner, "agent", "approval", "issue", "--action", "export", "--uses", "2"))
|
|
.stdout,
|
|
);
|
|
expect(issued.action).toBe("export");
|
|
expect(issued.uses_remaining).toBe(2);
|
|
expect(issued.id).toHaveLength(24); // 18 random bytes, base64url
|
|
|
|
expect(await listApprovals(appDir)).toHaveLength(1);
|
|
|
|
expect(await consumeApproval(appDir, { approvalId: issued.id, action: "export" })).toEqual({
|
|
ok: true,
|
|
reason: "approval_consumed",
|
|
});
|
|
expect(await consumeApproval(appDir, { approvalId: issued.id, action: "export" })).toEqual({
|
|
ok: true,
|
|
reason: "approval_consumed",
|
|
});
|
|
// Third use: the budget is spent.
|
|
expect(await consumeApproval(appDir, { approvalId: issued.id, action: "export" })).toEqual({
|
|
ok: false,
|
|
reason: "approval_exhausted",
|
|
});
|
|
});
|
|
|
|
it("distinguishes every way a consume can fail", async () => {
|
|
const issued = await issueApproval(appDir, {
|
|
action: "export",
|
|
resource: "vault-a",
|
|
ttlSeconds: 300,
|
|
uses: 1,
|
|
});
|
|
|
|
expect(
|
|
await consumeApproval(appDir, { approvalId: "no-such-id", action: "export" }),
|
|
).toEqual({ ok: false, reason: "approval_not_found" });
|
|
|
|
expect(
|
|
await consumeApproval(appDir, { approvalId: issued.id, action: "reveal_parent_seed" }),
|
|
).toEqual({ ok: false, reason: "approval_action_mismatch" });
|
|
|
|
expect(
|
|
await consumeApproval(appDir, {
|
|
approvalId: issued.id,
|
|
action: "export",
|
|
resource: "vault-b",
|
|
}),
|
|
).toEqual({ ok: false, reason: "approval_resource_mismatch" });
|
|
|
|
// Still unconsumed after all those failures.
|
|
expect((await listApprovals(appDir)).find((a) => a.id === issued.id)!.uses_remaining).toBe(1);
|
|
});
|
|
|
|
it("refuses an expired approval", async () => {
|
|
const issued = await issueApproval(appDir, {
|
|
action: "export",
|
|
ttlSeconds: 1,
|
|
uses: 1,
|
|
now: Date.now() - 10_000,
|
|
});
|
|
expect(await consumeApproval(appDir, { approvalId: issued.id, action: "export" })).toEqual({
|
|
ok: false,
|
|
reason: "approval_expired",
|
|
});
|
|
});
|
|
|
|
it("refuses a revoked approval and hides it from the default listing", async () => {
|
|
const issued = await issueApproval(appDir, { action: "export", ttlSeconds: 300, uses: 5 });
|
|
expect(await revokeApproval(appDir, issued.id)).toBe(true);
|
|
// Revoking twice is not an error but changes nothing.
|
|
expect(await revokeApproval(appDir, issued.id)).toBe(false);
|
|
|
|
expect(await consumeApproval(appDir, { approvalId: issued.id, action: "export" })).toEqual({
|
|
ok: false,
|
|
reason: "approval_revoked",
|
|
});
|
|
expect((await listApprovals(appDir)).map((a) => a.id)).not.toContain(issued.id);
|
|
expect((await listApprovals(appDir, { includeRevoked: true })).map((a) => a.id)).toContain(
|
|
issued.id,
|
|
);
|
|
});
|
|
|
|
it("refuses a ttl or use count that would make the approval meaningless", async () => {
|
|
// Mutation testing found these unguarded by any test. An approval issued
|
|
// with ttl 0 is expired the instant it exists; one with uses 0 can never
|
|
// be consumed. Both would look like a granted authorization to whoever
|
|
// requested it and silently authorize nothing — the confusing failure,
|
|
// not the safe one.
|
|
for (const ttl of [0, -1, 1.5, Number.NaN]) {
|
|
await expect(
|
|
issueApproval(appDir, { action: "export", ttlSeconds: ttl, uses: 1 }),
|
|
).rejects.toThrow(/ttl must be a positive integer/);
|
|
}
|
|
for (const uses of [0, -1, 2.5, Number.NaN]) {
|
|
await expect(
|
|
issueApproval(appDir, { action: "export", ttlSeconds: 60, uses }),
|
|
).rejects.toThrow(/uses must be a positive integer/);
|
|
}
|
|
});
|
|
|
|
it("treats an approval expiring exactly now as expired", async () => {
|
|
// The boundary, which no test previously pinned: `<=` refuses at the
|
|
// instant of expiry, `<` would allow one last use. Python uses `<=`, so
|
|
// this is parity as well as the safer direction.
|
|
const now = Date.now();
|
|
const issued = await issueApproval(appDir, {
|
|
action: "export",
|
|
ttlSeconds: 60,
|
|
uses: 1,
|
|
now: now - 60_000,
|
|
});
|
|
expect(Date.parse(issued.expires_at_utc)).toBe(now);
|
|
expect(
|
|
await consumeApproval(appDir, { approvalId: issued.id, action: "export", now }),
|
|
).toEqual({ ok: false, reason: "approval_expired" });
|
|
// And one millisecond earlier it is still usable, so the boundary is
|
|
// exactly where it claims to be rather than approximately.
|
|
const still = await issueApproval(appDir, {
|
|
action: "export",
|
|
ttlSeconds: 60,
|
|
uses: 1,
|
|
now: now - 60_000 + 1,
|
|
});
|
|
expect(
|
|
await consumeApproval(appDir, { approvalId: still.id, action: "export", now }),
|
|
).toEqual({ ok: true, reason: "approval_consumed" });
|
|
});
|
|
|
|
it("refuses to issue an approval for an action it does not know", async () => {
|
|
// An unknown action would produce an approval that gates nothing while
|
|
// looking like it gates something.
|
|
await expect(
|
|
issueApproval(appDir, { action: "delete_everything", ttlSeconds: 60, uses: 1 }),
|
|
).rejects.toThrow(/unknown approval action/);
|
|
});
|
|
|
|
it("reads require_for out of a policy", () => {
|
|
const policy = { approvals: { require_for: ["Export", "reveal_parent_seed"] } };
|
|
expect(approvalRequired(policy, "export")).toBe(true);
|
|
expect(approvalRequired(policy, "REVEAL_PARENT_SEED")).toBe(true);
|
|
expect(approvalRequired(policy, "private_key_retrieval")).toBe(false);
|
|
// A malformed policy must not accidentally mean "nothing needs approval"
|
|
// in a way that differs from Python — both treat it as no requirement.
|
|
expect(approvalRequired({}, "export")).toBe(false);
|
|
expect(approvalRequired({ approvals: "yes" }, "export")).toBe(false);
|
|
});
|
|
|
|
it("stores approvals at 0600", async () => {
|
|
await issueApproval(appDir, { action: "export", ttlSeconds: 60, uses: 1 });
|
|
const mode = (await stat(join(appDir, "agent_approvals.json"))).mode & 0o777;
|
|
expect(mode).toBe(0o600);
|
|
});
|
|
});
|