feat(verify): TypeScript port, test vectors, historical trust epochs#22
Conversation
- Fix pyproject.toml: rename package to ainfera-verify, add entry point, fix broken stray README lines, bump version to 0.2.0 - Fix README.md: resolve merge conflict, remove placeholder Quick Start, document all new verification checks - Add sequence continuity check to verify_chain (gaps, duplicates) - Add receipts.py: receipt-to-inference relationship verification (event linkage, inference type, cost matching, savings arithmetic) - Add merkle.py: externally anchored Merkle root computation + verification - Add CLI commands: receipt (receipt-to-chain), merkle (external anchor) - Create TypeScript/browser-compatible port (typescript/): - chain.ts: canonicalJson, computeEventHash, verifyChain - signatures.ts: verifyHmac, verifyEd25519 (Web Crypto API) - merkle.ts: computeMerkleRoot, verifyMerkleAnchor - receipts.ts: verifyReceipt (receipt-to-inference) - crypto.ts: sha256, hmacSha256 (Web Crypto API) - 6 tests, all passing - Publish 9 canonical test vectors (test-vectors/): genesis-hash, canonical-json, event-hash-seq1, hmac-signature, three-event-chain, merkle-root-3-events, savings-arithmetic, broken-chain-detection, sequence-gap-detection - Document historical trust epochs (docs/trust-epochs.md): Epoch 0 (hash-only), Epoch 1 (HMAC), Epoch 2 (Ed25519), Epoch 2a (agent Ed25519), future epochs (Sigstore, Merkle anchor) - Remove hardcoded debug log path from fetch.py - Add test_vector validation suite (9 tests) - Add sequence_continuity tests (4 tests) - Add merkle tests (9 tests) - Add receipts tests (8 tests) - Total: 82 Python tests pass, 6 TypeScript tests pass - Offline bundle verification confirmed: 1247 events, zero network calls
|
Warning Review limit reached
Next review available in: 17 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (36)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| else: | ||
| errors.append("audit_event_ref is missing or has no event_hash/seq") | ||
| else: | ||
| warnings.append("no chain provided; skipping receipt-to-inference linkage") |
There was a problem hiding this comment.
Wrong warning on chain load
Medium Severity
When --chain is given but _load_chain_events fails, chain_events stays unset and the code still emits the warning that no chain was provided, alongside the load error. That misstates what happened and can send operators toward the wrong fix.
Reviewed by Cursor Bugbot for commit 8642d9b. Configure here.
| return ev | ||
| if seq is not None and ev.seq == seq: | ||
| return ev | ||
| return None |
There was a problem hiding this comment.
Receipt ref OR not AND
High Severity
_find_event returns the first event that matches event_hash or seq, not one event where both match. An audit_event_ref with a mismatched hash and seq can bind to the wrong inference event while cost and savings checks still run.
Reviewed by Cursor Bugbot for commit 8642d9b. Configure here.
| }; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
TS ignores API signature field
High Severity
verifyChain only checks Ed25519 when platform_signature is set. Live audit events expose the platform signature as signature, which Python maps in AuditEvent.from_dict, but the TypeScript path never reads that field, so Ed25519 chains can verify as OK without any signature check.
Reviewed by Cursor Bugbot for commit 8642d9b. Configure here.
| { name: "Ed25519" }, | ||
| false, | ||
| ["verify"], | ||
| ); |
There was a problem hiding this comment.
Ed25519 PEM import wrong format
High Severity
verifyEd25519 base64-decodes the full PEM body and imports it as a raw Ed25519 key. Published keys are SPKI PEM; Web Crypto raw import expects 32 bytes, so import fails and verification always returns false when Ed25519 is actually exercised.
Reviewed by Cursor Bugbot for commit 8642d9b. Configure here.
|
|
||
| import { webcrypto } from "node:crypto"; | ||
|
|
||
| const _subtle = webcrypto.subtle; |
There was a problem hiding this comment.
Node-only crypto breaks browser
Medium Severity
crypto.ts imports webcrypto from node:crypto, which bundlers and browsers cannot resolve. The README positions the TypeScript tree for in-browser verification, but this module ties hashing to Node unless a nonstandard alias is added.
Reviewed by Cursor Bugbot for commit 8642d9b. Configure here.
| if baseline_kind and baseline_kind in PROJECTED_COST_ALIASES: | ||
| errors.append("projected_cost is a budget estimate, not a savings baseline") | ||
| elif baseline_kind and baseline_kind not in ALLOWED_BASELINE_KINDS: | ||
| errors.append(f"unsupported baseline_kind: {baseline_kind}") |
There was a problem hiding this comment.
Missing baseline kind check
Medium Severity
The new receipt / verify_receipt path only rejects invalid baseline_kind when the field is present. Omitting baseline_kind does not fail, unlike verify_savings_receipt, which requires it.
Reviewed by Cursor Bugbot for commit 23f2174. Configure here.
|
|
||
| expectedPrevious = event.event_hash; | ||
| tipHash = event.event_hash; | ||
| } |
There was a problem hiding this comment.
TS skips unsigned event failure
High Severity
After hash checks, TypeScript verifyChain does not fail when key material is supplied but an event has neither a verified Ed25519 nor HMAC signature. Python returns event has no signature in that case.
Reviewed by Cursor Bugbot for commit 23f2174. Configure here.
|
Bugbot Autofix prepared fixes for all 5 issues found in the latest run.
Or push these changes by commenting: Preview (20ed2f8bbd)diff --git a/ainfera_verify/receipts.py b/ainfera_verify/receipts.py
--- a/ainfera_verify/receipts.py
+++ b/ainfera_verify/receipts.py
@@ -83,7 +83,11 @@
"""Find an event by event_hash or seq."""
for ev in events:
- if event_hash and ev.event_hash == event_hash:
+ if event_hash is not None and seq is not None:
+ if ev.event_hash == event_hash and ev.seq == seq:
+ return ev
+ continue
+ if event_hash is not None and ev.event_hash == event_hash:
return ev
if seq is not None and ev.seq == seq:
return ev
@@ -116,6 +120,7 @@
# --- Load chain events ---
chain_events: list[AuditEvent] | None = None
+ chain_provided = events is not None or chain_path is not None
if events is not None:
chain_events = events
elif chain_path is not None:
@@ -149,7 +154,7 @@
errors.append("audit_event_ref does not match any event in the chain")
else:
errors.append("audit_event_ref is missing or has no event_hash/seq")
- else:
+ elif not chain_provided:
warnings.append("no chain provided; skipping receipt-to-inference linkage")
# --- Verify event is an inference event ---
diff --git a/typescript/src/chain.ts b/typescript/src/chain.ts
--- a/typescript/src/chain.ts
+++ b/typescript/src/chain.ts
@@ -155,8 +155,17 @@
};
}
- // Signature verification
- const usesPlatformEd25519 = event.sig_alg === "ed25519" && event.platform_signature;
+ // Signature verification.
+ //
+ // The API exposes the platform Ed25519 signature under the top-level
+ // `signature` field (matching Python's `AuditEvent.from_dict`). We
+ // accept either that field or the explicit `platform_signature`.
+ const platformSignature =
+ event.platform_signature ??
+ (event.sig_alg === "ed25519"
+ ? (event.signature as string | undefined)
+ : undefined);
+ const usesPlatformEd25519 = event.sig_alg === "ed25519" && !!platformSignature;
if (usesPlatformEd25519) {
if (!platformPublicKeyPem) {
return {
@@ -170,7 +179,7 @@
}
const sigOk = await verifyEd25519(
event.event_hash,
- event.platform_signature!,
+ platformSignature!,
platformPublicKeyPem,
);
if (!sigOk) {
diff --git a/typescript/src/crypto.ts b/typescript/src/crypto.ts
--- a/typescript/src/crypto.ts
+++ b/typescript/src/crypto.ts
@@ -1,14 +1,13 @@
/**
* SHA-256 implementation using the Web Crypto API (browser + Node 18+).
*
- * In Node, uses crypto.subtle. In browsers, uses window.crypto.subtle.
- * Falls back to a pure-JS implementation if SubtleCrypto is unavailable.
+ * Uses the global `crypto.subtle`, which is available in modern browsers
+ * and Node.js 18+ without any Node-specific imports so bundlers can ship
+ * this module to the browser as-is.
*/
-import { webcrypto } from "node:crypto";
+const _subtle = globalThis.crypto.subtle;
-const _subtle = webcrypto.subtle;
-
export async function sha256(data: string): Promise<string> {
const buf = await _subtle.digest("SHA-256", new TextEncoder().encode(data));
return Array.from(new Uint8Array(buf))
diff --git a/typescript/src/signatures.ts b/typescript/src/signatures.ts
--- a/typescript/src/signatures.ts
+++ b/typescript/src/signatures.ts
@@ -24,11 +24,13 @@
publicKeyPem: string,
): Promise<boolean> {
try {
- // Extract raw public key from PEM
- const rawKey = extractRawKeyFromPem(publicKeyPem);
+ // Published Ed25519 platform keys are SPKI-encoded PEM. Web Crypto's
+ // `spki` import expects the DER SubjectPublicKeyInfo bytes decoded
+ // from the PEM body.
+ const spkiKey = spkiFromPem(publicKeyPem);
const cryptoKey = await crypto.subtle.importKey(
- "raw",
- rawKey,
+ "spki",
+ spkiKey,
{ name: "Ed25519" },
false,
["verify"],
@@ -51,8 +53,8 @@
return result === 0;
}
-/** Extract raw key bytes from a PEM-encoded Ed25519 public key. */
-function extractRawKeyFromPem(pem: string): Uint8Array {
+/** Decode the DER SPKI bytes from a PEM-encoded public key. */
+function spkiFromPem(pem: string): Uint8Array {
const b64 = pem
.replace(/-----BEGIN PUBLIC KEY-----/g, "")
.replace(/-----END PUBLIC KEY-----/g, "")
diff --git a/typescript/src/types.ts b/typescript/src/types.ts
--- a/typescript/src/types.ts
+++ b/typescript/src/types.ts
@@ -11,6 +11,8 @@
hmac_signature: string;
ed25519_signature?: string;
platform_signature?: string;
+ /** Platform Ed25519 signature as returned by the API (alias for platform_signature when sig_alg == "ed25519"). */
+ signature?: string;
sig_alg?: string;
payload_hash?: string;
sigstore_sig?: Record<string, unknown> | null;You can send follow-ups to the cloud agent here. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
There are 9 total unresolved issues (including 7 from previous reviews).
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Weaker receipt savings gates
- verify_receipt now delegates to verify_savings_receipt to enforce all fail-closed gates (receipt_id, baseline_kind, baseline_model, catalog_snapshot, etc.) before running chain linkage checks.
- ✅ Fixed: Skipped cost linkage check
- Both Python and TypeScript verifiers now emit an error when the matched event's cost_usd (or the receipt's actual_cost, in TS) is missing instead of silently skipping the comparison.
Or push these changes by commenting:
@cursor push b6b7bd9bc4
Preview (b6b7bd9bc4)
diff --git a/ainfera_verify/receipts.py b/ainfera_verify/receipts.py
--- a/ainfera_verify/receipts.py
+++ b/ainfera_verify/receipts.py
@@ -21,12 +21,9 @@
from ainfera_verify.chain import AuditEvent
from ainfera_verify.savings import (
- ALLOWED_BASELINE_KINDS,
- PROJECTED_COST_ALIASES,
SavingsReceiptError,
- _money,
- _nested,
load_savings_receipt,
+ verify_savings_receipt,
)
_MONEY = Decimal("0.000001")
@@ -106,12 +103,16 @@
- errors: list of failure reasons
"""
- errors: list[str] = []
- warnings: list[str] = []
-
receipt = load_savings_receipt(receipt_path)
- receipt_id = receipt.get("receipt_id")
+ # Apply all fail-closed savings-receipt gates first so `ainfera-verify receipt`
+ # is at least as strict as `ainfera-verify savings` (baseline_kind, baseline_model,
+ # catalog_snapshot, receipt_id, savings arithmetic, ...).
+ savings_result = verify_savings_receipt(receipt)
+ errors: list[str] = list(savings_result.errors)
+ warnings: list[str] = list(savings_result.warnings)
+ receipt_id = savings_result.receipt_id
+
# --- Load chain events ---
chain_events: list[AuditEvent] | None = None
if events is not None:
@@ -151,6 +152,8 @@
warnings.append("no chain provided; skipping receipt-to-inference linkage")
# --- Verify event is an inference event ---
+ provider: str | None = None
+ model: str | None = None
if matched is not None:
if matched.event_type != "inference.routed":
errors.append(f"referenced event is not inference.routed (got {matched.event_type})")
@@ -160,6 +163,8 @@
event_cost = payload.get("cost_usd")
actual_cost_raw = receipt.get("actual_cost") or receipt.get("actual_cost_usd")
+ if event_cost is None:
+ errors.append("referenced event payload is missing cost_usd")
if event_cost is not None and actual_cost_raw is not None:
try:
event_cost_dec = Decimal(str(event_cost)).quantize(_MONEY)
@@ -171,54 +176,19 @@
except (InvalidOperation, ValueError):
errors.append("cannot compare actual_cost with event cost_usd")
- # Extract provider/model for the result
provider = payload.get("provider")
model = payload.get("model")
- else:
- provider = None
- model = None
- # --- Verify savings arithmetic ---
- actual_cost = _money(
- receipt.get("actual_cost") or receipt.get("actual_cost_usd"),
- "actual_cost",
- errors,
- )
- baseline_cost = _money(
- _nested(receipt, "baseline_cost") or _nested(receipt, "baseline_cost_usd"),
- "baseline_cost",
- errors,
- )
- savings_claimed = _money(
- receipt.get("savings") or receipt.get("savings_usd") or receipt.get("savings_proven_usd"),
- "savings",
- errors,
- )
-
- # Baseline kind validation
- baseline_kind_raw = _nested(receipt, "baseline_kind") or _nested(receipt, "kind")
- baseline_kind = str(baseline_kind_raw) if baseline_kind_raw is not None else None
- if baseline_kind and baseline_kind in PROJECTED_COST_ALIASES:
- errors.append("projected_cost is a budget estimate, not a savings baseline")
- elif baseline_kind and baseline_kind not in ALLOWED_BASELINE_KINDS:
- errors.append(f"unsupported baseline_kind: {baseline_kind}")
-
- expected_savings: Decimal | None = None
- if actual_cost is not None and baseline_cost is not None:
- expected_savings = max(Decimal("0"), baseline_cost - actual_cost).quantize(_MONEY)
- if savings_claimed is not None and savings_claimed != expected_savings:
- errors.append(f"savings mismatch: expected {expected_savings}, got {savings_claimed}")
-
return ReceiptVerifyResult(
ok=not errors,
- receipt_id=str(receipt_id) if receipt_id else None,
+ receipt_id=receipt_id,
matched_event=matched,
provider=provider,
model=model,
- actual_cost=actual_cost,
- baseline_cost=baseline_cost,
- savings_claimed=savings_claimed,
- savings_expected=expected_savings,
+ actual_cost=savings_result.actual_cost,
+ baseline_cost=savings_result.baseline_cost,
+ savings_claimed=savings_result.savings_claimed,
+ savings_expected=savings_result.savings_expected,
errors=errors,
warnings=warnings,
)
diff --git a/typescript/src/receipts.ts b/typescript/src/receipts.ts
--- a/typescript/src/receipts.ts
+++ b/typescript/src/receipts.ts
@@ -61,7 +61,18 @@
const eventCost = payload["cost_usd"];
const actualCostRaw = receipt["actual_cost"] ?? receipt["actual_cost_usd"];
- if (eventCost !== undefined && actualCostRaw !== undefined) {
+ if (eventCost === undefined || eventCost === null) {
+ errors.push("referenced event payload is missing cost_usd");
+ }
+ if (actualCostRaw === undefined || actualCostRaw === null) {
+ errors.push("receipt is missing actual_cost");
+ }
+ if (
+ eventCost !== undefined &&
+ eventCost !== null &&
+ actualCostRaw !== undefined &&
+ actualCostRaw !== null
+ ) {
const eventCostNum = Number(eventCost);
const actualNum = Number(actualCostRaw);
if (!isNaN(eventCostNum) && !isNaN(actualNum) && eventCostNum !== actualNum) {You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit be0ef37. Configure here.
| if actual_cost is not None and baseline_cost is not None: | ||
| expected_savings = max(Decimal("0"), baseline_cost - actual_cost).quantize(_MONEY) | ||
| if savings_claimed is not None and savings_claimed != expected_savings: | ||
| errors.append(f"savings mismatch: expected {expected_savings}, got {savings_claimed}") |
There was a problem hiding this comment.
Weaker receipt savings gates
Medium Severity
verify_receipt omits several fail-closed checks that verify_savings_receipt enforces, including required baseline_kind, baseline_model, catalog_snapshot, and receipt_id. Receipts missing those fields can pass ainfera-verify receipt while failing ainfera-verify savings.
Reviewed by Cursor Bugbot for commit be0ef37. Configure here.
| f"actual_cost ({actual_dec}) != event cost_usd ({event_cost_dec})" | ||
| ) | ||
| except (InvalidOperation, ValueError): | ||
| errors.append("cannot compare actual_cost with event cost_usd") |
There was a problem hiding this comment.
Skipped cost linkage check
Medium Severity
Receipt-to-inference verification only compares actual_cost to cost_usd when both values are present. If the matched event omits cost_usd or the receipt omits actual_cost, the cost linkage step is skipped and verification can still succeed.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit be0ef37. Configure here.



Summary
Expands
verifyinto the canonical independent verification CLI and library per ADR 2026-07-11-github-org-audit-restructure.Changes
Package + CLI
ainfera-verify(wasverify)pyproject.toml(stray README lines, missing entry point)fetch.pyreceipt(receipt-to-chain),merkle(external anchor)New verification checks
verify_chainnow checks for gaps and duplicates in seq values (contiguous from 1)receipts.pyverifies receipt links to a realinference.routedevent, cost matching, and savings arithmeticmerkle.pycomputes Merkle root over event hashes and verifies against external anchorsTypeScript/browser port (
typescript/)chain.ts: canonicalJson, computeEventHash, verifyChain (with sequence continuity + HMAC)signatures.ts: verifyHmac, verifyEd25519 (Web Crypto API)merkle.ts: computeMerkleRoot, verifyMerkleAnchorreceipts.ts: verifyReceipt (receipt-to-inference)crypto.ts: sha256, hmacSha256 (Web Crypto API)Test vectors (
test-vectors/)9 canonical vectors with known-good hashes:
Historical trust epochs (
docs/trust-epochs.md)Documents all signature epochs: Epoch 0 (hash-only), Epoch 1 (HMAC), Epoch 2 (Ed25519), Epoch 2a (agent Ed25519), future (Sigstore, Merkle anchor).
Test results
Acceptance criteria
Note
Medium Risk
Touches cryptographic verification semantics (sequence checks, Merkle tree, receipt–chain linkage) and dual Python/TS implementations; regressions could falsely pass or fail audits, though broad tests and published vectors mitigate this.
Overview
Ships ainfera-verify 0.2.0 as the canonical offline verifier: package rename and
pyproject.tomlfix (entry point, deps), README merge cleanup, and removal of debug logging fromfetch.py.Chain verification gains default sequence continuity (
seq1…n, no gaps/duplicates) inverify_chain, plus newmerkleandreceiptCLI commands backed bymerkle.py(Merkle root vs external anchor) andreceipts.py(link savings receipts toinference.routedevents, cost match, savings math).Adds a browser/Node TypeScript port under
typescript/mirroring chain, HMAC/Ed25519, Merkle, and receipt checks, with tests driven by sharedtest-vectors/(9 JSON fixtures + generator).docs/trust-epochs.mddocuments signature epochs and Merkle anchoring status.Reviewed by Cursor Bugbot for commit be0ef37. Bugbot is set up for automated code reviews on this repo. Configure here.