@geoclear/verify-receipt
v1.2.1
Published
Verify GeoClear signed receipts and Pattern 4B hybrid Evidence Bundles (ECDSA P-384 + ML-DSA-87 / FIPS 204) — offline, no network, any auditor.
Maintainers
Readme
@geoclear/verify-receipt
Verify a GeoClear signed receipt in any language, offline, without calling GeoClear servers. Proves to a regulator years from now exactly what GeoClear returned at exactly what moment.
Status (2026-05-05): PUBLISHED. @geoclear/[email protected] is live
on npm under MIT license. Source mirrored in this repo for transparency; npm is
the authoritative distribution. Install with npm install @geoclear/verify-receipt.
See https://www.npmjs.com/package/@geoclear/verify-receipt.
For the broader MCP / Receipts / Notary architecture and how this verifier fits in, see the developer overview. This README is verifier-specific; the overview is the conceptual map.
What a signed receipt is
Every GeoClear API response (JSON) comes with an X-GeoClear-Receipt header.
That header value is a JWS (RFC 7515)
with this payload:
{
"iss": "https://geoclear.io",
"iat": 1745370001,
"sub": "req_...",
"api_version": "v1",
"endpoint": "/v1/risk",
"req_hash": "sha256:...",
"resp_hash": "sha256:...",
"status": 200
}Signed with ECDSA on curve P-384 (alg: ES384), key held in AWS KMS, public
key distributed at https://geoclear.io/.well-known/jwks.json.
What it attests to
A signed receipt proves, cryptographically, what GeoClear returned at what moment. It does not attest to the ground-truth correctness of the underlying flood map, census tract, or climate model. If FEMA updates a flood-zone boundary six months from now, your old receipt is still valid — it accurately records what GeoClear returned on the day you asked.
This distinction is non-negotiable. We're in the attestation-of-our- statements business, not the insurance-of-ground-truth business.
Node.js
// Requires: npm i jose
const { jwtVerify, createRemoteJWKSet } = require('jose');
const crypto = require('crypto');
const JWKS = createRemoteJWKSet(new URL('https://geoclear.io/.well-known/jwks.json'));
function canonicalize(v) {
if (v === null || typeof v !== 'object') return JSON.stringify(v);
if (Array.isArray(v)) return '[' + v.map(canonicalize).join(',') + ']';
const keys = Object.keys(v).sort();
return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalize(v[k])).join(',') + '}';
}
const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
async function verifyGeoClearReceipt({ receipt, requestMethod, requestPath, requestBody, responseBody }) {
const { payload } = await jwtVerify(receipt, JWKS, {
issuer: 'https://geoclear.io',
algorithms: ['ES384'],
});
const reqRepr = `${requestMethod} ${requestPath}\n${requestBody ? canonicalize(requestBody) : ''}`;
const reqHash = 'sha256:' + sha256(reqRepr);
const respHash = 'sha256:' + sha256(canonicalize(responseBody));
if (payload.req_hash !== reqHash) throw new Error('request was tampered or differs from what GeoClear received');
if (payload.resp_hash !== respHash) throw new Error('response was tampered');
return payload; // includes iat (timestamp), sub (request ID), status
}
module.exports = { verifyGeoClearReceipt };Python
# Requires: pip install pyjwt cryptography requests
import hashlib, json, requests, jwt
from jwt.algorithms import ECAlgorithm
def canonicalize(v):
return json.dumps(v, sort_keys=True, separators=(',', ':'))
def sha256(s):
return hashlib.sha256(s.encode() if isinstance(s, str) else s).hexdigest()
def verify_geoclear_receipt(receipt, request_method, request_path, request_body, response_body):
jwks = requests.get('https://geoclear.io/.well-known/jwks.json').json()
header = jwt.get_unverified_header(receipt)
key_jwk = next(k for k in jwks['keys'] if k['kid'] == header['kid'])
key = ECAlgorithm.from_jwk(json.dumps(key_jwk))
claims = jwt.decode(receipt, key=key, algorithms=['ES384'], issuer='https://geoclear.io')
req_repr = f"{request_method} {request_path}\n{canonicalize(request_body) if request_body else ''}"
req_hash = 'sha256:' + sha256(req_repr)
resp_hash = 'sha256:' + sha256(canonicalize(response_body))
assert claims['req_hash'] == req_hash, 'request was tampered or differs from what GeoClear received'
assert claims['resp_hash'] == resp_hash, 'response was tampered'
return claimsBrowser (vanilla JS, no dependencies besides jose)
<script type="module">
import { jwtVerify, createRemoteJWKSet } from 'https://esm.sh/jose@5';
const JWKS = createRemoteJWKSet(new URL('https://geoclear.io/.well-known/jwks.json'));
async function sha256Hex(str) {
const buf = new TextEncoder().encode(str);
const hash = await crypto.subtle.digest('SHA-256', buf);
return [...new Uint8Array(hash)].map(b => b.toString(16).padStart(2, '0')).join('');
}
function canonicalize(v) {
if (v === null || typeof v !== 'object') return JSON.stringify(v);
if (Array.isArray(v)) return '[' + v.map(canonicalize).join(',') + ']';
return '{' + Object.keys(v).sort().map(k => JSON.stringify(k) + ':' + canonicalize(v[k])).join(',') + '}';
}
async function verify(receipt, method, path, reqBody, respBody) {
const { payload } = await jwtVerify(receipt, JWKS, {
issuer: 'https://geoclear.io', algorithms: ['ES384'],
});
const reqRepr = `${method} ${path}\n${reqBody ? canonicalize(reqBody) : ''}`;
const reqHash = 'sha256:' + await sha256Hex(reqRepr);
const respHash = 'sha256:' + await sha256Hex(canonicalize(respBody));
if (payload.req_hash !== reqHash) throw new Error('request tampered');
if (payload.resp_hash !== respHash) throw new Error('response tampered');
return payload;
}
</script>Future language support
- Go: planned (
go get github.com/shaileshjgd/geoclear-verify-receipt) - Rust: planned (
cargo add geoclear-verify-receipt) - Ruby: nice-to-have
- Java: nice-to-have
PRs welcome — see the contribution guide if present, or file an issue first to discuss substantive changes.
License
MIT. Published as @geoclear/verify-receipt on npm.
See https://www.npmjs.com/package/@geoclear/verify-receipt.
v1.2.0 — Pattern 4B hybrid bundle verification (Q-1284, EPIC-2026-0041)
Adds verifyBundle() for the Pattern 4B hybrid Evidence Bundle — a ZIP that
combines the legacy ECDSA P-384 JWS with a post-quantum ML-DSA-87 (FIPS 204)
signature sibling artifact. The two signatures cover the SAME canonical bytes
and are cryptographically anchored to each other via the JWS payload's
pqc_pubkey_fingerprint claim (the "ECDSA-chain anchor"). Per CNSA 2.0
security category 5; ML-DSA-87 is the parameter set aligned with NSA CNSA 2.0 for NSS workloads
for National Security Systems.
CLI usage
npm install -g @geoclear/verify-receipt
geoclear-verify-receipt ./evidence-bundle.zipExpected output — VALID hybrid bundle
GeoClear Receipt Verifier v1.2.0
Bundle: /path/to/evidence-bundle.zip
[OK] Canonical payload bytes hash: e3b0c44298fc1c149afbf4c8996fb924...
[OK] ECDSA P-384 verified via KMS HSM anchor
[OK] ECDSA chain anchors ML-DSA-87 public key fingerprint
[OK] ML-DSA-87 signature verified under CNSA 2.0-aligned federal crypto profile
Standard: NIST FIPS 204 algorithm · NIST security category 5 · Key lifecycle: ephemeral-per-enclave-instance
Bundle VALID. Verification completed in 47ms.
Mode: hybrid-ecdsa-p384-mldsa-87
Issuer: https://geoclear.io
ML-DSA-87 pubkey fingerprint: sha384:7908c2b841764c4f4ba2b7f125e8ab8e...Negative-case rehearsal — what tamper detection looks like
The verifier produces a specific terminal output for each of the three known tamper attack vectors. APL / NIST / FIPS auditors typically ask to see these LIVE — practice them before the demo.
Case (a) — modified canonical payload (response body bytes changed)
When the canonical body bytes in the bundle don't match the JWS payload's
resp_hash claim, the ECDSA verification at step 1 fails immediately:
GeoClear Receipt Verifier v1.2.0
Bundle: /path/to/tampered-payload.zip
[FAIL] ECDSA P-384 verification failed via KMS HSM anchor
ECDSA_VERIFY_FAILED: signature verification failed (signature mismatch)
Bundle INVALID. ECDSA chain broken.
Exit code: 1 (ECDSA_VERIFY_FAILED)To rehearse: open the bundle, edit a byte in payload/canonical-payload.bytes,
re-zip, run the verifier. The first [FAIL] arrives in <50ms — the chain
broke at the foundational layer.
Case (b) — modified ML-DSA-87 public key (downgrade / swap attack)
If an attacker swaps signature/mldsa-pubkey.json for a key they control
(hoping the ML-DSA signature is also theirs), the sha384 fingerprint they
recompute won't match the one the ECDSA chain anchors:
GeoClear Receipt Verifier v1.2.0
Bundle: /path/to/swapped-pubkey.zip
[OK] ECDSA P-384 verified via KMS HSM anchor
[FAIL] ECDSA chain does not anchor ML-DSA-87 public key fingerprint
ECDSA_CHAIN_FINGERPRINT_MISMATCH: ECDSA chain does not anchor ML-DSA public key. Expected (from JWS payload claim): 7908c2b841764c4f4ba2b7f1... Got (recomputed from signature/mldsa-pubkey.json): a3f1d92e6c5b4783b9e0f4a8... Possible downgrade/swap attack on the ML-DSA pubkey.
Bundle INVALID. Possible downgrade attack on the ML-DSA pubkey.
Exit code: 2 (ECDSA_CHAIN_FINGERPRINT_MISMATCH)To rehearse: open the bundle, swap signature/mldsa-pubkey.json with another
bundle's mldsa-pubkey.json (different enclave instance), re-zip, run. Step 1
passes (ECDSA didn't change), step 3 fails (fingerprints diverge). This is
the binding pattern's core defense — the ECDSA chain notarizes the ML-DSA
public key by reference (fingerprint), so swapping the key breaks the chain.
Case (c) — modified ML-DSA-87 signature (post-quantum tamper)
If signature/mldsa.sig is altered (or the signed input is altered), step 1
- step 3 pass (ECDSA chain + binding both intact) but step 4 fails:
GeoClear Receipt Verifier v1.2.0
Bundle: /path/to/tampered-mldsa-sig.zip
[OK] ECDSA P-384 verified via KMS HSM anchor
[OK] ECDSA chain anchors ML-DSA-87 public key fingerprint
[FAIL] ML-DSA-87 signature does not verify under CNSA 2.0-aligned federal crypto profile
ML_DSA_SIGNATURE_INVALID: ML-DSA-87 signature does not verify against the public key + signed-input bytes in the bundle. Possible tamper on signature/mldsa.sig or signature/mldsa-signed-input.bytes.
Bundle INVALID. Quantum-Resistant Overlay verification failed.
Exit code: 3 (ML_DSA_SIGNATURE_INVALID)To rehearse: flip a single byte in signature/mldsa.sig, re-zip, run. ECDSA
chain is undisturbed (passes step 1 + step 3), but the ML-DSA-87 verifier
catches the byte flip. This is the "harvest now, decrypt later" defense —
even an attacker with a quantum computer cannot forge a new ML-DSA-87 signature
without the ephemeral secret key that lived only in the Nitro Enclave's RAM.
Wire-format details
Bundle layout for Pattern 4B (additive to existing pre-Q-1284 layout):
evidence-bundle.zip
├── README.md
├── receipt/
│ └── receipt.jws ← ECDSA P-384 JWS (unchanged from pre-v1.2)
├── payload/
│ ├── canonical-payload.bytes ← response body canonicalized (unchanged)
│ ├── canonical-payload.json
│ └── canonical-payload.sha256
├── signature/ ← NEW in v1.2 (when pqc_overlay active)
│ ├── mldsa.sig ← raw 4627-byte ML-DSA-87 signature
│ ├── mldsa-pubkey.json ← ephemeral pubkey descriptor (2592-byte pubkey base64)
│ └── mldsa-signed-input.bytes ← exact bytes both signatures cover
├── trust/
│ ├── jwks-snapshot.json
│ ├── key-fingerprint.txt
│ └── trust-anchor.txt
├── verify/
│ └── offline-verifier.html
└── audit/
└── verification-report.json ← includes pqc_overlay block when hybridPre-v1.2 bundles (no signature/mldsa.sig) verify cleanly via the
ecdsa-only mode — back-compat preserved.
Programmatic API
const { verifyBundle } = require('@geoclear/verify-receipt');
const fs = require('node:fs');
const zip = fs.readFileSync('./evidence-bundle.zip');
const result = await verifyBundle({ zip });
if (result.mode === 'hybrid-ecdsa-p384-mldsa-87') {
console.log('Hybrid PQC verified:', result.pqc_overlay);
}On failure, verifyBundle() throws an Error with a specific .code field
(ECDSA_VERIFY_FAILED, ECDSA_CHAIN_FINGERPRINT_MISMATCH, ML_DSA_SIGNATURE_INVALID,
or ML_DSA_VERIFY_THREW) so callers can distinguish tamper modes.
