Vérifier un rapport signé¶
Chaque rapport RestoreProof est signé par le runner qui l'a produit, sur l'infrastructure du client. Cette page s'adresse à la personne qui doit contrôler cette signature : un auditeur, un DPO, un RSSI, l'équipe sécurité d'un client.
Elle est écrite pour que ce contrôle se fasse sans nous : sans compte, sans appel à notre API, sans avoir à nous faire confiance. Tout ce qu'il faut est dans le fichier téléchargé.
Obtenir le fichier¶
Dans l'application : Exécutions → bouton Télécharger sur la ligne de l'exécution. Le même bouton est sur la page d'une exécution, à côté du titre du rapport.
Vous obtenez un fichier restoreproof-report-<id>.json, où <id> est le début
de l'identifiant du rapport — ses huit premiers caractères, de quoi distinguer
deux fichiers dans un dossier sans en faire un nom illisible. L'identifiant
complet est dans le fichier, sous report.id. Rien d'autre n'est nécessaire.
Le téléchargement n'est ouvert que pendant la durée de consultation de l'offre du compte. Au-delà, le bouton ne rend plus le contenu ; une copie complète du rapport reste archivée cinq ans chez nous et se restitue sur demande. Voir Données et conservation.
Le contrôle le plus rapide¶
Ouvrez https://app.restoreproof.io/verify et déposez-y le fichier.
L'adresse est aussi inscrite dans le fichier, sous
signature.verification_page.
Ni compte ni connexion. La page lit le fichier avec la cryptographie du navigateur et ne fait aucun appel réseau pour le vérifier : ouvrez les outils de développement et regardez, ou débranchez le réseau d'abord. Elle dit lesquels des contrôles décrits plus bas sont passés, et affiche la charge utile qu'elle a hachée.
C'est commode, ce n'est pas une autorité : cette page vient de nous. Si votre modèle de menace nous inclut, ignorez-la et exécutez l'un des scripts ci-dessous. Ce sont exactement les mêmes contrôles, sur votre machine, en dix lignes.
Ce que contient le fichier¶
{
"format": "restoreproof.report/v1",
"generated_at": "2026-08-15T21:40:00Z",
"organization": { "id": "…", "name": "Acme Inc.",
"current_name": "…" /* seulement en cas de renommage depuis */ },
"restore_plan": { "id": "…", "name": "Vérifier la base Commandes", "deleted": false },
"run": { "id": "…", "status": "completed", "trigger_type": "scheduled",
"created_at": "…", /* mise en file par nous — non signé */
"started_at": "…", "completed_at": "…" }, /* fenêtre du runner — signé */
"runner": { "id": "…", "name": "runner-dc-paris-01", "deleted": false },
"report": {
"id": "…",
"status": "PASS",
"created_at": "…",
"probe_results": [ /* une entrée par sonde, son verdict et son message */ ],
"metrics": {
/* dont `artifacts` : l'objet de sauvegarde restauré,
avec son nom, son emplacement et son SHA-256 */
},
"sanitized_logs": "…"
},
"signature": {
"algorithm": "Ed25519",
"public_key": "<base64, 32 octets>",
"value": "<base64, 64 octets>",
"signed_payload_hash": "<sha256 hexadécimal>",
"signed_payload": "<les octets exacts qui ont été hachés>",
"verification_guide": "…",
"verification_page": "https://app.restoreproof.io/verify"
}
}
restore_plan.deleted et runner.deleted signalent que l'objet a depuis été
supprimé du compte. Le rapport reste valide et vérifiable dans les deux cas.
Ce que la signature couvre, et ce qu'elle ne couvre pas¶
À lire avant de traiter un contrôle réussi comme une preuve.
Signé — les champs que porte signature.signed_payload (l'ordre réel des
clés dans le fichier est alphabétique, il n'a aucune importance : vous hachez
les octets tels qu'ils sont livrés) :
{
"version": 1,
"run_id": "…",
"organization_id": "…", "organization_name": "Acme Inc.",
"plan_id": "…", "plan_name": "Vérifier la base Commandes",
"runner_id": "…", "runner_name": "runner-dc-paris-01",
"started_at": "2026-08-28T02:00:03Z", // l'horloge du runner, RFC 3339 UTC
"finished_at": "2026-08-28T02:06:55Z",
"status": "PASS",
"probe_results": [ … ],
"metrics": { … },
"logs_sha256": "<sha256 hexadécimal de report.sanitized_logs>"
}
Les noms sont un instantané : ils disent comment s'appelaient l'organisation, le
plan et le runner au moment de l'exécution. Un renommage ultérieur
n'invalide rien — le fichier porte alors le nom actuel à part, dans
organization.current_name, et celui-là ne prouve rien.
Les journaux sont couverts par empreinte plutôt que par valeur :
sanitized_logs voyage à côté de la charge utile, et le rehacher doit
reproduire logs_sha256.
Non signé, sans contrepartie à laquelle se comparer : run.trigger_type,
run.created_at (le moment où nous avons mis le travail en file), report.id,
restore_plan.deleted, runner.deleted, organization.current_name et
generated_at. Ce sont nos propres annotations. Modifiez-les dans un fichier
téléchargé et la signature vérifie toujours, parce que ces octets n'en ont
jamais fait partie.
Le fichier répète aussi les valeurs signées dans les champs lisibles
report.*, run.*, organization.*, restore_plan.* et runner.*, que le
control plane relit dans sa propre base. Lisez les valeurs dans
signed_payload, et comparez-y la copie lisible : toute différence signifie
que le fichier a été modifié après signature. C'est exactement ce que fait la
page de vérification, qui affiche la charge utile signée et signale chaque champ
lisible qui la contredit.
Et la clé de signature voyage dans le fichier. N'importe qui peut produire
une paire de clés Ed25519, signer une charge utile de son choix et poser sa
propre public_key à côté : ce fichier-là vérifie. Un contrôle réussi prouve
donc que le fichier est cohérent avec lui-même et inchangé depuis sa signature —
pas qu'il vient du runner qu'il nomme.
Pour combler cet écart, il faut la clé depuis une autre source que le fichier. Deux façons :
-
Nous demander. La route
GET /api/v1/public/attestations/{report_id}?fingerprint=<32 caractères hexadécimaux>répond si cette empreinte est bien celle de la clé de signature enregistrée pour ce rapport à sa réception. Sans compte, sans clé, ouverte à toute origine."key_match": falsesignifie que le fichier a été resigné par quelqu'un d'autre. Un404signifie qu'aucun rapport de ce numéro ne nous est jamais parvenu. La page de vérification propose ce contrôle sous forme d'un bouton, et c'est la seule étape qui sort de votre navigateur.Un rapport dont le contenu a été effacé au terme de la durée de consultation du forfait répond toujours : nous gardons son identifiant, sa signature et la clé qui l'a signée. La réponse porte alors un champ
purged_at, et la page l'annonce comme un rapport authentique dont le contenu a été supprimé à cette date. Une preuve téléchargée en son temps reste donc vérifiable, y compris son origine. Voir Données et conservation.Cela vaut ce que valent nos registres : vous nous demandez. C'est aussi le seul contrôle qui parle d'origine.
-
Demander au titulaire du compte. Faites-lui afficher l'empreinte de la clé publique du runner dans son compte RestoreProof, et comparez-la à celle qu'imprime la page de vérification. Celui-là ne nous fait confiance en rien.
Comment la signature est construite¶
Le runner sérialise en JSON la charge utile ci-dessus, puis :
signed_payload = ce JSON, octet pour octet
signed_payload_hash = hex( sha256( signed_payload ) )
signature = Ed25519_sign( clé privée du runner, ASCII(signed_payload_hash) )
Deux détails comptent, et sont faciles à manquer :
- La signature porte sur la chaîne hexadécimale, pas sur l'empreinte binaire.
Vous signez et vérifiez les 64 caractères ASCII de
signed_payload_hash. - Vous n'avez jamais à reproduire la sérialisation.
signed_payloadest livré tel quel dans le fichier : vous hachez exactement ce qui a été signé. C'est délibéré —probe_resultsetmetricssont aussi conservés dans une base qui normalise l'ordre des clés et le format des nombres, et les re-sérialiser ne redonnerait pas les octets d'origine de façon fiable.
sanitized_logs voyage hors de la charge utile parce qu'il peut atteindre
100 000 caractères — le runner tronque au-delà. Il n'est pas moins protégé pour
autant : la charge utile porte
logs_sha256, donc rehacher le texte est un troisième contrôle, à la portée de
tous.
Le vérifier¶
Trois contrôles. Chacun donne son sens au précédent.
- Intégrité —
sha256(signed_payload)vautsigned_payload_hash. C'est ce qui lie l'empreinte aux valeurs que vous lisez. - Authenticité — la signature Ed25519 sur
signed_payload_hashvérifie contrepublic_key. - Concordance — les champs lisibles du fichier disent la même chose que la
charge utile, et
sha256(report.sanitized_logs)vautlogs_sha256. C'est le contrôle qu'un fichier modifié échoue alors que sa signature, elle, reste parfaitement valide.
Node (sans dépendance)¶
const crypto = require('node:crypto')
const fs = require('node:fs')
const archive = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'))
const { public_key, value, signed_payload_hash, signed_payload } = archive.signature
// 1. Intégrité : l'empreinte appartient bien à ces résultats.
const recomputed = crypto.createHash('sha256').update(signed_payload, 'utf8').digest('hex')
console.log('empreinte conforme :', recomputed === signed_payload_hash)
// 2. Authenticité : le runner a signé cette empreinte.
// Node veut un objet clé Ed25519 : on habille les 32 octets bruts d'un en-tête SPKI.
const SPKI_ED25519_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
const key = crypto.createPublicKey({
key: Buffer.concat([SPKI_ED25519_PREFIX, Buffer.from(public_key, 'base64')]),
format: 'der',
type: 'spki',
})
const ok = crypto.verify(
null, // Ed25519 ne prend pas de condensat séparé
Buffer.from(signed_payload_hash, 'ascii'), // le message signé
key,
Buffer.from(value, 'base64'),
)
console.log('signature vérifiée :', ok)
// 3. Concordance : la copie lisible dit toujours ce qui a été signé.
const signed = JSON.parse(signed_payload)
const logsHash = crypto.createHash('sha256')
.update(archive.report.sanitized_logs ?? '', 'utf8').digest('hex')
console.log('journaux intacts :', logsHash === signed.logs_sha256)
console.log('plan intact :', archive.restore_plan.name === signed.plan_name)
Les quatre lignes doivent afficher true. Changez un seul octet de
signed_payload et la première passe à false ; changez un octet de la
signature, ou utilisez la clé d'un autre runner, et c'est la deuxième.
Python (cryptography)¶
import base64, hashlib, json, sys
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
archive = json.load(open(sys.argv[1]))
sig = archive["signature"]
recomputed = hashlib.sha256(sig["signed_payload"].encode()).hexdigest()
print("empreinte conforme :", recomputed == sig["signed_payload_hash"])
key = Ed25519PublicKey.from_public_bytes(base64.b64decode(sig["public_key"]))
try:
key.verify(base64.b64decode(sig["value"]), sig["signed_payload_hash"].encode())
print("signature vérifiée : True")
except InvalidSignature:
print("signature vérifiée : False")
signed = json.loads(sig["signed_payload"])
logs = hashlib.sha256(archive["report"].get("sanitized_logs", "").encode()).hexdigest()
print("journaux intacts :", logs == signed["logs_sha256"])
print("plan intact :", archive["restore_plan"]["name"] == signed["plan_name"])
Confirmer que la clé appartient au runner¶
signature.public_key est la clé que le control plane détient pour ce runner.
La même valeur est affichée sur la page du runner dans l'application, et rendue
par GET /api/v1/runners/{id} sous le champ public_key.
La moitié privée ne quitte jamais l'infrastructure du client : le runner produit
la paire à son premier démarrage et ne publie jamais que la moitié publique,
avec son battement de cœur. Pour rattacher la clé à une machine précise,
lisez-la dans le journal de démarrage de cette machine — le runner y écrit
Public Key: <base64> :
Si les deux chaînes sont identiques, le rapport a été signé par cette machine.
Les rapports anciens¶
signed_payload n'a pas toujours été conservé. Sur un rapport qui n'en a pas,
le contrôle 2 (authenticité) fonctionne encore — le runner a bien signé cette
empreinte — mais le contrôle 1 est impossible, donc l'empreinte ne peut pas être
rattachée aux résultats de sondes présents dans le fichier. Les rapports
produits depuis la version qui a introduit signed_payload le portent tous.