BénéficesDoc CLIVersion 0.1Démarrer
Documentation CLI

Format de reçu

Un reçu est l’élément de preuve produit par attest. Ce document en décrit le format avec suffisamment de précision pour écrire un vérificateur indépendant sans consulter le code Rust.

C’est son objectif. La possibilité pour chacun de vérifier un reçu ne tient pas à la disponibilité de notre implémentation : elle tient à la description explicite du format.

Version actuelle : schema_version: 3.

Les sections marquées prévu décrivent des champs réservés par le format, mais que cette version ne produit pas encore. Elles permettent aux auteurs d’implémentations indépendantes d’anticiper les évolutions ; elles n’indiquent pas que ces fonctionnalités sont déjà prises en charge.


1. Emplacement des reçus

Les reçus sont enregistrés dans .attest/receipts/receipt_<UTC timestamp>_<pipeline>_<suffix>.yaml, au format YAML encodé en UTF-8. Le nom du fichier comprend un suffixe aléatoire pour éviter une collision entre deux reçus produits à la même milliseconde. Aucune règle du format ne dépend de ce suffixe.

2. Document

schema_version: 3
pipeline_hash: "9f2c…"            # 64 lowercase hex
steps: [...]                      # see §3
timestamp: "2026-09-16T10:36:36.554327Z"
total_duration_secs: 4
signature: "a71f…"                # 128 lowercase hex, or null
signer_public_key: "333d…"        # 64 lowercase hex, or null
attest_version: "0.1.0"
causal_events: ["…", "…"]         # event ids
causal_chain_hash: "4c8b…"        # or null
reproducibility: {...}            # optional, see §5
provenance: {...}                 # optional, see §6
timestamp_token: "MIIG…"          # optional, base64 DER, see §7

Les champs inconnus sont rejetés. Un reçu est un document de sécurité : un vérificateur ne peut pas interpréter un champ qu’il ne comprend pas. Cette règle est l’inverse de celle du protocole d’échange des plugins, qui doit tolérer les champs inconnus pour pouvoir évoluer.

Gestion des versions. Le vérificateur lit schema_version dans le document brut avant l’analyse stricte. Une valeur supérieure à celles qu’il prend en charge doit déclencher un message explicite demandant une mise à jour, jamais une erreur obscure de champ inconnu. Un reçu sans schema_version correspond à la version 1 et reste valide. Le champ est omis, plutôt que renseigné à null, précisément pour préserver à l’octet près les reçus antérieurs à la gestion des versions et leurs signatures.

3. Étapes

steps:
  - name: "build"
    input_hash: "1a2b…"           # 64 lowercase hex, see §4
    output_hash: "3c4d…"
    duration_secs: 3
    exit_code: 0
    cache_hit: false
    stdout: "…"
    stderr: "…"
    capsule_hash: "5e6f…"         # optional; present for capsule steps

Seules les étapes exécutées apparaissent. Une étape ignorée parce qu’une dépendance a échoué est absente : une entrée du reçu doit contenir de vraies empreintes, qu’une étape jamais exécutée ne peut pas produire. Une étape exécutée en échec figure dans le reçu, avec un exit_code non nul.

4. Empreintes : attest-manifest/v1

input_hash et output_hash correspondent à blake3(manifest_bytes), où le manifeste est un document texte canonique :

attest-manifest/v1\n
run:<blake3-hex of the step's run command, UTF-8 bytes>\n   (input manifests only)
f:<blake3-hex of file content> <normalized relative path>\n
l:<blake3-hex of symlink target string> <normalized relative path>\n

Une implémentation conforme doit respecter les règles suivantes :

  • Les lignes qui suivent l’en-tête sont triées selon l’ordre lexicographique des octets du chemin normalisé, et non selon la langue ou l’ordre d’insertion.
  • Les chemins sont relatifs à la racine de l’espace de travail, normalisés et utilisent / comme séparateur.
  • Les liens symboliques ne sont jamais suivis. Un lien symbolique est enregistré en calculant l’empreinte de la chaîne de caractères qui désigne sa cible, dans une ligne l:. Si cette cible sort de la racine de l’espace de travail, c’est une erreur ; le lien ne doit pas simplement être ignoré.
  • Un chemin déclaré qui n’existe pas provoque une erreur bloquante. L’omettre silencieusement permettrait au reçu de prétendre couvrir une entrée qu’il ne couvre pas réellement.
  • Les répertoires ne produisent pas de ligne propre. Un répertoire vide est donc invisible dans le manifeste.
  • Le préfixe run: sépare le domaine des commandes de celui des fichiers : un fichier dont le contenu est identique à une commande ne peut pas être confondu avec celle-ci.

attest hash expose directement ce calcul. C’est le moyen le plus simple de comparer une implémentation indépendante à la nôtre :

$ attest hash --run "cargo build" --input src/ --input Cargo.toml
$ attest hash --output target/release/attest

5. Reproductibilité

reproducibility:
  verified: true
  runs: 2
  method: "double-build"

Ce champ est écrit uniquement par attest run --check-reproducibility. Son absence signifie non vérifié. Cela ne signifie pas non reproductible, et un vérificateur ne doit conclure ni à la reproductibilité ni à son absence.

6. Provenance

Il s’agit d’observations signées que l’environnement d’exécution, ou runner, fait sur lui-même : invocation_id, started_at, pipeline_name, source (URL du dépôt, commit, branche, tracked_dirty), runner_os, runner_arch, ci (fournisseur, identifiants de l’exécution et du job), command_hash pour chaque étape avec ses entrées et sorties déclarées, ainsi que les artefacts produits et leurs empreintes.

Ce sont des déclarations, pas des preuves indépendantes. Un runner qui ment sur son propre commit peut produire un reçu cohérent en interne, mais faux. La provenance sert à rapprocher les informations et à préparer un audit ; elle ne constitue pas un point d’ancrage de confiance.

7. Jeton d’horodatage

Il s’agit d’un TimeStampToken RFC 3161, encodé en DER puis en base64, écrit par attest run --sign --timestamp. Le champ est absent si l’exécution n’a pas demandé d’horodatage.

Le jeton porte sur la signature, pas sur le corps du reçu. La signature engage le corps du reçu ; l’horodatage engage la signature. Ensemble, ils établissent l’existence d’une signature précise, produite par une clé précise, à une date que le signataire n’a pas choisie. Horodater le corps du reçu prouverait seulement l’existence du contenu, sans indiquer quand il a été signé.

L’empreinte est le SHA-256 des octets bruts de la signature, soit les 64 octets obtenus en décodant la représentation hexadécimale de signature.

Ce champ est exclu des octets signés (§8). Le jeton est en effet émis après la création de la signature et porte sur celle-ci : l’inclure créerait une dépendance circulaire. Son intégrité repose sur la signature de l’autorité d’horodatage, et non sur la nôtre.

Vérifier un jeton

  1. Décoder le base64 en DER et analyser le SignedData CMS.
  2. Extraire le TSTInfo encapsulé, puis lire genTime et messageImprint.
  3. Vérifier que l’empreinte est égale à SHA-256(octets bruts de la signature). Sinon, le jeton porte sur un autre objet et n’a aucune valeur ici.
  4. Vérifier que l’attribut signé message-digest est égal à SHA-256(TSTInfo DER).
  5. Vérifier la signature de l’autorité sur les attributs signés, réencodés avec la balise SET OF 0x31 à la place du marquage implicite [0] utilisé dans le message transmis (RFC 5652 §5.4).
  6. Vérifier que le certificat de signature a été émis par une autorité explicitement approuvée et qu’il était valide à genTime.

L’implémentation de référence approuve explicitement l’autorité de certification émettrice, et non le service qui répond aux demandes d’horodatage. Elle vérifie exactement une signature entre les deux. Elle ne construit pas de chaîne de certification X.509 : aucune découverte, aucune contrainte de nom, aucune CRL ni aucun contrôle OCSP. Les certificats des services d’horodatage sont renouvelés, souvent chaque année ; approuver un de ces certificats directement empêcherait de vérifier les reçus émis après son renouvellement.

Plugins (prévu)

Une liste plugins est réservée à une version ultérieure du schéma : elle contiendra un enregistrement par plugin consulté et sera intégrée aux octets signés. Deux invariants sont fixés dès maintenant, car les vérificateurs en dépendront : un plugin ne peut jamais modifier une valeur décrite au §3, et un vérificateur ne doit jamais avoir besoin d’un plugin pour rendre son verdict. Consultez le protocole de plugin.

8. Signature

La signature Ed25519 porte sur les octets canoniques à signer, construits comme suit :

  1. Prendre le reçu.
  2. Affecter null à signature et à signer_public_key.
  3. Affecter null à timestamp_token.
  4. Trier steps par name.
  5. Trier causal_events.
  6. Sérialiser en JSON, et non en YAML, dans l’ordre des champs de la structure, exactement comme indiqué au §2.

Les trois champs remis à null n’existent qu’une fois la signature réalisée. Cette remise à zéro permet de signer un reçu, puis de l’horodater, tout en préservant la validité de la vérification.

La séquence d’octets obtenue est celle qui est signée.

  • signature est la signature Ed25519 de 64 octets, en hexadécimal minuscule (128 caractères).
  • signer_public_key est la clé publique Ed25519 brute de 32 octets, en hexadécimal minuscule (64 caractères).
  • L’identifiant de clé utilisé dans le CLI et le magasin de confiance est hex(blake3(raw_public_key)[..16]) : les 16 premiers octets de l’empreinte, soit 32 caractères hexadécimaux.

Les reçus sont donc stockés en YAML, mais signés en JSON. La représentation signée est canonique et stable ; la représentation de stockage est conçue pour être lue par une personne.

9. Magasin de confiance

Le répertoire .attest/trust/ est versionné dans le dépôt : <key-id>.pub contient une clé publique encodée en PEM et trust.toml enregistre la politique de confiance.

version = 1
 
[[key]]
id = "0b948c80672304dda0bb674dcc963b71"    # hex(blake3(pubkey)[..16])
name = "release"
status = "trusted"          # or "revoked"
# revoked_at = "2026-09-16T10:00:00Z"

La révocation tient compte de la date. Un reçu signé avant revoked_at reste valide, avec un avertissement ; un reçu signé après échoue à la vérification. Révoquer une clé n’invalide pas rétroactivement le travail effectué lorsqu’elle était approuvée.

Quelle horloge fait foi ? Si le reçu contient un jeton d’horodatage, la comparaison utilise la date attestée par l’autorité. Sinon, elle utilise timestamp, que le signataire a lui-même renseigné et signé. Une personne détenant une clé volée peut donc antidater un reçu pour le faire précéder la révocation de cette clé. Le vérificateur doit avertir lorsqu’il utilise cette date de repli ; l’implémentation de référence le fait.

Les autorités d’horodatage approuvées sont enregistrées dans .attest/trust/tsa/, avec la commande attest keys trust-tsa <certificate> --name <name>. Plusieurs autorités peuvent être approuvées. Les anciennes doivent être conservées pour que les reçus antérieurs restent vérifiables après un changement d’autorité.

Les clés ne sont jamais supprimées, seulement marquées comme révoquées. Les clés privées résident dans .attest/keys/, exclu du suivi Git. attest keys export refuse d’exporter tout élément qui n’est pas une clé publique.

10. Vérification

Cinq contrôles sont effectués dans cet ordre, chacun pouvant faire l’objet d’un résultat distinct :

Contrôle Ce qu’il établit
schema Le reçu est bien formé et sa version est comprise par le vérificateur
consistency Les références internes sont cohérentes
signature Le reçu a été signé par une clé approuvée, avant sa révocation
timestamp Un tiers a attesté la date à laquelle la signature existait
recompute Les empreintes des entrées déclarées correspondent toujours à celles du reçu

recompute doit être activé explicitement avec --recompute, car il nécessite l’espace de travail. Les trois premiers contrôles dépendent uniquement du reçu et du magasin de confiance.

Codes de sortie : 0 si tous les reçus passent la vérification, 1 si au moins une vérification échoue, 2 en cas d’erreur opérationnelle (fichier illisible, options invalides). Un vérificateur qui confond 1 et 2 empêche un pipeline CI de distinguer une preuve invalide d’un fichier qui n’a pas pu être lu.

11. Ce qu’un reçu valide n’établit pas

Ces limites sont explicites pour qu’une implémentation indépendante n’attribue pas au format des garanties qu’il n’apporte pas :

  • Sans jeton d’horodatage, la date provient de l’horloge du runner, et est renseignée puis signée par celui-ci. --timestamp permet de la remplacer par une date attestée par un tiers.
  • La racine causale n’est publiée nulle part. Le journal est conçu localement pour permettre uniquement l’ajout d’entrées. Il n’existe ni journal de transparence ni ancrage externe : cette propriété ne protège donc que contre un attaquant qui ne contrôle pas la machine hébergeant le journal.
  • Le signataire est une clé, pas une personne. Aucun lien n’est établi entre une clé Ed25519 et une identité juridique.

Un reçu établit une cohérence interne, vérifiable par un tiers à partir des sources. Il ne constitue pas, à ce jour, une garantie indépendante.