FR EN

Vérifier un ConsentReceipt

Agreely::verifyReceipt($receipt, $opts) est statique, ne demande aucune clé d'API, et résout sur toute entrée : un fichier falsifié ou illisible rend un verdict, jamais une exception. Il prend le dossier de vérification qu'émet un consentement par clé d'accès (version "1" : le reçu, son signedOffer et sa merkleRoot) ou un reçu seul (sur papier ou par téléphone), sous forme de texte JSON (recommandé : le fichier est lu exactement tel qu'écrit), des objets que rend json_decode(), ou d'un tableau. ReceiptVerifier::isVerificationBundle($value) distingue un dossier d'un reçu. En PHP, les seams réseau s'injectent par des appelables httpGet / httpPost (et non par fetch). Cette page décrit le SDK PHP 0.6.2.

use Agreely\Sdk\Agreely;
use Agreely\Sdk\Verify\ReceiptVerifier;

$json = file_get_contents('agreely-verification-bundle.json');
ReceiptVerifier::isVerificationBundle(json_decode($json)); // true for a verification bundle

$v = Agreely::verifyReceipt($json, [
    'rpcUrl' => 'https://base-rpc.publicnode.com', // optional: the on-chain checks, else skipped
]);

Le point d'honnêteté porteur : verifyReceipt sépare une vérification qui a échoué (fail, la cryptographie ou la chaîne contredit activement le fichier) d'une vérification qui n'a pas pu aboutir (unavailable : un DID n'a pas pu être résolu, une passerelle IPFS ou un RPC a échoué, refusé ou expiré). Un reçu valide pendant une panne ne ressemble donc jamais, octet pour octet, à une contrefaçon.

Les trois types de reçu

  • company_attested : l'entreprise a attesté un consentement (typiquement un PDF signé à la main). Sa signature porte sur le corps du reçu. Meilleur verdict possible : verified, que le reçu soit seul ou dans son dossier.
  • company_documented : l'organisation a documenté un consentement donné par téléphone (un VerbalConsentReceipt). La signature de l'entreprise se vérifie, mais il n'existe aucun document signé : le meilleur verdict possible est partial.
  • citizen : la personne a signé avec une clé d'accès (WebAuthn). Meilleur verdict possible : verified, à partir de son dossier de vérification seulement. Un reçu citoyen seul rend incomplete : passez le dossier émis avec lui (voir la limite honnête plus bas).

Les contrôles (plus la liaison des étiquettes)

verifyReceipt renvoie un ReceiptVerification aux propriétés readonly. Chaque contrôle est une chaîne CheckStatus nue. Les explications lisibles vivent toutes dans un seul tableau notes plat, écrit pour les humains : un programme lit les propriétés, jamais les notes. toArray() rend la même forme que le SDK TypeScript.

// $v->input             'bundle' | 'receipt'
// $v->receiptType       'company_attested' | 'company_documented' | 'citizen'
// $v->companySignature  'pass' | 'fail' | 'unavailable' | 'unbound' | 'withdrawn' | 'skipped' | 'unsupported'
// $v->receiptIntegrity, $v->citizenAssertion, $v->disclosureCopy,
// $v->documentAnchor, $v->consentAnchor, $v->cellLabelBinding   (same CheckStatus strings)
// $v->overall           'verified' | 'partial' | 'incomplete' | 'failed' | 'unavailable' | 'malformed' | 'withdrawn'
// $v->withdrawal        'active' | 'withdrawn' | 'not_checked'
// $v->withdrawnAt       ?int, unix seconds, when the chain records it
// $v->documentAnchorRecord   ?array { anchoredAt, anchoredBlock }
// $v->consentAnchorRecords   ?array of { consentRef, state, anchoredAt, revokedAt, anchoredBlock }
// $v->consentAnchorCounts    ?array { cells, active, withdrawn, pending, unreadable }
// $v->registryRefused   bool
// $v->notes             string[], plain-language caveats, one flat array
  1. companySignature : la signature Ed25519 de l'entreprise. Sur un reçu sur papier ou par téléphone, elle porte sur le corps du reçu. Sur un dossier par clé d'accès, c'est proof[0] sur l'offre signée. Seule la méthode que nomme proof[0].verificationMethod la contrôle : une méthode de l'émetteur (son id est le DID de l'émetteur suivi de # et d'un fragment), listée sous assertionMethod dans le document DID de l'émetteur. Un document résolu sans cette clé rend fail; un DID irrésolvable rend unavailable. Sur un reçu citoyen seul, unsupported : l'offre ne voyage que dans le dossier.
  2. receiptIntegrity : sur un dossier par clé d'accès, le reçu correspond à l'offre signée champ par champ (ses clés de premier niveau, type, @context et l'id urn:agreely:receipt: compris), le disclosureHash se recalcule à partir de l'offre, validFrom égale grantedAt, et grantedAt tombe dans la fenêtre de l'offre, de createdAt à expiresAt (en secondes entières). Le tableau proof a la forme qu'écrit l'émetteur : deux preuves sur un reçu par clé d'accès (le DataIntegrityProof de l'entreprise, puis le WebAuthnAssertion de la personne), une seule sur un reçu sur papier ou par téléphone. Sur un reçu sur papier ou par téléphone, ce contrôle suit companySignature (la signature couvre le corps) et rend fail si la preuve n'a pas sa forme émise. Sur un reçu citoyen seul, unsupported.
  3. citizenAssertion : sur un dossier, l'assertion WebAuthn se vérifie, son défi se recalcule comme keccak256(JCS({issuer, subject, requestId, action, grantedAt, merkleRoot}) || nonce), et le document DID du citoyen (dont l'id est le sujet) liste cette clé exacte sous authentication, avec une fenêtre [validFrom, validUntil) qui couvre grantedAt. Un document qui ne confirme pas la clé rend fail; un document qui ne peut pas être lu comme celui de cette identité rend unavailable. Quand grantedAt tombe hors de la fenêtre de l'offre, la clé n'est pas évaluée à cette date : unavailable. La moitié citoyenne n'est contrôlée que pour un sujet did:agreely:citizen:, quel que soit le résolveur; tout autre sujet rend unavailable. Sur un reçu citoyen seul, l'assertion se vérifie sur le défi que porte le reçu, mais rien ne relie ce défi à son contenu (il se calcule à partir du requestId, de la racine de Merkle et du nonce, que seul le dossier porte) : unbound, qui ne prouve rien du reçu. Sur un reçu sur papier ou par téléphone, unsupported.
  4. disclosureCopy (recoupement optionnel, 'verifyDisclosure' => true, sinon skipped sans contacter la passerelle) : récupère la copie de divulgation sur IPFS et la compare au disclosureHash signé. Sur un dossier, c'est celui de l'offre signée. Quand le reçu déclare disclosureVersion: "2", le hachage couvre {disclosure, legalBasis}, et un reçu v2 sans son legalBasis rend fail. Une passerelle qui ne rend pas le document (404, 502), qui échoue ou qui expire rend unavailable. Sur un reçu citoyen seul, unsupported : son hachage n'est signé par personne.
  5. documentAnchor (recoupement optionnel sur la chaîne, exige rpcUrl, sinon skipped) : un seul eth_call rootOf(keccak256(cid)) sur l'AgreelyRegistry. Un enregistrement dans la voie des documents de consentement passe et remplit documentAnchorRecord (anchoredAt, anchoredBlock). Aucun enregistrement encore rend unavailable : le document s'ancre après sa publication, son absence n'est pas une contradiction. Un enregistrement dans une autre voie, ou étiqueté, rend fail. Ce contrôle prouve que le document a existé, pas qu'un consentement a été donné.
  6. consentAnchor (recoupement optionnel sur la chaîne, exige rpcUrl, dossier par clé d'accès seulement) : un eth_call registry(consentRef) par cellule, rattaché au reçu par la racine de Merkle que lie l'assertion vérifiée. pass quand chaque cellule a été lue sous cette racine et est en vigueur; withdrawn quand la chaîne consigne le retrait d'une cellule sous cette racine (le consentement est authentique, et n'est plus en vigueur); fail quand la chaîne consigne une cellule sous une autre racine; unavailable autrement, une cellule pas encore ancrée ou un RPC en échec compris. Chaque cellule acceptée que liste le reçu compte : une cellule dont le dossier ne nomme pas la consentRef compte comme illisible, car personne ne signe cette liste. consentAnchorRecords donne chaque cellule lue sous cette racine (state, anchoredAt, revokedAt, anchoredBlock), et consentAnchorCounts le décompte (cells, active, withdrawn, pending, unreadable). Sur un reçu sur papier, par téléphone ou citoyen seul, skipped.

À ces contrôles s'ajoute un verdict dérivé :

  1. cellLabelBinding : est-ce que les étiquettes lisibles des cellules (category / purpose) sont liées cryptographiquement? C'est le champ à lire avant de faire confiance à une étiquette affichée. Sur un reçu sur papier ou par téléphone, il suit companySignature (les étiquettes sont dans le corps signé). Sur un dossier, il vaut pass quand la signature de l'offre et l'intégrité du reçu passent (chaque cellule est un élément exact de l'offre signée), fail quand l'une échoue, et unavailable quand le DID de l'émetteur ne se résout pas. Sur un reçu citoyen seul, il vaut unsupported : ne vous fiez pas aux étiquettes affichées.

Légende des statuts

Statut par contrôle (CheckStatus) :

Statut Sens
pass le contrôle a été mené et a vérifié
fail le contrôle a été mené et n'a activement pas vérifié : une altération, une mauvaise clé, ou une chaîne qui contredit le fichier
unavailable le contrôle n'a pas pu aboutir : DID irrésolvable, passerelle IPFS ou RPC en échec, en refus ou expiré, appelable httpGet / httpPost qui lève une exception, ou registre inconnu refusé avant tout appel
unbound la signature se vérifie, mais rien ne la relie au contenu du reçu (l'assertion d'un reçu citoyen seul). Ne prouve rien du reçu
withdrawn le consentement est authentique et sous la racine du reçu, mais la chaîne consigne son retrait. Ni un succès, ni une contrefaçon
skipped l'entrée nécessaire est absente, ou le contrôle n'a pas été demandé
unsupported le contrôle ne s'applique pas à cette entrée

Statut global (overall) :

Statut Sens
verified un reçu company_attested sain, seul ou dans son dossier, ou un dossier par clé d'accès dont la signature de l'entreprise, l'intégrité du reçu et l'assertion du citoyen passent. Les contrôles énumérés tiennent; ce n'est pas une preuve juridique
partial un reçu téléphonique (company_documented) dont la signature de l'entreprise se vérifie : la parole de l'organisation, authentifiée. Il ne montre pas à lui seul que la personne a consenti, faute de toute signature de sa part. Le vérificateur à app.agreely.ca/verify lit en plus, avec son dossier de vérification, si un reçu de version 4 est encore en vigueur
incomplete un reçu citoyen seul : il ne peut pas être vérifié sans son dossier. Ni un succès, ni une falsification
failed un contrôle a activement échoué
unavailable un contrôle décisif n'a pas pu aboutir : peu concluant, PAS une contrefaçon. Aussi quand le dossier nomme un registre ou une chaîne que le SDK ne lit pas (registryRefused)
malformed un fichier que le vérificateur ne peut pas lire : pas un objet, un dossier d'une autre version, un receipt ou un signedOffer qui n'est pas un objet, un dossier par clé d'accès sans son signedOffer ou sa merkleRoot, un reçu par clé d'accès, dans un dossier, qui porte un champ que l'émetteur n'écrit jamais, une valeur que la forme canonique refuse, ou une imbrication de plus de 64 niveaux. Aucun contrôle n'a eu lieu : ce verdict ne dit rien d'une altération
withdrawn le reçu est authentique et la chaîne consigne le retrait du consentement : chaque autre contrôle décisif aurait rendu verified, et consentAnchor rend withdrawn

Règle porteuse : un fail actif l'emporte toujours sur un unavailable, et withdrawn n'est rendu que si rien d'autre n'a échoué ni manqué. Un vrai négatif n'est jamais masqué par une indisponibilité.

Un recoupement demandé qui n'a pas pu aboutir (disclosureCopy, documentAnchor ou consentAnchor à unavailable) ne fait pas baisser un verified : lisez ces contrôles avant de vous fier au verdict. La CLI sort alors en 4.

Les clés viennent des documents DID que lit le vérificateur. Un resolver qui sert un document citoyen sans authentication ni validFrom, ou un document d'émetteur qui ne liste pas la clé sous assertionMethod, fait rendre failed à un reçu authentique. Un failed seul n'est donc pas une preuve de falsification : lisez le contrôle qui a échoué et notes, vérifiez les documents que sert votre resolver, ou vérifiez le reçu à app.agreely.ca/verify avec son dossier de vérification.

Le retrait : withdrawal

overall juge le reçu; withdrawal dit si le consentement est encore en vigueur, tel que la chaîne le consigne. Il vaut active quand chaque cellule acceptée a été lue sur la chaîne sous la racine de Merkle du dossier et qu'aucune n'est retirée, withdrawn quand la chaîne consigne le retrait d'une cellule (withdrawnAt donne le plus récent, en secondes unix, quand l'enregistrement le porte), et not_checked autrement. Sans rpcUrl, rien ne le lit : un consentement retiré, vérifié hors ligne, rend overall verified et withdrawal not_checked. Un reçu sur papier, par téléphone ou citoyen seul rend toujours not_checked, car ce SDK lit l'enregistrement sur la chaîne d'un dossier par clé d'accès seulement. Pour n'accepter un consentement que tant qu'il est en vigueur :

$result = Agreely::verifyReceipt($bundleJson, ['rpcUrl' => 'https://base-rpc.publicnode.com']);
$inForce = $result->overall === 'verified' && $result->withdrawal === 'active';

Ce qui est prouvé contre ce qui est présumé

Contrôle Reçu sur papier ou par téléphone Dossier par clé d'accès (citizen) Un pass PROUVE Ce qui reste PRÉSUMÉ
companySignature Ed25519 sur le corps du reçu proof[0] sur l'offre signée l'entreprise a signé ce corps (sur papier : elle atteste un PDF signé à la main de hachage H) ou cette offre, sous une clé que son document DID liste sous assertionMethod qu'un humain a bien signé le PDF (le SDK voit l'attestation, pas la signature manuscrite); que l'hôte du DID est bien l'entreprise que vous attendez
receiptIntegrity suit companySignature, plus la forme de la preuve le reçu contre l'offre, champ par champ, et grantedAt dans la fenêtre de l'offre le reçu dit ce que l'entreprise a offert, à une date que l'offre permet rien de plus sur le contenu
citizenAssertion unsupported WebAuthn, défi recalculé, clé confirmée par le DID du citoyen à grantedAt une clé d'accès que le document DID du citoyen lui attribue à grantedAt a approuvé ce reçu, tel que le relie le dossier que la personne était seule à détenir cette clé (l'assertion prouve la possession); que le document DID du citoyen dit vrai
disclosureCopy copie IPFS contre le disclosureHash du reçu contre celui de l'offre signée la copie IPFS récupérée correspond au hachage signé que la passerelle a servi la vraie copie (un pass ne lie que les octets récupérés au hachage)
documentAnchor rootOf sur la chaîne idem le document a été ancré dans la voie des documents de consentement qu'un consentement a été donné (l'ancrage prouve le document, pas un octroi)
consentAnchor skipped registry(consentRef) sous la racine liée chaque cellule est consignée sur la chaîne sous la racine de ce dossier, en vigueur au moment de la lecture que la liste des consentRef du dossier est complète (personne ne la signe; une cellule acceptée sans référence compte comme illisible)
cellLabelBinding suit companySignature pass quand l'offre et l'intégrité passent les étiquettes category/purpose affichées sont dans un corps ou une offre signés par l'entreprise rien de plus; sur un reçu citoyen seul (unsupported), ne présumez pas l'exactitude des étiquettes

Meilleur verdict global par entrée :

Entrée Meilleur verdict Pourquoi
company_attested, seul ou dans son dossier verified la signature de l'entreprise porte sur le corps du reçu
company_documented partial la signature se vérifie, mais la parole de l'organisation est tout ce qu'il y a
citizen, dans son dossier verified la signature de l'offre, l'intégrité du reçu et l'assertion liée, avec une clé que le DID du citoyen confirme
citizen, reçu seul incomplete rien ne relie l'assertion au contenu du reçu (voir plus bas)

La limite honnête : un reçu citoyen seul

Sur un reçu citoyen seul, companySignature et receiptIntegrity sont unsupported et citizenAssertion est unbound, et ce n'est pas un oubli. L'entreprise a signé l'offre d'origine, qui porte la divulgation complète, le nonce et les dates de l'offre; le reçu ne la reproduit pas, pour préserver la non-liaison. La référence client de l'entreprise n'a jamais fait partie de ce que l'entreprise a signé. L'offre voyage dans le dossier de vérification que la personne télécharge avec le reçu (receipt, signedOffer, merkleRoot) : passez ce dossier à verifyReceipt, contrôlez-le à la main (la recette) ou dans le vérificateur public à app.agreely.ca/verify. C'est pour cela qu'un reçu citoyen seul rend incomplete.

Ce que lit le SDK, et ce que fait en plus app.agreely.ca/verify

Sur un dossier par clé d'accès, Agreely::verifyReceipt mène la signature de l'offre, l'intégrité du reçu, l'assertion liée avec la clé que le DID du citoyen confirme à grantedAt, et, avec un rpcUrl, les deux ancrages et le retrait. Le vérificateur côté client à app.agreely.ca/verify fait en plus la revérification DNS en direct de la liaison de domaine did:web, la validation de la clé de l'entreprise au point-dans-le-temps à travers les rotations, la lecture du retrait d'un reçu sur papier ou par téléphone de version 4, et la vérification de dossiers par lot à app.agreely.ca/verify/batch. Ne présumez pas que le SDK effectue ces contrôles-là.

Lire le résultat

$v = Agreely::verifyReceipt($json, ['rpcUrl' => 'https://base-rpc.publicnode.com']);

switch ($v->overall) {
    case 'verified':
        // the checks listed held: read $v->withdrawal before treating the consent as in force
        break;
    case 'withdrawn':
        // authentic, and the chain records its withdrawal: no longer in force, NOT a forgery
        break;
    case 'partial':
        // a telephone receipt: the organisation's word, its signature verified. A
        // partial does not on its own show consent: verify its bundle at app.agreely.ca/verify
        // to read whether a version 4 receipt is still in force
        break;
    case 'incomplete':
        // a bare passkey receipt: verify the verification bundle issued with it
        break;
    case 'unavailable':
        // inconclusive (a DID, a gateway or an RPC did not answer, or an unknown registry), NOT a forgery
        break;
    case 'failed':
        // a check ACTIVELY failed: read which one, and the notes, before calling it a tamper
        break;
    case 'malformed':
        // not a file this verifier can read: no check ran
        break;
}

Options de vérification

Les options (un tableau associatif) :

Option Défaut Rôle
resolver résolveur did:web par défaut, et résolveur citoyen un appelable fn(string $did) qui rend le document DID, de préférence en texte JSON pour qu'il soit lu tel que publié; s'il lève une exception, la résolution compte comme unavailable. Les clés ne sont jamais tirées des didDocuments du dossier
companyDidHost app.agreely.ca hôte du did:web de l'entreprise : le niveau Web d'Agreely, le seul qui sert /c/{slug}/did.json (pas l'apex agreely.ca), utilisé par resolveCompanyDid
citizenResolverBaseUrl https://my.agreely.ca base de résolution du DID citoyen : le palier citoyen d'Agreely, qui sert /did/{did}
verifyDisclosure false mène le recoupement de la copie de divulgation sur IPFS; autrement skipped, sans contacter la passerelle
ipfsGateway passerelle Lighthouse (https://gateway.lighthouse.storage/ipfs/ suivi du CID) un callable fn(string $cid): string qui rend l'URL à récupérer. Une chaîne (une URL) est ignorée sans erreur : la passerelle par défaut sert alors
rpcUrl (aucun) l'URL JSON-RPC des contrôles sur la chaîne (documentAnchor, consentAnchor) et du retrait; sans elle, skipped
registryAddress le registre connu de chainId l'adresse d'AgreelyRegistry, lue par eth_call sur toute chaîne; hors de Base et de Base Sepolia, passez-la, sinon les contrôles d'ancrage rendent unavailable
chainId 8453 (Base, le réseau principal) la chaîne d'ancrage; passez 84532 pour essayer contre Base Sepolia
timeoutMs 10000 délai de chaque appel HTTP des appelables par défaut, en millisecondes
rpcDeadlineMs 30000 délai de toute la séquence sur la chaîne d'une vérification, depuis son premier appel RPC; passé ce délai, les contrôles d'ancrage encore en attente rendent unavailable
httpGet / httpPost appelables curl par défaut fn(string $url): ?string et fn(string $url, string $body): ?string; un appelable qui lève une exception compte comme unavailable

Le registre que lit le SDK

Sans registryAddress, le SDK lit le registre connu de la chaîne : sur Base, le réseau principal (chain id 8453), 0x6F2cF94045cF0cbDD94338882c34D183575023E9, déployé au bloc 53113531; sur Base Sepolia (84532), 0xf25D3F8E73648C797Be2CCCD428061f357567b9A, déployé au bloc 50213158. Les contrôles sur la chaîne lisent le registre par eth_call seulement, sans requête de journaux. Un dossier dont la délégation de la clé de l'émetteur nomme un autre registre ou une autre chaîne que celui lu est refusé avant tout appel RPC : les contrôles d'ancrage rendent unavailable, registryRefused vaut true, et le verdict n'est jamais verified. Ce résultat est peu concluant, pas une contrefaçon. L'adresse en vigueur est toujours celle que publie le vérificateur public à app.agreely.ca/verify.

Confiance et SSRF : injectez votre propre résolveur pour un reçu non fiable

Le résolveur did:web par défaut récupère un hôte tiré du reçu lui-même (HTTPS uniquement). Un verified montre donc que le reçu a été signé par qui contrôle le document DID de cet hôte, pas que cet hôte est bien l'entreprise : un reçu qui nomme un hôte de son choix, signé par une clé que cet hôte publie, peut rendre verified. Pour un reçu que vous n'avez pas récupéré vous-même, injectez votre propre resolver (ou vos appelables httpGet / httpPost) ou fournissez les documents DID localement : vous choisissez alors les émetteurs que vous croyez, plutôt que de laisser le reçu les choisir et diriger une requête sortante. Les clés ne sont jamais tirées des didDocuments du dossier, que n'importe qui peut écrire.

Ensuite