Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions services/agent/src/mandate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
assertBidWithinMandate,
bidFromAppraisal,
createSessionMandate,
mandateDigest,
MandateCapError,
MandateError,
usdcToStroops,
Expand Down Expand Up @@ -91,3 +92,74 @@ test("bidFromAppraisal clamps to mandate maxBid", () => {
assert.equal(bidValue, usdcToStroops(40));
assert.equal(escrow, usdcToStroops(40));
});

test("verifySessionMandate rejects when issuedAt > expiresAt", () => {
const p = baseParams();
const { mandate } = createSessionMandate(p);
const { signature: _sig, ...payload } = mandate;
const tamperedPayload = { ...payload, issuedAt: payload.expiresAt + 10 };
const tampered = {
...tamperedPayload,
signature: Keypair.fromSecret(p.principalSecret).sign(mandateDigest(tamperedPayload)).toString("base64"),
};
assert.throws(
() => verifySessionMandate(tampered, { clock: p.clock }),
(error: unknown) => error instanceof MandateError && /issuedAt.*expiresAt/.test((error as Error).message),
);
});

test("verifySessionMandate rejects when issuedAt > commitDeadline", () => {
const p = baseParams();
const { mandate } = createSessionMandate(p);
const { signature: _sig, ...payload } = mandate;
const tamperedPayload = { ...payload, commitDeadline: payload.issuedAt - 10 };
const tampered = {
...tamperedPayload,
signature: Keypair.fromSecret(p.principalSecret).sign(mandateDigest(tamperedPayload)).toString("base64"),
};
assert.throws(
() => verifySessionMandate(tampered, { clock: p.clock }),
(error: unknown) => error instanceof MandateError && /issuedAt.*commitDeadline/.test((error as Error).message),
);
});

test("verifySessionMandate rejects when commitDeadline > expiresAt", () => {
const p = baseParams();
const { mandate } = createSessionMandate(p);
const { signature: _sig, ...payload } = mandate;
const tamperedPayload = { ...payload, commitDeadline: payload.expiresAt + 10 };
const tampered = {
...tamperedPayload,
signature: Keypair.fromSecret(p.principalSecret).sign(mandateDigest(tamperedPayload)).toString("base64"),
};
assert.throws(
() => verifySessionMandate(tampered, { clock: p.clock }),
(error: unknown) => error instanceof MandateError && /commitDeadline.*expiresAt/.test((error as Error).message),
);
});

test("verifySessionMandate allows equality boundaries (issuedAt == commitDeadline == expiresAt)", () => {
const p = baseParams();
// Make commitDeadline equal to expiresAt at creation (already is), then tamper to set all three equal
const { mandate } = createSessionMandate(p);
const { signature: _sig, ...payload } = mandate;
const equalTime = payload.expiresAt;
const tamperedPayload = { ...payload, issuedAt: equalTime, commitDeadline: equalTime, expiresAt: equalTime };
const tampered = {
...tamperedPayload,
signature: Keypair.fromSecret(p.principalSecret).sign(mandateDigest(tamperedPayload)).toString("base64"),
};
assert.doesNotThrow(() => verifySessionMandate(tampered, { clock: p.clock }));
});

test("createSessionMandate rejects timestamp ordering violations at creation", () => {
const p = baseParams();
assert.throws(
() => createSessionMandate({ ...p, commitDeadline: p.clock.nowSeconds() - 10 }),
(error: unknown) => error instanceof MandateError && /issuedAt.*commitDeadline/.test((error as Error).message),
);
assert.throws(
() => createSessionMandate({ ...p, commitDeadline: p.clock.nowSeconds() + 7200, ttlSeconds: 3600 }),
(error: unknown) => error instanceof MandateError && /commitDeadline.*expiresAt/.test((error as Error).message),
);
});
15 changes: 15 additions & 0 deletions services/agent/src/mandate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ export interface SessionMandate extends SessionMandatePayload {
export class MandateError extends Error {}
export class MandateCapError extends MandateError {}

function validateMandateTimestampOrdering(payload: SessionMandatePayload): void {
if (payload.issuedAt > payload.expiresAt) {
throw new MandateError("issuedAt must be <= expiresAt");
}
if (payload.issuedAt > payload.commitDeadline) {
throw new MandateError("issuedAt must be <= commitDeadline");
}
if (payload.commitDeadline > payload.expiresAt) {
throw new MandateError("commitDeadline must be <= expiresAt");
}
}

const canonical = (value: unknown): string => {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === "object") {
Expand Down Expand Up @@ -122,6 +134,7 @@ export function createSessionMandate(params: CreateMandateParams): {
issuedAt: now,
expiresAt: now + (params.ttlSeconds ?? 3600),
};
validateMandateTimestampOrdering(payload);
const sig = principal.sign(mandateDigest(payload));
return {
mandate: { ...payload, signature: sig.toString("base64") },
Expand All @@ -145,6 +158,8 @@ export function verifySessionMandate(
);
if (!ok) throw new MandateError("invalid mandate signature");

validateMandateTimestampOrdering(payload);

const now = opts?.now ?? (opts?.clock ?? systemClock).nowSeconds();
if (now > mandate.expiresAt) throw new MandateError("mandate expired");
if (now > mandate.commitDeadline) throw new MandateError("commit deadline passed");
Expand Down