FR EN

Vérifier un reçu

Un reçu de consentement est conçu pour être vérifiable par quiconque, sans aucune confiance en Agreely. Cette page est la recette au niveau des champs; pour une marche à suivre exécutable, voir vérifier un reçu vous-même.

Essayez-le

Téléchargez un vrai reçu attesté par l'entreprise et le document DID de son émetteur, tirés des vecteurs de référence du SDK, puis vérifiez le reçu hors ligne avec la CLI : aucun réseau, aucune clé d'API.

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.

La ligne cellLabelBinding est le champ à lire avant de faire confiance à une étiquette affichée : ici, pass, car les étiquettes sont dans le corps signé par l'entreprise. Le reçu ne contient que des données de démonstration (l'entreprise fictive « Acme »).

Ce dont vous avez besoin

Pour vérifier une cellule de bout en bout, un vérificateur détient :

  • le reçu VC (le reçu de consentement);
  • l'objet offre d'origine (pour contrôler companySig, qui a signé l'offre, et recalculer le défi avec son nonce);
  • l'enveloppe de la cellule : son salt de 32 octets et sa preuve d'inclusion de Merkle (divulgués par Agreely; le sel est détruit à l'effacement);
  • la racine de Merkle ancrée (issue du consentement du reçu, recoupable sur le registre sur la chaîne);
  • les deux documents DID (résolus de façon indépendante).

Les quatre contrôles

1. Recalculer l'engagement à partir de la déclaration et du sel

Reconstruisez la déclaration figée à partir des champs du reçu, canonicalisez-la en JCS, concaténez brutalement le sel, et passez au keccak256. Le résultat doit égaler l'engagement stocké.

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. Vérifier l'inclusion de Merkle par rapport à la racine

Prenez leaf = keccak256(commitment) et repliez-la avec les frères de la preuve à l'aide de l'appariement commutatif. Le résultat doit égaler la racine de Merkle du consentement, qui est la valeur ancrée sur la chaîne.

leaf = keccak256(commitment)
fold(leaf, proof) == merkleRoot     // keccak256(min||max) at each step

3. Vérifier companySig par rapport à la clé du DID de l'entreprise

Résolvez le document DID de l'entreprise et prenez la méthode que nomme proof[0].verificationMethod : son id est le DID de l'émetteur suivi de # et d'un fragment, elle est listée sous assertionMethod, et ce n'est jamais simplement la première méthode du document. Décodez son publicKeyMultibase vers la clé Ed25519 brute, et vérifiez proof[0].proofValue (décodé depuis base58btc) par rapport aux octets JCS de l'offre moins ses champs d'enveloppe (companySig, subject, status, result). Le signedOffer du dossier de vérification les a déjà retirés.

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. Vérifier l'assertion WebAuthn par rapport à la clé valide à grantedAt

Résolvez le document DID du citoyen. Recalculez d'abord le défi à partir des champs du reçu, de la racine de Merkle et du nonce de l'offre signée : il doit égaler proof[1].challenge, et le clientDataJSON.challenge intégré aussi. Prenez la méthode que nomme proof[1].verificationMethod et vérifiez l'assertion (authenticatorData, clientDataJSON, signature) par rapport à sa clé COSE. Cette clé n'est celle de la personne que si une méthode du DID du sujet, listée sous authentication, porte exactement cette clé COSE et que sa fenêtre [validFrom, validUntil) couvre grantedAt (validUntil exclu). Enfin, grantedAt doit tomber dans la fenêtre de l'offre, createdAt et expiresAt inclus.

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

Pourquoi ces quatre, et rien au sujet d'Agreely

Le contrôle 1 prouve que la déclaration est exactement ce qui a été engagé. Le contrôle 2 prouve que cet engagement est dans le consentement qui a été ancré. Le contrôle 3 prouve que l'entreprise a fait l'offre. Le contrôle 4 prouve que la personne l'a approuvée, avec une clé que le document DID dit être la sienne à ce moment-là. Chaque intrant est un artéfact publié ou une résolution indépendante; rien de tout cela ne vous demande de croire Agreely sur parole.

La frontière honnête de la vérification

Les quatre contrôles ci-dessus supposent qu'on vous a remis l'offre d'origine et l'enveloppe de la cellule. Ce que vous pouvez vérifier avec le seul reçu hors ligne dépend de sa variante.

Un reçu attesté par l'entreprise est entièrement vérifiable hors ligne : sa signature Ed25519 d'entreprise se vérifie par rapport à la clé did:web de l'entreprise résolue. Il n'y a pas de moitié citoyen à contrôler.

Un reçu citoyen n'est que partiellement vérifiable hors ligne :

  • L'assertion de la clé d'accès du citoyen se vérifie sur le défi que porte le reçu, sans le relier à son contenu : recalculer ce défi demande le nonce de l'offre signée, que le reçu ne reproduit pas. Elle montre la possession de la clé d'accès, pas que cette personne a consenti à ces cellules.
  • La moitié signature de l'entreprise n'est pas vérifiable à partir du reçu seul. L'entreprise a signé l'offre d'origine, qui porte la divulgation complète, le nonce et les dates de l'offre, et le reçu ne la reproduit pas. Aucun point de terminaison serveur ne refait ce contrôle. Il se fait hors ligne à partir du dossier de vérification que la personne télécharge avec son reçu : receipt, signedOffer (l'offre exacte que l'entreprise a signée, sans subject ni les autres champs d'enveloppe), merkleRoot, consentRefs et didDocuments (issuer et citizen). La référence client de l'entreprise n'y figure jamais.

« Indisponible » n'est pas « échoué »

Distinguez toujours indisponible d'échoué. Un document DID qui ne s'est pas résolu est indisponible : le résultat est non concluant, ce n'est pas une falsification. Un échec cryptographique actif (une signature qui ne vérifie pas contre la bonne clé) est échoué : c'est une altération ou une mauvaise clé. Ne rapportez jamais l'un pour l'autre.

Cette matrice est mise en œuvre par la vérification d'un reçu en TypeScript, la vérification d'un reçu en PHP et la commande verify de la CLI.

Référence PHP

Ceci reflète le propre test d'intégration d'Agreely, qui effectue exactement ces contrôles.

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).

Ensuite