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.
La version 0.4.0 conserve le schéma 3 et vérifie les anciens reçus, signatures
comprises. Les champs optionnels isolation et provenance.execution sont omis
lorsqu’ils sont absents afin de préserver les octets signés des anciens reçus.
Les anciens vérificateurs peuvent refuser les nouveaux champs ; mettez-les
à jour avant de livrer les nouvelles preuves.
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.4.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
isolation: {...} # optional, see §6Les 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.
Descripteur d’exécution (0.3.1)
execution est un champ final optionnel de provenance, après artifacts.
Il est omis lorsqu’il est absent, jamais sérialisé en null. Un pipeline natif
écrit {"mode":"native"}. Une commande enveloppée écrit :
{"mode":"wrapped","command":["npm","run","build"]}Les champs et modes inconnus sont refusés. Le tableau de chaînes command
conserve les limites entre arguments. Ses octets canoniques sont le JSON UTF-8
compact, sans espace entre les éléments, produit par serde_json. Pour l’unique
étape enveloppée, command_hash est le BLAKE3 de ce tableau. pipeline_hash
est le BLAKE3 de attest-wrap/v1\n suivi du même tableau ; la ligne run:
du manifeste d’entrée utilise command_hash.
Ce descripteur signé permet le recalcul depuis les fichiers déclarés sans
attest.yaml et sans exécuter la commande. Son absence dans un ancien reçu
n’établit aucun mode d’exécution. Sans pipeline correspondant ni descripteur
enveloppé, le recalcul est explicitement ignoré et ne satisfait pas
--require recompute. Consultez le
contrat de recalcul enveloppé.
Contrôles d’isolation (0.2)
Le champ optionnel isolation, placé après timestamp_token dans l’ordre des
champs, fait partie des octets signés. Il contient mode, platform, une liste
controls et, si nécessaire, capsule_steps. Chaque contrôle porte name,
status (enforced, not-enforced ou not-available) et detail.
Les contrôles couvrent le réseau, le système de fichiers, les capacités,
l’environnement, la mémoire, les fichiers ouverts, le CPU, le délai et les
entrées déclarées. L’absence du bloc ne permet aucune conclusion sur l’isolation.
--verify applique des limites de processus, sans isolation réseau ni du système
de fichiers. Le rapport décrit les contrôles observés par le runner ; il ne prouve
pas que celui-ci était intègre.
7. Jeton d’horodatage
Il s’agit d’un TimeStampToken RFC 3161, encodé en DER puis en base64,
écrit par attest run --sign --timestamp, attest sign --timestamp ou
attest timestamp. Le champ est absent si aucun jeton n’a été joint.
Les requêtes et redirections exigent HTTPS ; le service par défaut est
https://timestamp.sectigo.com/rfc3161.
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 au digest deTSTInfo DERselon l’algorithme CMS déclaré, SHA-256 ou SHA-384. - 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). L’implémentation accepte les signatures RSA PKCS#1 v1.5 avec SHA-256 ou SHA-384 ; elle refuse les déclarations d’algorithme non prises en charge ou incohérentes. - 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 ? Pour une clé révoquée, le vérificateur utilise
la date d’un jeton d’horodatage indépendant vérifié. Sans ce jeton, le reçu est
refusé par défaut : le détenteur d’une clé volée pourrait antidater timestamp.
L’option explicite --trust-receipt-timestamp autorise ce repli sur la date
signée par le runner, avec un avertissement. Elle affaiblit la vérification.
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 par défaut dans .attest/keys/, exclu du suivi Git ;
ATTEST_KEYS_DIR permet de les conserver hors de l’espace de travail.
attest keys export refuse d’exporter tout élément qui n’est pas une clé publique.
Bundles de confiance signés (0.2)
attest trust bundle transmet les clés, les révocations datées, l’émetteur,
la période de validité et le numéro de séquence dans un bundle signé.
Le premier attest trust import exige un émetteur obtenu par un canal de
confiance distinct. attest trust refresh refuse les retours à une séquence
antérieure, les altérations et les émetteurs révoqués. La fraîcheur est évaluée
selon l’horloge locale et le dernier bundle importé, sans requête réseau.
Consultez la référence CLI pour les commandes et leurs exigences.
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 et sorties déclarées correspondent à 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.
Un contrôle absent ou non exécuté est skipped. Sans politique, un verdict
passe si aucun contrôle n’échoue ; il ne signifie pas que tous les contrôles
ont été exécutés. --require signature,timestamp,recompute impose que chaque
propriété soit établie. linkage, trust et success sont également disponibles.
Le succès de la compilation est distinct de la validité des preuves. Les champs
JSON policy, execution et trust_material rendent ces distinctions explicites.
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 publication externe est explicite. Le reçu seul contient un historique causal local. En 0.4, un enregistrement de publication conservé séparément peut relier sa racine à un journal externe par un engagement aléatoire. Vérifiez cette preuve avec une confiance dans le journal configurée indépendamment ; consultez le guide de publication.
- Le signataire est une clé, pas une personne. Aucun lien n’est établi entre une clé Ed25519 et une identité juridique.
Un reçu contient des déclarations signées et des empreintes de fichiers. L’inclusion externe et la comparaison de points de contrôle sont des contrôles de preuve distincts ; aucun n’établit une exécution correcte du build.