mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-02 02:42:04 +00:00
High-risk partitions move ssh/pgp/seed/nostr/managed_account records out of
the vault index into a separately encrypted file, so those kinds need a SECOND
factor beyond the master password. The index keeps a stub carrying kind, label
and archived — enough to list, nothing more.
Both file formats are byte-compatible with Python and verified bidirectionally:
TS unwraps a Python-written key envelope and reads a Python-written partition,
and Python unwraps a TS-written envelope and reads a TS-written partition. That
matters more than usual here — the entries are SSH keys, PGP keys and seeds, so
a partition only one implementation can open is lost secrets, not an
inconvenience. This required a Fernet encryptor in core, which had been
decrypt-only by design; the header now says why that exception exists and that
no other Fernet payload should ever be written.
**One deliberate divergence, and it is the reason this is not a straight port.**
Python records an unlock session in agent_high_risk_unlock.json, and that record
contains the partition_key_tag — the value the partition file's encryption key
is derived from. So for the life of any Python unlock session the partition is
decryptable from disk alone, with no factor, by anything that can read the app
directory; both files live in the same directory, so "can read one" implies "can
read the other". That voids the guarantee the partition exists to provide.
Verified empirically before writing any of this.
Here the tag lives only in the session agent's memory, beside the parent seeds
it already holds, with the same TTL discipline — and in the API process's memory
for the API. Nothing derived from the factor reaches disk, asserted by a test
that scans every file in the app directory for the tag, and confirmed on a live
install. No file format or interop changes; only where the live session lives.
Other properties the tests pin:
- a locked partition refuses reveal rather than deriving from the stub, which
would return a plausible wrong secret
- locking the vault drops the high-risk grant, which is scoped to it
- the grant expires on its own clock, typically much shorter than the seed's
- unlock is owner-gated: a scoped token cannot reach it
- the tag is absent from the audit log, which records only that an unlock
happened
- the factor comes from the environment, never argv
- factor-set refuses to replace an existing factor, since a new partition key
would make an existing partition permanently unreadable
- migrate writes the partition BEFORE the index, so a crash between them
cannot leave stubs pointing at records that were never stored
Approval gates port straight across: one-shot (or N-use) authorizations for
export / reveal_parent_seed / private_key_retrieval, consumed under the store
lock so two callers cannot both spend the last use, with every failure mode
getting its own reason string matching Python's. A corrupt store reads as empty,
which fails closed.
API: /api/v1/high-risk/{status,unlock,lock} are real rather than 501. Unlock
needs the bearer token, the master password header AND the factor header; a
wrong factor gets one indistinguishable reason so probing cannot tell failures
apart.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
393 lines
15 KiB
TypeScript
393 lines
15 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 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);
|
|
});
|
|
});
|