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)
  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 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, 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/verify endpoint, 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