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
6 changes: 3 additions & 3 deletions gateway/tests/test_receipt_portability.py
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,7 @@ def _hashed_payload(payload):
return {**body, "payload_hash": sha256_hex(body)}


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

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

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


def test_signature_under_a_key_the_payload_does_not_name_is_rejected():
Expand Down
5 changes: 4 additions & 1 deletion gateway/tests/test_spec_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,10 @@ def test_valid_vectors_have_documented_payloads(self):
payload = json.loads(case["bundle"]["signing_input"])
assert RECEIPT_ALWAYS_SIGNED <= set(payload), case["name"]
assert set(payload) <= RECEIPT_DOCUMENTED | RECEIPT_ADDITIVE, case["name"]
assert _recompute_payload_hash(payload), case["name"]
if case["name"] == "synthetic.payload_hash_mismatch":
assert not _recompute_payload_hash(payload), case["name"]
else:
assert _recompute_payload_hash(payload), case["name"]
assert payload["outcome"] in SPEC_OUTCOMES, case["name"]
assert re.fullmatch(r"\d+(\.\d+)?", payload["credits_authorized"])
assert re.fullmatch(r"\d+(\.\d+)?", payload["credits_charged"])
Expand Down
33 changes: 10 additions & 23 deletions sdk/python/src/b2a_sdk/receipt_verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@

import base64
import binascii
import hashlib
import json
from dataclasses import dataclass, field
from enum import Enum
Expand Down Expand Up @@ -141,9 +140,10 @@ def canonical_json(payload: Any) -> str:
"""Serialize already-decoded JSON the way the trust plane signs it.

The rules are: keys sorted at every level, no insignificant whitespace,
and non-ASCII escaped. Numbers and timestamps are already strings by the
time they reach a signed payload, so no numeric formatting rules are
needed here — which is what makes this reimplementable in any language.
and non-ASCII escaped. Signed payloads may carry JSON numbers (floats,
exponents, large integers), whose rendering differs between languages,
so this helper is for *signing* new payloads only. Verification never
re-serializes the signed bytes: the signature covers them verbatim.
"""
return json.dumps(
payload,
Expand Down Expand Up @@ -515,25 +515,12 @@ def verify_bundle(
key_id=key_id,
)

stated_hash = payload.get("payload_hash")
if isinstance(stated_hash, str):
inner = {k: v for k, v in payload.items() if k != "payload_hash"}
try:
canonical = canonical_json(inner)
except (RecursionError, ValueError):
return _fail(
VerificationStatus.MALFORMED,
"signed payload exceeds the supported JSON nesting depth",
key_id=key_id,
)
computed = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
if computed != stated_hash:
return _fail(
VerificationStatus.MISMATCH,
"payload_hash does not match the signed payload",
key_id=key_id,
)

# payload_hash is advisory and never recomputed here. The Ed25519 check
# above already covers signing_input verbatim, payload_hash included, so
# any edit to the signed bytes is INVALID. Re-serializing the parsed
# payload would reject genuine receipts: JSON numbers do not round-trip
# across languages (the gateway signs floats such as 1.0 and 4e-05, and
# integers above 2^53 lose precision in some parsers).
receipt_id = payload.get("receipt_id")
if not isinstance(receipt_id, str) or not receipt_id:
return _fail(
Expand Down
40 changes: 40 additions & 0 deletions sdk/python/tests/test_receipt_verifier_signed.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,46 @@ def test_payload_without_payload_hash_verifies():
assert result.status is VerificationStatus.VERIFIED, result.reason


def test_signed_payload_hash_is_advisory():
"""A wrong but signed payload_hash still verifies; the signature covers it."""
kid, key_set, sign = _local_signer()
payload = _payload(kid=kid)
payload["payload_hash"] = "0" * 64

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

assert result.status is VerificationStatus.VERIFIED, result.reason


def test_editing_payload_hash_without_resigning_is_invalid():
bundle, key_set = _verified_bundle()
good = json.loads(bundle["signing_input"])["payload_hash"]

result = verify_bundle(_retarget(bundle, good, "0" * 64), key_set)

assert result.status is VerificationStatus.INVALID
assert result.is_tampered


def test_float_bearing_payload_verifies_without_reserialization():
"""Floats the gateway signs (1.0, 0.0) verify; numbers are never rechecked."""
kid, key_set, sign = _local_signer()
payload = _payload(kid=kid)
del payload["payload_hash"]
payload["constraints_evaluated"] = {
"jev_risk_guard": {"latency_ms": 0.0, "injected_instructions": 1.0}
}
inner = {k: v for k, v in payload.items() if k != "payload_hash"}
payload["payload_hash"] = hashlib.sha256(canonical_json(inner).encode("utf-8")).hexdigest()
bundle = _signed_bundle(payload, kid=kid, sign=sign)
assert '"latency_ms":0.0' in bundle["signing_input"]
assert '"injected_instructions":1.0' in bundle["signing_input"]

result = verify_bundle(bundle, key_set)

assert result.status is VerificationStatus.VERIFIED, result.reason


def test_extra_envelope_fields_are_ignored():
bundle, key_set = _verified_bundle()
bundle = {**bundle, "keys_url": "/.well-known/trust-keys.json", "future_field": {"n": 1}}
Expand Down
90 changes: 76 additions & 14 deletions sdk/typescript/src/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*
* Licensed under Apache-2.0.
*/
import { createHash, createPublicKey, verify as edVerify } from "node:crypto";
import { createPublicKey, verify as edVerify } from "node:crypto";

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

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

Expand Down Expand Up @@ -118,6 +123,66 @@ function hasLoneSurrogate(value: unknown): boolean {
return false;
}

const PLAIN_INTEGER = /^-?(?:0|[1-9]\d*)$/;
const JSON_NUMBER = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;

/**
* True when a JSON integer token is not the number JSON.parse would return.
* 9007199254740993 becomes 9007199254740992; the signed digits must be kept.
*/
function integerLexemeRounds(lexeme: string): boolean {
if (!PLAIN_INTEGER.test(lexeme)) return false;
const digits = lexeme.startsWith("-") ? lexeme.slice(1) : lexeme;
// A finite double stops near 1e308. Longer tokens are not that number, and
// they do not need a BigInt the size of the token.
if (digits.length > 400) return true;
const asNumber = Number(lexeme);
if (!Number.isFinite(asNumber)) return true;
return BigInt(lexeme) !== BigInt(asNumber);
}

/**
* Parse JSON, quoting integer tokens that a JS number would round.
* Strings are copied through, so digits inside a string stay a string.
*/
function parseJsonPreservingUnsafeIntegers(text: string): unknown {
let out = "";
let i = 0;
while (i < text.length) {
const c = text[i]!;
if (c === '"') {
const start = i;
i += 1;
while (i < text.length) {
if (text[i] === "\\") {
i += 2;
continue;
}
if (text[i] === '"') {
i += 1;
break;
}
i += 1;
}
out += text.slice(start, i);
continue;
}
if (c === "-" || (c >= "0" && c <= "9")) {
JSON_NUMBER.lastIndex = i;
const match = JSON_NUMBER.exec(text);
if (match && match.index === i) {
const lexeme = match[0];
i += lexeme.length;
out += integerLexemeRounds(lexeme) ? JSON.stringify(lexeme) : lexeme;
continue;
}
}
out += c;
i += 1;
}
return JSON.parse(out);
}

function edOk(raw: Buffer, sig: Buffer, msg: Buffer): boolean {
try {
const key = createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: raw.toString("base64url") }, format: "jwk" });
Expand Down Expand Up @@ -148,7 +213,9 @@ export function verifyBundle(bundle: unknown, keySet: KeySet, expectedIssuer?: s

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

if (typeof p.payload_hash === "string") {
const inner: Record<string, unknown> = { ...p };
delete inner.payload_hash;
let canonical: string;
try {
canonical = canonicalJson(inner);
} catch {
return fail("malformed", "signed payload exceeds the supported JSON nesting depth", kid);
}
const computed = createHash("sha256").update(canonical, "utf8").digest("hex");
if (computed !== p.payload_hash) return fail("mismatch", "payload_hash does not match the signed payload", kid);
}
// payload_hash is advisory and never recomputed here. The Ed25519 check
// above already covers signing_input verbatim, payload_hash included, so
// any edit to the signed bytes is invalid. Re-serializing the parsed
// payload would reject genuine receipts: JSON numbers do not round-trip
// across languages (Python signs 1.0 and 4e-05; this parser reads them as
// 1 and 0.00004). Integers that do not fit in a JS number are returned as
// the signed decimal text, not the rounded number.
const receiptId = p.receipt_id;
if (typeof receiptId !== "string" || !receiptId) return fail("malformed", "signed payload has no receipt_id", kid);
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);
Expand Down
15 changes: 15 additions & 0 deletions sdk/typescript/test/vectors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ for (const c of cases) {
});
}

test("float-bearing vector keeps the signed integer above 2^53", () => {
const floatCase = cases.find((c: { name: string }) => c.name === "synthetic.float_payload_verifies");
const result = verifyBundle(floatCase.bundle, keys);
assert.equal(result.status, "verified");
const guard = (
result.claims?.constraints_evaluated as {
jev_risk_guard: { big: unknown; latency_ms: unknown; injected_instructions: unknown; tiny: unknown };
}
).jev_risk_guard;
assert.equal(guard.big, "9007199254740993");
assert.equal(guard.latency_ms, 0);
assert.equal(guard.injected_instructions, 1);
assert.equal(guard.tiny, 4e-05);
});

test("canonical JSON sorts keys and escapes non-ASCII", () => {
assert.equal(canonicalJson({ b: 1, a: ["é", null] }), '{"a":["\\u00e9",null],"b":1}');
});
Expand Down
Loading
Loading