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 §7Les 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 stepsSeules 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>\nUne 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/attest5. 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
- Décoder le base64 en DER et analyser le
SignedDataCMS. - Extraire le
TSTInfoencapsulé, puis liregenTimeetmessageImprint. - 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. - Vérifier que l’attribut signé
message-digestest égal àSHA-256(TSTInfo DER). - Vérifier la signature de l’autorité sur les attributs signés, réencodés
avec la balise
SET OF0x31à la place du marquage implicite[0]utilisé dans le message transmis (RFC 5652 §5.4). - 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 :
- Prendre le reçu.
- Affecter
nullàsignatureet àsigner_public_key. - Affecter
nullàtimestamp_token. - Trier
stepsparname. - Trier
causal_events. - 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.
signatureest la signature Ed25519 de 64 octets, en hexadécimal minuscule (128 caractères).signer_public_keyest 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.
--timestamppermet 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.