mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 16:02:05 +00:00
Collapse the duplicated high-risk expiry rule; close agent mutation gaps
Mutation testing carried agent.ts to zero survivors (37 mutants, all killed or non-compiling). Four gaps, in descending order of how much they mattered: The high-risk session's expiry was decided in two places -- the sweep in expire() and a second comparison inside highRiskTag(). Breaking EITHER left the whole suite green, because each covered for the other. Worse, the copy in highRiskTag() was unreachable: handle() sweeps before it dispatches, so no request could ever arrive with an expired session still in the map. It read like a check and enforced nothing. highRiskTag() now sweeps instead of re-testing expiresAt, so the rule has one home and a test can hold it to account. The remaining three were missing assertions, not missing checks: - `put`'s `!fingerprint || !mnemonic` guard could become `&&` unnoticed. Both fields are re-validated downstream, so nothing is permitted that was not before -- but a blank mnemonic then reports "fingerprint does not match the supplied seed", sending whoever reads it after the wrong field. Tests now pin the message. - Same shape on high-risk-unlock's fingerprint format guard: disabling it entirely still refused, via "profile not unlocked" -- a lie about a string that could never be a profile. It is also the guard between a caller-supplied string and the audit-log path join, so it should be the one that answers. - highRiskStatus's client-side `expires_at === null` decode was never asserted; inverted, it reports 0 for a locked profile and null for a live one. That number is how a caller knows what is left of the second factor. Also hoists FROZEN/TAG/daemonAt to module scope so the new tests share the injectable clock. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
This commit is contained in:
1 parent
17b94d3e26
commit
62e577a6e1
2 files changed
+162
-26
No files matched your search
@@ -541,13 +541,15 @@ export class AgentDaemon {
|
||||
* partition, so recording it would put it on disk by another route.
|
||||
*/
|
||||
private highRiskTag(fingerprint: string): string {
|
||||
const session = this.highRisk.get(fingerprint);
|
||||
if (!session) return "";
|
||||
if (session.expiresAt <= this.now()) {
|
||||
this.highRisk.delete(fingerprint);
|
||||
return "";
|
||||
}
|
||||
return session.tag;
|
||||
// Sweep rather than re-test `expiresAt` here. This used to carry its own
|
||||
// copy of the expiry comparison, which was wrong twice over: it was a
|
||||
// second place the rule could drift from expire(), and -- because
|
||||
// handle() sweeps before it dispatches -- it was a copy no request could
|
||||
// ever reach. Mutation testing found it: breaking EITHER comparison left
|
||||
// the whole suite green, because each one covered for the other. One
|
||||
// decision point means a test can actually hold it to account.
|
||||
this.expire();
|
||||
return this.highRisk.get(fingerprint)?.tag ?? "";
|
||||
}
|
||||
|
||||
private async handle(msg: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
|
||||
@@ -570,6 +570,83 @@ describe("the owner capability", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Shared by the boundary tests and the `put` guard tests below: both need
|
||||
// a daemon on a clock they control.
|
||||
const FROZEN = 1_800_000_000;
|
||||
const TAG = "a".repeat(64);
|
||||
|
||||
async function daemonAt(now: () => number): Promise<{ d: AgentDaemon; sock: string }> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "seedpass-expiry-"));
|
||||
// `put` writes an audit record, which needs the profile directory and an
|
||||
// index to serve from.
|
||||
const profile = join(dir, FINGERPRINT);
|
||||
await mkdir(profile, { recursive: true });
|
||||
await writeFile(
|
||||
join(profile, INDEX_FILENAME),
|
||||
await encryptV3(
|
||||
deriveIndexKeyBytes(MNEMONIC),
|
||||
utf8(JSON.stringify({ schema_version: 4, entries: {} })),
|
||||
),
|
||||
);
|
||||
const sock = join(dir, "agent.sock");
|
||||
const d = new AgentDaemon(sock, 900, dir, now);
|
||||
await d.start();
|
||||
return { d, sock };
|
||||
}
|
||||
|
||||
describe("put refuses a half-supplied identity", () => {
|
||||
// `put` is the only door a seed comes through, and everything after it
|
||||
// trusts that both halves arrived. Mutation testing turned the `||` in its
|
||||
// guard into `&&`, which lets EITHER field be empty on its own -- and the
|
||||
// suite stayed green, because nothing asked what happens when one is blank.
|
||||
it("refuses an empty mnemonic under a well-formed fingerprint", async () => {
|
||||
let clock = FROZEN;
|
||||
const { d, sock } = await daemonAt(() => clock);
|
||||
try {
|
||||
const client = new AgentClient(sock);
|
||||
// The MESSAGE matters, not just the refusal: without this guard the
|
||||
// blank mnemonic falls through to the fingerprint comparison and
|
||||
// reports a mismatch, sending whoever reads it after the wrong field.
|
||||
await expect(client.put(FINGERPRINT, "", 900)).rejects.toThrow(/missing fields/);
|
||||
// And the refusal must be total: nothing resident afterwards.
|
||||
expect(await client.ownerMnemonic(FINGERPRINT)).toBeNull();
|
||||
} finally {
|
||||
await d.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("names the malformed field when high-risk-unlock gets a bad fingerprint", async () => {
|
||||
// Disabling this guard entirely left the suite green: a malformed
|
||||
// fingerprint is never in `held`, so the "profile not unlocked" check
|
||||
// below refuses it anyway. The format check is still the one that should
|
||||
// answer -- "not unlocked" is a lie about a string that could never be a
|
||||
// profile in the first place, and it is the guard standing between a
|
||||
// caller-supplied string and the audit-log path join.
|
||||
let clock = FROZEN;
|
||||
const { d, sock } = await daemonAt(() => clock);
|
||||
try {
|
||||
const client = new AgentClient(sock);
|
||||
await client.put(FINGERPRINT, MNEMONIC, 900);
|
||||
await expect(client.highRiskUnlock("../../etc", TAG, 300)).rejects.toThrow(
|
||||
/16 uppercase hex/,
|
||||
);
|
||||
} finally {
|
||||
await d.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses an empty fingerprint under a real seed", async () => {
|
||||
let clock = FROZEN;
|
||||
const { d, sock } = await daemonAt(() => clock);
|
||||
try {
|
||||
const client = new AgentClient(sock);
|
||||
await expect(client.put("", MNEMONIC, 900)).rejects.toThrow(/missing fields/);
|
||||
} finally {
|
||||
await d.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("expiry is denied AT the boundary, not after it", () => {
|
||||
/**
|
||||
* Mutation testing found `token.expires_at <= now` could become `<` with
|
||||
@@ -581,26 +658,83 @@ describe("expiry is denied AT the boundary, not after it", () => {
|
||||
* The daemon takes an injectable clock so this can be pinned exactly
|
||||
* rather than raced against wall time.
|
||||
*/
|
||||
const FROZEN = 1_800_000_000;
|
||||
|
||||
async function daemonAt(now: () => number): Promise<{ d: AgentDaemon; sock: string }> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "seedpass-expiry-"));
|
||||
// `put` writes an audit record, which needs the profile directory and an
|
||||
// index to serve from.
|
||||
const profile = join(dir, FINGERPRINT);
|
||||
await mkdir(profile, { recursive: true });
|
||||
await writeFile(
|
||||
join(profile, INDEX_FILENAME),
|
||||
await encryptV3(
|
||||
deriveIndexKeyBytes(MNEMONIC),
|
||||
utf8(JSON.stringify({ schema_version: 4, entries: {} })),
|
||||
),
|
||||
);
|
||||
const sock = join(dir, "agent.sock");
|
||||
const d = new AgentDaemon(sock, 900, dir, now);
|
||||
await d.start();
|
||||
return { d, sock };
|
||||
}
|
||||
|
||||
it("stops handing out the high-risk tag at the exact instant it expires", async () => {
|
||||
// The most consequential of these: the tag IS the partition's encryption
|
||||
// key. A session that keeps answering past its expiry means the second
|
||||
// factor lapsed on paper only.
|
||||
let clock = FROZEN;
|
||||
const { d, sock } = await daemonAt(() => clock);
|
||||
try {
|
||||
const client = new AgentClient(sock);
|
||||
await client.put(FINGERPRINT, MNEMONIC, 3600);
|
||||
await client.highRiskUnlock(FINGERPRINT, TAG, 60);
|
||||
|
||||
clock = FROZEN + 59;
|
||||
expect(await client.highRiskTag(FINGERPRINT)).toBe(TAG);
|
||||
// `expires_at` is what tells a caller how long the second factor has
|
||||
// left, so assert the number and not just the flag: the client-side
|
||||
// null check that decodes it can invert with nothing noticing
|
||||
// otherwise, reporting 0 for a locked profile and null for a live one.
|
||||
expect(await client.highRiskStatus(FINGERPRINT)).toEqual({
|
||||
unlocked: true,
|
||||
expires_at: FROZEN + 60,
|
||||
});
|
||||
|
||||
// Exactly at expiry the tag is gone. `<` would hand it over once more.
|
||||
clock = FROZEN + 60;
|
||||
expect(await client.highRiskTag(FINGERPRINT)).toBeNull();
|
||||
expect(await client.highRiskStatus(FINGERPRINT)).toEqual({
|
||||
unlocked: false,
|
||||
expires_at: null,
|
||||
});
|
||||
} finally {
|
||||
await d.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("forgets a held seed at exactly its expiry, via the background sweep", async () => {
|
||||
let clock = FROZEN;
|
||||
const { d, sock } = await daemonAt(() => clock);
|
||||
try {
|
||||
const client = new AgentClient(sock);
|
||||
await client.put(FINGERPRINT, MNEMONIC, 60);
|
||||
|
||||
// The sweep runs on a real one-second timer, so each step waits for a
|
||||
// tick; only the daemon's notion of NOW is under test control.
|
||||
clock = FROZEN + 59;
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
expect(await client.ownerMnemonic(FINGERPRINT)).toBe(MNEMONIC);
|
||||
|
||||
clock = FROZEN + 60;
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
expect(await client.ownerMnemonic(FINGERPRINT)).toBeNull();
|
||||
} finally {
|
||||
await d.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops a high-risk session in the sweep even while the seed is still held", async () => {
|
||||
// The two lifetimes are independent: a high-risk grant is normally much
|
||||
// shorter than the seed's, and outliving it would silently extend the
|
||||
// second factor.
|
||||
let clock = FROZEN;
|
||||
const { d, sock } = await daemonAt(() => clock);
|
||||
try {
|
||||
const client = new AgentClient(sock);
|
||||
await client.put(FINGERPRINT, MNEMONIC, 3600);
|
||||
await client.highRiskUnlock(FINGERPRINT, TAG, 60);
|
||||
|
||||
clock = FROZEN + 60;
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
expect((await client.highRiskStatus(FINGERPRINT)).unlocked).toBe(false);
|
||||
// The seed itself is untouched — only the stronger grant lapsed.
|
||||
expect(await client.ownerMnemonic(FINGERPRINT)).toBe(MNEMONIC);
|
||||
} finally {
|
||||
await d.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("denies a token at the exact instant it expires", async () => {
|
||||
let clock = FROZEN;
|
||||
|
||||
Reference in new issue
Block a user