Le format .carnet et l’interface du serveur
Ce qu’il faut savoir pour écrire un logiciel qui relit vos données Carnet, ou qui parle au serveur de synchronisation. Cette page décrit le code tel qu’il est publié ; en cas d’écart, le code fait foi.
État décrit : code source de Carnet au 9 octobre 2026, après la version 1.0.0-beta. Format de sauvegarde carnet-sauvegarde, version 1 ; export lisible carnet-journal-dechiffre, version 1. Le code source de chaque version publiée est à l’adresse carnet.health/source/ (licence AGPLv3). Cette page est gratuite, comme tout ce qu’elle décrit (règlement (UE) 2023/2854, art. 26 let. b et art. 30 § 2 ; annexe MONDE, M-5.5).
En bref. Vos données ne sont lisibles qu’avec vos secrets. Le fichier .carnet est du JSON (souvent compressé en gzip) ; chaque opération du journal y est un bloc chiffré en ChaCha20-Poly1305 sous votre clé maîtresse ; la clé maîtresse y est elle-même chiffrée sous une clé tirée de votre mot de passe ou de votre clé de secours (Argon2id). Avec l’un de ces secrets, tout se relit avec la bibliothèque libre libsodium. Sans eux, personne ne le peut, l’éditeur compris.
Pour changer d’outil sans programmer, l’application exporte aussi le journal lisible : le même journal, déchiffré sur votre appareil, en JSON (section 4).
1. Le fichier .carnet
Nom : carnet-AAAA-MM-JJ.carnet (jour local de l’export). Le fichier commence par les octets 1f 8b s’il est compressé en gzip (le cas quand le navigateur sait compresser) ; sinon c’est du JSON brut, en UTF-8. Une fois décompressé, c’est un seul objet JSON, dont les clés sont écrites dans cet ordre :
| Clé | Type | Contenu |
|---|---|---|
format | texte | toujours "carnet-sauvegarde" ; tout autre fichier est refusé avant toute tentative de déchiffrement |
version | nombre | 1 ; un lecteur refuse une version plus récente que celle qu’il connaît |
cree | texte | instant de création, ISO 8601 (UTC), en clair |
meta | objet | l’enveloppe publique du compte (section 2) : sels et clé maîtresse chiffrée. Absente des fichiers les plus anciens (fusion possible, restauration sur un appareil vierge impossible) |
edition | texte, facultatif | l’édition qui a produit le fichier : "CH" (Suisse), "QC" (Québec), "XX" (édition internationale). écrite à partir de la version qui suit 1.0.0-beta ; absente des fichiers produits avant |
partie | objet, facultatif | {"index": i, "total": n} pour un ancien export découpé en plusieurs fichiers ; purement informatif, chaque partie se restaure seule |
blobs | tableau de textes | une opération chiffrée par entrée : toutes les lignes du journal de l’appareil, identités des patient·e·s comprises (section 3) |
medias | tableau, facultatif | {"mediaId": "…", "blob": "…"} : photos de plaies et pièces jointes, chiffrées hors du journal (section 3.3) |
quarantaine | tableau, facultatif | {"opId", "lamport", "appareil", "type", "blob"} : lignes que l’appareil n’a pas su lire, gardées telles quelles avec leur identité |
metaReleve | tableau, facultatif | {"cle", "val"}, clés préfixées releve: seulement : configuration des relèves, chaque val chiffrée sous la clé maîtresse (même forme qu’un bloc) |
Le fichier n’est pas chiffré dans son ensemble : seules la clé maîtresse et chaque entrée le sont. Il ne contient jamais le jeton d’accès au serveur, ni la configuration de synchronisation, ni le réglage biométrique de l’appareil.
2. Les clés : de vos secrets à la clé maîtresse
Toutes les valeurs binaires sont en base64 standard avec remplissage (variante ORIGINAL de libsodium : alphabet A–Z a–z 0–9 + /, signe =).
La clé maîtresse (32 octets aléatoires) chiffre tout. L’objet meta la porte sous deux ou trois enveloppes :
| Enveloppe | Sel | Secret | Coûts Argon2id |
|---|---|---|---|
envPw | saltPw | votre mot de passe, tel quel | kdfPw s’il est présent ({"ops": 3, "mem": 268435456}, « MODERATE ») ; absent : ops 2, mem 67108864 (« INTERACTIVE », comptes créés avant la version 0.83.0) |
envSec | saltSec | la clé de secours (8 mots), normalisée | INTERACTIVE |
envReleve (facultative) | saltReleve | le code dépositaire (8 mots), normalisé | INTERACTIVE |
Dérivation : clé = Argon2id13(secret, sel de 16 octets, ops, mem), sortie de 32 octets (crypto_pwhash, ALG_ARGON2ID13). Enveloppe : base64(nonce) + "." + base64(chiffré), où chiffré = ChaCha20-Poly1305-IETF(clé maîtresse, données associées vides, nonce de 12 octets, clé) (crypto_aead_chacha20poly1305_ietf_encrypt, étiquette de 16 octets comprise).
Normalisation des mots (clé de secours, code dépositaire) : NFKC, minuscules, découpage sur les espaces et les tirets (U+2010 à U+2015, U+2212 compris), chaque mot ramené à sa forme exacte dans la liste de mots BIP39 de la feuille (correspondance sans accents, unique), puis mots joints par un seul tiret -. La liste est le français si lexiqueSecours (ou lexiqueReleve) est absent ; sinon le code de liste qu’il porte. Les listes sont dans src/coffre/lexiques/.
Les autres champs de meta (version, dates creeLe…, recup, recupOptOut) sont des renseignements publics sur les portes du coffre ; recup.pkRec est une clé publique. Aucun n’est un secret.
3. Le journal
3.1 Un bloc
Chaque entrée de blobs est base64(nonce) + "." + base64(chiffré), avec chiffré = ChaCha20-Poly1305-IETF(texte, données associées vides, nonce de 12 octets, clé maîtresse). Le texte déchiffré est un objet JSON en UTF-8 : une opération. Un bloc ne dépasse pas 6 Mio une fois chiffré.
3.2 Une opération
| Champ | Contenu |
|---|---|
opId | identifiant unique (UUID) ; une opération déjà présente n’est jamais dupliquée ni réécrite |
lamport | horloge logique : l’ordre déterministe des opérations est (lamport, appareil, opId) |
appareil | identifiant de l’appareil qui a écrit |
type | nature de l’opération, par exemple visite.creer, plaie.evaluer, patient.identite |
horloge | heure murale de l’écriture, en millisecondes (indicative ; l’ordre est celui de lamport) |
sv | version de schéma, facultative (absente = 0) ; un lecteur met de côté une opération d’un schéma qu’il ne connaît pas |
ch | facultatif, {"n", "prev"} : chaîne d’intégrité par appareil — prev est le SHA-256 (hexadécimal) du bloc chiffré de l’opération précédente du même appareil, n son rang |
payload | le contenu, propre à chaque type |
Le journal est append-only : rien ne s’efface ni ne se modifie ; une correction est une opération de plus. L’état du dossier (patients, visites, plaies, factures…) se reconstitue en rejouant les opérations dans l’ordre. Les formes de payload, et la manière de les rejouer, sont celles des projections du dossier publiées dans src/domaine/ (un fichier par domaine) ; un lecteur doit tolérer les formes anciennes, qui ne sont jamais réécrites. Les identités des patient·e·s (patient.identite, patient.langue, contact.acces, client.ponctuel, facturation.accuse, opas7.accuse) sont des opérations locales : elles sont dans le .carnet, jamais sur le serveur.
3.3 Les médias
Une photo ou une pièce jointe est rangée hors du journal ; l’opération qui la cite porte son mediaId (UUID). Le blob d’un média a la même forme qu’un bloc d’opération ; son texte déchiffré est une chaîne JSON contenant le fichier en URL data: (par exemple "data:image/jpeg;base64,…"). Depuis le 5 septembre 2026, il est chiffré avec des données associées égales à media:v1: suivi du mediaId ; un lecteur essaie d’abord avec ces données associées, puis sans (photos plus anciennes). Dans les journaux les plus anciens, une photo est encore dans le payload de l’opération elle-même (champ photo, image en URL data:).
4. Le journal lisible (export déchiffré)
Le bouton « Exporter le journal lisible » (écran Sauvegarde) produit carnet-journal-dechiffre-AAAA-MM-JJ.json, en clair, déchiffré sur votre appareil :
{
"format": "carnet-journal-dechiffre",
"version": 1,
"produitLe": "…ISO 8601…",
"edition": "CH",
"nombreOperations": 1234,
"lignesNonLues": 0,
"nonCompris": ["medias", "lignes-illisibles", "secrets-releve",
"soins-releve-en-attente", "enveloppe-compte"],
"metadonneesCompte": { "etat": "jointes", "fichier": { … } },
"operations": [ { "opId": "…", "lamport": 1, "appareil": "…", "type": "…",
"horloge": 0, "payload": { … } }, … ]
}
Les opérations sont celles de la section 3.2, dans l’ordre (lamport, appareil, opId). nonCompris nomme ce qui manque : les photos et pièces (dans le .carnet), les lignes illisibles (comptées), les secrets des relèves, les soins d’une remplaçante pas encore versés au dossier, l’enveloppe du compte. metadonneesCompte.etat vaut jointes (le fichier du serveur, section 5.6), sans-synchro ou indisponibles.
5. L’interface du serveur de synchronisation
Le serveur ne reçoit que des blocs chiffrés : il ne peut rien lire. Son code est serveur-sync/ dans les sources publiées. Toutes les requêtes et réponses sont en JSON sur HTTPS ; une erreur rend {"error": "…", "code": "…"}.
5.1 Accès
Un compte est un identifiant opaque ([A-Za-z0-9_-], 22 à 64 caractères). Sa création (POST /compte/:compte/creer) exige en principe un code d’invitation et rend un jeton, une seule fois. Toute autre route du compte exige l’en-tête Authorization: Bearer <jeton>. Le serveur ne garde que l’empreinte du jeton. Il peut répondre 401 (jeton refusé), 402 (abonnement suspendu : la lecture reste ouverte pendant une sortie), 403 (écriture fermée), 429 (trop de requêtes).
5.2 Lire le journal du compte
GET /sync/:compte/pull?depuis=<seq> rend, à partir du numéro d’arrivée seq (0 au début) :
{ "ok": true, "epoch": "…", "curseur": 1000, "reste": true,
"ops": [ { "seq": 1, "opId": "…", "lamport": 1, "type": "op", "blob": "…" }, … ],
"tete": "…", "teteSeq": 1000 }
Au plus 1 000 opérations ou environ 16 Mio par page : relancer avec depuis=curseur tant que reste est vrai. Le type vu par le serveur est toujours le mot neutre op : le vrai type est dans le bloc. Si epoch change, le serveur a été restauré : repartir de depuis=0. D’autres clés peuvent s’ajouter (sortie du contrat, porte de récupération, relève) ; un lecteur les ignore. GET /sync/:compte/opids?depuis=<seq> rend seulement les opIds (par pages de 5 000), le curseur, lamportMax et reste.
5.3 Lire un média
GET /media/:compte/:mediaId rend {"ok": true, "blob": "…"} (même forme qu’en section 3.3), ou 404.
5.4 Écrire
POST /sync/:compte/push avec {"ops": [{"opId", "lamport", "type": "op", "blob"}, …]} (500 opérations et 9 Mio au plus par requête, 6 Mio par bloc) rend {"ok": true, "acceptes": n} ; une opération déjà présente est ignorée. PUT /media/:compte/:mediaId avec {"blob": "…"} dépose un média. Quota : 500 000 opérations et 4 Go par compte.
5.5 Vérifier l’intégrité
Le serveur chaîne chaque opération acceptée, dans l’ordre d’arrivée : tete₀ = sha256("carnet-chaine-v1|" + compte), puis teteᵢ = sha256(teteᵢ₋₁ + "|" + opId + "|" + lamport + "|" + sha256(blob)) (SHA-256 en hexadécimal minuscule sur l’UTF-8, lamport en décimal). GET /verif/:compte?depuis=<seq> rend tete, teteSeq et, par pages de 20 000, {"seq", "opId", "lamport", "h"} où h = sha256(blob) : de quoi recalculer la tête sans rien déchiffrer. Les opérations arrivées avant la mise en place de la chaîne (13 août 2026) y entrent à la première vérification. GET /ancres/:compte?depuis=<id> rend les ancrages quotidiens de cette tête (jour, tete, seqMax, preuve OpenTimestamps en base64 dans ots, complet).
5.6 Les métadonnées de votre compte
GET /compte/:compte/metadonnees (jeton du compte titulaire) rend en JSON ce que le serveur sait de votre compte : identifiant, dates, origine, quota, abonnement et paiements, souscription, reçus d’acceptation, relèves ouvertes, porte de récupération, jours d’horodatage, sortie. Le fichier nomme aussi ce qu’il ne contient pas ; il ne contient jamais l’empreinte de votre jeton, la part de garde ni une adresse IP.
5.7 État du service
GET /health, sans jeton, rend l’état du serveur (200 ou 503) et son epoch. Il ne contient aucune donnée.
6. Limites, dites franchement
- Aucun autre logiciel ne relit aujourd’hui le
.carnet: cette page et le code publié sont là pour qu’on puisse en écrire un. Changer de fournisseur reste complexe. - Le serveur seul ne suffit pas à reconstituer un dossier : il n’a ni les identités des patient·e·s (opérations locales), ni vos secrets. Le
.carnetou le journal lisible les ont. - La part de garde de la porte de récupération n’est pas remise : la remettre compromettrait la sécurité du service, et elle ne donne rien sans la clé de garde de l’éditeur.
- Les formats évoluent par ajouts : une clé nouvelle peut apparaître, une forme ancienne reste lisible. Toute version du format se retrouve, avec son code, sur carnet.health/source/.
Une question sur ce format : contact (at) carnet.health.