Skip to content

Commit 879e3bc

Browse files
committed
fix(agent): validate mandate timestamp ordering
Reject signed mandates whose issued, expiry, and commit timestamps are logically inconsistent. Enforce issuedAt <= commitDeadline <= expiresAt with exact equality allowed. Add focused tests for each invalid ordering and equality-boundary behavior. Closes #274
1 parent 8b9d81b commit 879e3bc

2 files changed

Lines changed: 87 additions & 0 deletions

File tree

services/agent/src/mandate.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
assertBidWithinMandate,
1010
bidFromAppraisal,
1111
createSessionMandate,
12+
mandateDigest,
1213
MandateCapError,
1314
MandateError,
1415
usdcToStroops,
@@ -91,3 +92,74 @@ test("bidFromAppraisal clamps to mandate maxBid", () => {
9192
assert.equal(bidValue, usdcToStroops(40));
9293
assert.equal(escrow, usdcToStroops(40));
9394
});
95+
96+
test("verifySessionMandate rejects when issuedAt > expiresAt", () => {
97+
const p = baseParams();
98+
const { mandate } = createSessionMandate(p);
99+
const { signature: _sig, ...payload } = mandate;
100+
const tamperedPayload = { ...payload, issuedAt: payload.expiresAt + 10 };
101+
const tampered = {
102+
...tamperedPayload,
103+
signature: Keypair.fromSecret(p.principalSecret).sign(mandateDigest(tamperedPayload)).toString("base64"),
104+
};
105+
assert.throws(
106+
() => verifySessionMandate(tampered, { clock: p.clock }),
107+
(error: unknown) => error instanceof MandateError && /issuedAt.*expiresAt/.test((error as Error).message),
108+
);
109+
});
110+
111+
test("verifySessionMandate rejects when issuedAt > commitDeadline", () => {
112+
const p = baseParams();
113+
const { mandate } = createSessionMandate(p);
114+
const { signature: _sig, ...payload } = mandate;
115+
const tamperedPayload = { ...payload, commitDeadline: payload.issuedAt - 10 };
116+
const tampered = {
117+
...tamperedPayload,
118+
signature: Keypair.fromSecret(p.principalSecret).sign(mandateDigest(tamperedPayload)).toString("base64"),
119+
};
120+
assert.throws(
121+
() => verifySessionMandate(tampered, { clock: p.clock }),
122+
(error: unknown) => error instanceof MandateError && /issuedAt.*commitDeadline/.test((error as Error).message),
123+
);
124+
});
125+
126+
test("verifySessionMandate rejects when commitDeadline > expiresAt", () => {
127+
const p = baseParams();
128+
const { mandate } = createSessionMandate(p);
129+
const { signature: _sig, ...payload } = mandate;
130+
const tamperedPayload = { ...payload, commitDeadline: payload.expiresAt + 10 };
131+
const tampered = {
132+
...tamperedPayload,
133+
signature: Keypair.fromSecret(p.principalSecret).sign(mandateDigest(tamperedPayload)).toString("base64"),
134+
};
135+
assert.throws(
136+
() => verifySessionMandate(tampered, { clock: p.clock }),
137+
(error: unknown) => error instanceof MandateError && /commitDeadline.*expiresAt/.test((error as Error).message),
138+
);
139+
});
140+
141+
test("verifySessionMandate allows equality boundaries (issuedAt == commitDeadline == expiresAt)", () => {
142+
const p = baseParams();
143+
// Make commitDeadline equal to expiresAt at creation (already is), then tamper to set all three equal
144+
const { mandate } = createSessionMandate(p);
145+
const { signature: _sig, ...payload } = mandate;
146+
const equalTime = payload.expiresAt;
147+
const tamperedPayload = { ...payload, issuedAt: equalTime, commitDeadline: equalTime, expiresAt: equalTime };
148+
const tampered = {
149+
...tamperedPayload,
150+
signature: Keypair.fromSecret(p.principalSecret).sign(mandateDigest(tamperedPayload)).toString("base64"),
151+
};
152+
assert.doesNotThrow(() => verifySessionMandate(tampered, { clock: p.clock }));
153+
});
154+
155+
test("createSessionMandate rejects timestamp ordering violations at creation", () => {
156+
const p = baseParams();
157+
assert.throws(
158+
() => createSessionMandate({ ...p, commitDeadline: p.clock.nowSeconds() - 10 }),
159+
(error: unknown) => error instanceof MandateError && /issuedAt.*commitDeadline/.test((error as Error).message),
160+
);
161+
assert.throws(
162+
() => createSessionMandate({ ...p, commitDeadline: p.clock.nowSeconds() + 7200, ttlSeconds: 3600 }),
163+
(error: unknown) => error instanceof MandateError && /commitDeadline.*expiresAt/.test((error as Error).message),
164+
);
165+
});

services/agent/src/mandate.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,18 @@ export interface SessionMandate extends SessionMandatePayload {
4949
export class MandateError extends Error {}
5050
export class MandateCapError extends MandateError {}
5151

52+
function validateMandateTimestampOrdering(payload: SessionMandatePayload): void {
53+
if (payload.issuedAt > payload.expiresAt) {
54+
throw new MandateError("issuedAt must be <= expiresAt");
55+
}
56+
if (payload.issuedAt > payload.commitDeadline) {
57+
throw new MandateError("issuedAt must be <= commitDeadline");
58+
}
59+
if (payload.commitDeadline > payload.expiresAt) {
60+
throw new MandateError("commitDeadline must be <= expiresAt");
61+
}
62+
}
63+
5264
const canonical = (value: unknown): string => {
5365
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
5466
if (value && typeof value === "object") {
@@ -122,6 +134,7 @@ export function createSessionMandate(params: CreateMandateParams): {
122134
issuedAt: now,
123135
expiresAt: now + (params.ttlSeconds ?? 3600),
124136
};
137+
validateMandateTimestampOrdering(payload);
125138
const sig = principal.sign(mandateDigest(payload));
126139
return {
127140
mandate: { ...payload, signature: sig.toString("base64") },
@@ -145,6 +158,8 @@ export function verifySessionMandate(
145158
);
146159
if (!ok) throw new MandateError("invalid mandate signature");
147160

161+
validateMandateTimestampOrdering(payload);
162+
148163
const now = opts?.now ?? (opts?.clock ?? systemClock).nowSeconds();
149164
if (now > mandate.expiresAt) throw new MandateError("mandate expired");
150165
if (now > mandate.commitDeadline) throw new MandateError("commit deadline passed");

0 commit comments

Comments
 (0)