Verify a receipt
A consent receipt is meant to be checkable by anyone, with no trust in Agreely. This page is the field-level recipe; for a runnable walkthrough see verify a receipt yourself.
Try it yourself
Download a genuine company-attested receipt and its issuer DID document, extracted from the SDK's golden vectors, then verify the receipt offline with the CLI: no network, no API key.
- sample-receipt.json: the company-attested receipt
- sample-did.json: the issuer DID document
agreely verify sample-receipt.json --did-doc sample-did.json
✓ VERIFIED (company_attested)
companySignature pass
citizenAssertion unsupported
disclosureCopy skipped
documentAnchor skipped
cellLabelBinding pass
· Company signature verified against issuer DID did:web:api.agreely.ca:c:acme (key did:web:api.agreely.ca:c:acme#kms-1).
· This proves the company ATTESTED to a hand-signed PDF (hash 0xabababababababababababababababababababababababababababababababab); it does NOT prove a human signed.
· A company-attested receipt carries no citizen passkey assertion, so citizenAssertion is not applicable.
· Cell labels bound: the category/purpose labels sit inside the company-signed body, so the verified signature covers them. Mutating any item label breaks it.
· disclosureCopy skipped: the receipt carries no document.ipfsCid + disclosureHash pair to fetch and compare.
· documentAnchor skipped: pass an rpcUrl to check the on-chain document anchor.
The cellLabelBinding row is the one field to read before trusting a displayed
label: here it is pass, because the labels sit inside the company-signed body.
The receipt contains demo data only (the fictional company "Acme").
What you need
To verify one cell end to end, a verifier holds:
- the receipt VC (the consent receipt);
- the original offer object (to check
companySig, which signed the offer); - the cell's envelope: its 32-byte
saltand its Merkle inclusion proof (disclosed by Agreely; the salt is destroyed on erasure); - the anchored Merkle root (from the receipt's consent, cross-checkable on the on-chain registry);
- the two DID documents (resolved independently).
The four checks
1. Recompute the commitment from the claim and salt
Rebuild the frozen claim from the receipt fields, JCS-canonicalize it, raw-concat the salt, and keccak256. It must equal the stored commitment.
claim = {
"issuer": receipt.issuer,
"subject": receipt.credentialSubject.id,
"item": { itemId, category, purpose }, // from receipt.credentialSubject.consent.items[i]
"grantedAt": receipt.credentialSubject.consent.grantedAt
}
commitment == keccak256( JCS(claim) || salt )
2. Verify Merkle inclusion against the root
Take leaf = keccak256(commitment) and fold it with the proof siblings using the
commutative pairing. The result must equal the consent's Merkle root, which is
the value anchored on-chain.
leaf = keccak256(commitment)
fold(leaf, proof) == merkleRoot // keccak256(min||max) at each step
3. Verify companySig against the company DID key
Resolve the company DID document, decode
its publicKeyMultibase to the raw Ed25519 key, and verify proof[0].proofValue
(decoded from base58btc) against the JCS bytes of the offer minus its envelope
fields (companySig, status, result).
pubKey = decodeEd25519( companyDoc.verificationMethod[0].publicKeyMultibase )
ed25519_verify( decodeBytes(proof[0].proofValue), JCS(offer\{companySig,status,result}), pubKey )
4. Verify the WebAuthn assertion against the key valid at grantedAt
Resolve the citizen DID document. Find the
verification method whose id ends with proof[1].verificationMethod's fragment,
confirm its [validFrom, validUntil] window contains grantedAt, and verify the
assertion (authenticatorData, clientDataJSON, signature) against that
method's COSE key. The embedded clientDataJSON.challenge must equal
proof[1].challenge.
method = citizenDoc.verificationMethod[ ends-with proof[1].verificationMethod fragment ]
assert method.validFrom <= grantedAt and (method.validUntil is null or grantedAt <= method.validUntil)
webauthn_verify( proof[1], method.publicKeyCose )
Why these four, and nothing about Agreely
Check 1 proves the claim is exactly what was committed. Check 2 proves that commitment is in the consent that was anchored. Check 3 proves the company made the offer. Check 4 proves the person approved it, with a key the DID document says was theirs at the time. Every input is a published artifact or an independent resolution; none of it asks you to take Agreely's word.
The honest verification boundary
The four checks above assume you were handed the original offer and the cell's envelope. What you can verify from the receipt alone, offline, depends on its variant.
A company-attested receipt is fully verifiable offline: its company Ed25519
signature verifies against the resolved company did:web key. There is no citizen
half to check.
A citizen receipt is only partially verifiable offline:
- The citizen passkey assertion is verifiable offline. It proves passkey possession over the committed keccak challenge, bound to the Merkle root. It does not prove cell-level consent semantics.
- The company-signature half is not soundly verifiable offline on a citizen
receipt. The company signed the original offer, which includes the subject
reference and the full disclosure, and the receipt omits those for
unlinkability. A sound company-signature check needs a server
receipts/verifyendpoint, which does not exist yet.
"Unavailable" is not "failed"
Always distinguish unavailable from failed. A DID document that did not resolve is unavailable: the result is inconclusive, not a forgery. An active cryptographic failure (a signature that does not verify against the right key) is failed: it is tamper or a wrong key. Never report one as the other.
This matrix is implemented by
verifying a receipt in TypeScript,
verifying a receipt in PHP, and the
CLI verify command.
PHP reference
This mirrors Agreely's own integration test, which performs exactly these checks.
use App\Models\Crypto\{Canonicalizer, Commitment, Keccak, Merkle, Multibase};
$canon = new Canonicalizer();
$commitment = new Commitment();
// 1 + 2: per item, recompute the commitment and verify Merkle inclusion.
foreach ($receipt['credentialSubject']['consent']['items'] as $item) {
$claim = [
'issuer' => $receipt['issuer'],
'subject' => $receipt['credentialSubject']['id'],
'item' => ['itemId' => $item['itemId'], 'category' => $item['category'], 'purpose' => $item['purpose']],
'grantedAt' => $receipt['credentialSubject']['consent']['grantedAt'],
];
$recomputed = $commitment->commit($claim, $salt); // == stored commitment
assert(Merkle::verify(Merkle::leafForCommitment($recomputed), $proofSiblings, $merkleRoot));
}
// 3: companySig over the offer (envelope fields removed) vs the company DID key.
$pubKey = Multibase::decodeEd25519PublicKey($companyDoc['verificationMethod'][0]['publicKeyMultibase']);
unset($offer['status'], $offer['companySig'], $offer['result']);
$sig = Multibase::decodeBytes($receipt['proof'][0]['proofValue']);
assert(sodium_crypto_sign_verify_detached($sig, $canon->encode($offer), $pubKey));
// 4: resolve the COSE key valid at grantedAt and verify the WebAuthn assertion
// (use a WebAuthn library; the method's validFrom/validUntil must bracket grantedAt).
Next
- Verify a receipt yourself: a runnable guide.