BénéficesDoc CLIVersion 0.4.0Dé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.


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 §6

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.

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

  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 au digest de TSTInfo DER selon l’algorithme CMS déclaré, SHA-256 ou SHA-384.
  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). 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.
  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 ? 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. --timestamp permet 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.