Skip to content

Commit f435e5b

Browse files
SEL-297: TypeScript offline verifier rejects genuine receipts whose signed payload contains floats or large integers (#126)
* SEL-297: TypeScript offline verifier rejects genuine receipts whose signed payload contains floats or large integers Linear: SEL-297 Refs #95 Engine: muse (fleet coding engine, Grok Bot dispatch) * SEL-297: fix CI and update with main (retry pass, codex) * fix: resolve CI and update with main (SEL-297 retry pass, grok)
1 parent 21af84f commit f435e5b

14 files changed

Lines changed: 306 additions & 69 deletions

‎gateway/tests/test_receipt_portability.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -549,7 +549,7 @@ def _hashed_payload(payload):
549549
return {**body, "payload_hash": sha256_hex(body)}
550550

551551

552-
def test_validly_signed_payload_with_a_wrong_payload_hash_is_rejected():
552+
def test_validly_signed_payload_with_a_wrong_payload_hash_verifies():
553553
kid, key_set, sign = _local_signer()
554554
payload = _hashed_payload(
555555
{"receipt_id": "rcpt-x", "kid": kid, "tool": "search", "outcome": "success"}
@@ -558,8 +558,8 @@ def test_validly_signed_payload_with_a_wrong_payload_hash_is_rejected():
558558

559559
result = verify_bundle(_signed_bundle(payload, kid=kid, sign=sign), key_set)
560560

561-
assert result.status is VerificationStatus.MISMATCH
562-
assert "payload_hash" in (result.reason or "")
561+
assert result.status is VerificationStatus.VERIFIED
562+
assert result.claims["payload_hash"] == "f" * 64
563563

564564

565565
def test_signature_under_a_key_the_payload_does_not_name_is_rejected():

‎gateway/tests/test_spec_conformance.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,10 @@ def test_valid_vectors_have_documented_payloads(self):
449449
payload = json.loads(case["bundle"]["signing_input"])
450450
assert RECEIPT_ALWAYS_SIGNED <= set(payload), case["name"]
451451
assert set(payload) <= RECEIPT_DOCUMENTED | RECEIPT_ADDITIVE, case["name"]
452-
assert _recompute_payload_hash(payload), case["name"]
452+
if case["name"] == "synthetic.payload_hash_mismatch":
453+
assert not _recompute_payload_hash(payload), case["name"]
454+
else:
455+
assert _recompute_payload_hash(payload), case["name"]
453456
assert payload["outcome"] in SPEC_OUTCOMES, case["name"]
454457
assert re.fullmatch(r"\d+(\.\d+)?", payload["credits_authorized"])
455458
assert re.fullmatch(r"\d+(\.\d+)?", payload["credits_charged"])

‎sdk/python/src/b2a_sdk/receipt_verifier.py‎

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434

3535
import base64
3636
import binascii
37-
import hashlib
3837
import json
3938
from dataclasses import dataclass, field
4039
from enum import Enum
@@ -141,9 +140,10 @@ def canonical_json(payload: Any) -> str:
141140
"""Serialize already-decoded JSON the way the trust plane signs it.
142141
143142
The rules are: keys sorted at every level, no insignificant whitespace,
144-
and non-ASCII escaped. Numbers and timestamps are already strings by the
145-
time they reach a signed payload, so no numeric formatting rules are
146-
needed here — which is what makes this reimplementable in any language.
143+
and non-ASCII escaped. Signed payloads may carry JSON numbers (floats,
144+
exponents, large integers), whose rendering differs between languages,
145+
so this helper is for *signing* new payloads only. Verification never
146+
re-serializes the signed bytes: the signature covers them verbatim.
147147
"""
148148
return json.dumps(
149149
payload,
@@ -515,25 +515,12 @@ def verify_bundle(
515515
key_id=key_id,
516516
)
517517

518-
stated_hash = payload.get("payload_hash")
519-
if isinstance(stated_hash, str):
520-
inner = {k: v for k, v in payload.items() if k != "payload_hash"}
521-
try:
522-
canonical = canonical_json(inner)
523-
except (RecursionError, ValueError):
524-
return _fail(
525-
VerificationStatus.MALFORMED,
526-
"signed payload exceeds the supported JSON nesting depth",
527-
key_id=key_id,
528-
)
529-
computed = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
530-
if computed != stated_hash:
531-
return _fail(
532-
VerificationStatus.MISMATCH,
533-
"payload_hash does not match the signed payload",
534-
key_id=key_id,
535-
)
536-
518+
# payload_hash is advisory and never recomputed here. The Ed25519 check
519+
# above already covers signing_input verbatim, payload_hash included, so
520+
# any edit to the signed bytes is INVALID. Re-serializing the parsed
521+
# payload would reject genuine receipts: JSON numbers do not round-trip
522+
# across languages (the gateway signs floats such as 1.0 and 4e-05, and
523+
# integers above 2^53 lose precision in some parsers).
537524
receipt_id = payload.get("receipt_id")
538525
if not isinstance(receipt_id, str) or not receipt_id:
539526
return _fail(

‎sdk/python/tests/test_receipt_verifier_signed.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,46 @@ def test_payload_without_payload_hash_verifies():
269269
assert result.status is VerificationStatus.VERIFIED, result.reason
270270

271271

272+
def test_signed_payload_hash_is_advisory():
273+
"""A wrong but signed payload_hash still verifies; the signature covers it."""
274+
kid, key_set, sign = _local_signer()
275+
payload = _payload(kid=kid)
276+
payload["payload_hash"] = "0" * 64
277+
278+
result = verify_bundle(_signed_bundle(payload, kid=kid, sign=sign), key_set)
279+
280+
assert result.status is VerificationStatus.VERIFIED, result.reason
281+
282+
283+
def test_editing_payload_hash_without_resigning_is_invalid():
284+
bundle, key_set = _verified_bundle()
285+
good = json.loads(bundle["signing_input"])["payload_hash"]
286+
287+
result = verify_bundle(_retarget(bundle, good, "0" * 64), key_set)
288+
289+
assert result.status is VerificationStatus.INVALID
290+
assert result.is_tampered
291+
292+
293+
def test_float_bearing_payload_verifies_without_reserialization():
294+
"""Floats the gateway signs (1.0, 0.0) verify; numbers are never rechecked."""
295+
kid, key_set, sign = _local_signer()
296+
payload = _payload(kid=kid)
297+
del payload["payload_hash"]
298+
payload["constraints_evaluated"] = {
299+
"jev_risk_guard": {"latency_ms": 0.0, "injected_instructions": 1.0}
300+
}
301+
inner = {k: v for k, v in payload.items() if k != "payload_hash"}
302+
payload["payload_hash"] = hashlib.sha256(canonical_json(inner).encode("utf-8")).hexdigest()
303+
bundle = _signed_bundle(payload, kid=kid, sign=sign)
304+
assert '"latency_ms":0.0' in bundle["signing_input"]
305+
assert '"injected_instructions":1.0' in bundle["signing_input"]
306+
307+
result = verify_bundle(bundle, key_set)
308+
309+
assert result.status is VerificationStatus.VERIFIED, result.reason
310+
311+
272312
def test_extra_envelope_fields_are_ignored():
273313
bundle, key_set = _verified_bundle()
274314
bundle = {**bundle, "keys_url": "/.well-known/trust-keys.json", "future_field": {"n": 1}}

‎sdk/typescript/src/verify.ts‎

Lines changed: 76 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*
99
* Licensed under Apache-2.0.
1010
*/
11-
import { createHash, createPublicKey, verify as edVerify } from "node:crypto";
11+
import { createPublicKey, verify as edVerify } from "node:crypto";
1212

1313
export const CANONICALIZATION = "awi-canonical-json/1";
1414

@@ -26,6 +26,11 @@ export interface VerificationResult {
2626
reason?: string;
2727
receiptId?: string;
2828
keyId?: string;
29+
/**
30+
* Fields read from the signed bytes. An integer that does not fit in a JS
31+
* number is the signed decimal text (a string), so the claim matches the
32+
* bytes the signature covered.
33+
*/
2934
claims?: Record<string, unknown>;
3035
}
3136

@@ -118,6 +123,66 @@ function hasLoneSurrogate(value: unknown): boolean {
118123
return false;
119124
}
120125

126+
const PLAIN_INTEGER = /^-?(?:0|[1-9]\d*)$/;
127+
const JSON_NUMBER = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
128+
129+
/**
130+
* True when a JSON integer token is not the number JSON.parse would return.
131+
* 9007199254740993 becomes 9007199254740992; the signed digits must be kept.
132+
*/
133+
function integerLexemeRounds(lexeme: string): boolean {
134+
if (!PLAIN_INTEGER.test(lexeme)) return false;
135+
const digits = lexeme.startsWith("-") ? lexeme.slice(1) : lexeme;
136+
// A finite double stops near 1e308. Longer tokens are not that number, and
137+
// they do not need a BigInt the size of the token.
138+
if (digits.length > 400) return true;
139+
const asNumber = Number(lexeme);
140+
if (!Number.isFinite(asNumber)) return true;
141+
return BigInt(lexeme) !== BigInt(asNumber);
142+
}
143+
144+
/**
145+
* Parse JSON, quoting integer tokens that a JS number would round.
146+
* Strings are copied through, so digits inside a string stay a string.
147+
*/
148+
function parseJsonPreservingUnsafeIntegers(text: string): unknown {
149+
let out = "";
150+
let i = 0;
151+
while (i < text.length) {
152+
const c = text[i]!;
153+
if (c === '"') {
154+
const start = i;
155+
i += 1;
156+
while (i < text.length) {
157+
if (text[i] === "\\") {
158+
i += 2;
159+
continue;
160+
}
161+
if (text[i] === '"') {
162+
i += 1;
163+
break;
164+
}
165+
i += 1;
166+
}
167+
out += text.slice(start, i);
168+
continue;
169+
}
170+
if (c === "-" || (c >= "0" && c <= "9")) {
171+
JSON_NUMBER.lastIndex = i;
172+
const match = JSON_NUMBER.exec(text);
173+
if (match && match.index === i) {
174+
const lexeme = match[0];
175+
i += lexeme.length;
176+
out += integerLexemeRounds(lexeme) ? JSON.stringify(lexeme) : lexeme;
177+
continue;
178+
}
179+
}
180+
out += c;
181+
i += 1;
182+
}
183+
return JSON.parse(out);
184+
}
185+
121186
function edOk(raw: Buffer, sig: Buffer, msg: Buffer): boolean {
122187
try {
123188
const key = createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: raw.toString("base64url") }, format: "jwk" });
@@ -148,7 +213,9 @@ export function verifyBundle(bundle: unknown, keySet: KeySet, expectedIssuer?: s
148213

149214
let payload: unknown;
150215
try {
151-
payload = JSON.parse(signingInput);
216+
// JSON.parse rounds integers above 2^53. Claims are read from this
217+
// object, so those tokens stay the signed decimal text.
218+
payload = parseJsonPreservingUnsafeIntegers(signingInput);
152219
} catch {
153220
return fail("malformed", "signing_input is not valid JSON", envKid);
154221
}
@@ -201,18 +268,13 @@ export function verifyBundle(bundle: unknown, keySet: KeySet, expectedIssuer?: s
201268
const envelopeCanon = b.canonicalization === undefined ? CANONICALIZATION : b.canonicalization;
202269
if (envelopeCanon !== expectedCanon) return fail("mismatch", "bundle canonicalization differs from the signed payload", kid);
203270

204-
if (typeof p.payload_hash === "string") {
205-
const inner: Record<string, unknown> = { ...p };
206-
delete inner.payload_hash;
207-
let canonical: string;
208-
try {
209-
canonical = canonicalJson(inner);
210-
} catch {
211-
return fail("malformed", "signed payload exceeds the supported JSON nesting depth", kid);
212-
}
213-
const computed = createHash("sha256").update(canonical, "utf8").digest("hex");
214-
if (computed !== p.payload_hash) return fail("mismatch", "payload_hash does not match the signed payload", kid);
215-
}
271+
// payload_hash is advisory and never recomputed here. The Ed25519 check
272+
// above already covers signing_input verbatim, payload_hash included, so
273+
// any edit to the signed bytes is invalid. Re-serializing the parsed
274+
// payload would reject genuine receipts: JSON numbers do not round-trip
275+
// across languages (Python signs 1.0 and 4e-05; this parser reads them as
276+
// 1 and 0.00004). Integers that do not fit in a JS number are returned as
277+
// the signed decimal text, not the rounded number.
216278
const receiptId = p.receipt_id;
217279
if (typeof receiptId !== "string" || !receiptId) return fail("malformed", "signed payload has no receipt_id", kid);
218280
if (b.receipt_id !== undefined && b.receipt_id !== null && b.receipt_id !== receiptId) return fail("mismatch", "bundle receipt_id does not match the signed receipt_id", kid, receiptId);

‎sdk/typescript/test/vectors.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,21 @@ for (const c of cases) {
1616
});
1717
}
1818

19+
test("float-bearing vector keeps the signed integer above 2^53", () => {
20+
const floatCase = cases.find((c: { name: string }) => c.name === "synthetic.float_payload_verifies");
21+
const result = verifyBundle(floatCase.bundle, keys);
22+
assert.equal(result.status, "verified");
23+
const guard = (
24+
result.claims?.constraints_evaluated as {
25+
jev_risk_guard: { big: unknown; latency_ms: unknown; injected_instructions: unknown; tiny: unknown };
26+
}
27+
).jev_risk_guard;
28+
assert.equal(guard.big, "9007199254740993");
29+
assert.equal(guard.latency_ms, 0);
30+
assert.equal(guard.injected_instructions, 1);
31+
assert.equal(guard.tiny, 4e-05);
32+
});
33+
1934
test("canonical JSON sorts keys and escapes non-ASCII", () => {
2035
assert.equal(canonicalJson({ b: 1, a: ["é", null] }), '{"a":["\\u00e9",null],"b":1}');
2136
});

0 commit comments

Comments
 (0)