FR EN

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.

agreely verify sample-receipt.json --did-doc sample-did.json
✓ VERIFIED  (company_attested, read as a receipt)
  companySignature  pass
  receiptIntegrity  pass
  citizenAssertion  unsupported
  disclosureCopy    skipped
  documentAnchor    skipped
  consentAnchor     skipped
  cellLabelBinding  pass
  withdrawal        not checked

  ! WITHDRAWAL NOT CHECKED: this verify did not read the chain, so it says nothing of a withdrawal since the consent was given. For a passkey consent's verification bundle, add --onchain --rpc-url https://base-rpc.publicnode.com to read it.

  · 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.
  · consentAnchor skipped: this verifier reads a consent's on-chain record from a passkey bundle only, so the withdrawal of a paper or telephone consent is not checked.

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, and to recompute the challenge with its nonce);
  • the cell's envelope: its 32-byte salt and 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 and take the method proof[0].verificationMethod names: its id is the issuer DID followed by # and a fragment, it is listed under assertionMethod, and it is never simply the document's first method. 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, subject, status, result). The verification bundle's signedOffer already has them removed.

method = companyDoc.verificationMethod[ id == proof[0].verificationMethod ]   // listed under companyDoc.assertionMethod
pubKey = decodeEd25519( method.publicKeyMultibase )
ed25519_verify( decodeBytes(proof[0].proofValue), JCS(offer\{companySig,subject,status,result}), pubKey )

4. Verify the WebAuthn assertion against the key valid at grantedAt

Resolve the citizen DID document. First recompute the challenge from the receipt's fields, the Merkle root and the signed offer's nonce: it must equal proof[1].challenge, and so must the embedded clientDataJSON.challenge. Take the method proof[1].verificationMethod names and verify the assertion (authenticatorData, clientDataJSON, signature) against its COSE key. That key is the person's only when a method of the subject DID, listed under authentication, carries exactly that COSE key and its [validFrom, validUntil) window covers grantedAt (validUntil excluded). Last, grantedAt must fall inside the offer's window, createdAt and expiresAt included.

challenge = keccak256( JCS({ issuer, subject, requestId, action, grantedAt, merkleRoot }) || hexBytes(offer.nonce) )
assert  challenge == proof[1].challenge                 // clientDataJSON.challenge carries the same bytes
method = citizenDoc.verificationMethod[ id == proof[1].verificationMethod ]
webauthn_verify( proof[1], method.publicKeyCose )
assert  method.id under the subject DID, listed in citizenDoc.authentication
assert  method.validFrom <= grantedAt  and  (method.validUntil is null or grantedAt < method.validUntil)
assert  offer.createdAt <= grantedAt <= offer.expiresAt

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 verifies over the challenge the receipt carries, without tying it to the receipt's content: recomputing that challenge takes the signed offer's nonce, which the receipt does not reproduce. It shows passkey possession, not that this person consented to these cells.
  • The company-signature half is not verifiable from the receipt alone. The company signed the original offer, which carries the full disclosure, the nonce and the offer's dates, and the receipt does not reproduce it. No server endpoint re-checks it. It is checked offline from the verification bundle the person downloads with their receipt: receipt, signedOffer (the exact offer the company signed, without subject or the other envelope fields), merkleRoot, consentRefs and didDocuments (issuer and citizen). The company's customer reference is never in it.

"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 key proof[0] names.
//    This test's document holds one method, the one proof[0] names; a verifier selects it by id.
$pubKey = Multibase::decodeEd25519PublicKey($companyDoc['verificationMethod'][0]['publicKeyMultibase']);
unset($offer['subject'], $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: recompute the challenge, then verify the WebAuthn assertion with the COSE key valid at grantedAt
//    (use a WebAuthn library; the method's [validFrom, validUntil) must cover grantedAt).

Next