7.10.2.13. Sécurisation (traçabilité)

7.10.2.13.1. Présentation

Package parent : fr.gouv.vitam.logbook
Packages concernés : fr.gouv.vitam.logbook.common.traceability, fr.gouv.vitam.logbook.administration.core, fr.gouv.vitam.worker.core.plugin.lfc_traceability

La sécurisation fige périodiquement le contenu des journaux dans un fichier horodaté, stocké sur les offres. Elle permet de démontrer a posteriori qu’aucune entrée n’a été modifiée ni supprimée. Trois périmètres sont sécurisés indépendamment :

  • le journal des opérations (TraceabilityType.OPERATION) ;
  • le journal du cycle de vie des unités archivistiques (TraceabilityType.UNIT_LIFECYCLE) ;
  • le journal du cycle de vie des groupes d’objets (TraceabilityType.OBJECTGROUP_LIFECYCLE).

Le journal des écritures de stockage (TraceabilityType.STORAGE) suit le même algorithme générique, mais il est porté par le module storage-engine-server (StorageTraceabilityAdministration, LogbookStorageTraceabilityHelper). Il n’est pas décrit ici : se référer à la documentation du module storage.

Modules impliqués :

  • common-private : briques cryptographiques transverses, arbre de Merkle (security.merkletree) et production du jeton d’horodatage (timestamp) ;
  • logbook-common : algorithme générique (TraceabilityService), contrat LogbookTraceabilityHelper, écriture du zip (TraceabilityFile), modèle TraceabilityEvent ;
  • logbook-administration : sécurisation du journal des opérations (LogbookAdministration, LogbookOperationTraceabilityHelper) ;
  • logbook-administration-lfc : déclenchement de la sécurisation des cycles de vie (LogbookLFCAdministration, LfcTraceabilityType) ;
  • worker-core (plugin.lfc_traceability) : plugins PREPARE / BUILD / GENERATE / FINALIZE et croisement d’empreintes (DigestValidator) ;
  • processing-management : définition des workflows DefaultUnitLifecycleTraceability.json et DefaultObjectGroupLifecycleTraceability.json ;
  • scheduler-server : jobs Quartz TraceabilityJob et TraceabilityLFCJob.

La documentation métier (finalité, valeur probante, exploitation fonctionnelle) n’est pas reprise ici : se référer à la documentation vitam-doc.

7.10.2.13.1.1. Vue d’ensemble

Deux déclencheurs et deux chemins d’exécution, mais un seul algorithme et un seul format d’archive :

                        scheduler-server
                               |
              +----------------+-----------------+
              |                                  |
       TraceabilityJob                   TraceabilityLFCJob
              |                                  |
              v                                  v
    LogbookAdministration            LogbookLFCAdministration
 (dans le serveur logbook,                       |
      sans workflow)               workflow LOGBOOK_*_LFC_TRACEABILITY
              |                    PREPARE > BUILD > GENERATE > FINALIZE
              |                       (distribué sur les workers)
              +----------------+-----------------+
                               v
                       TraceabilityService
                               |
                    LogbookTraceabilityHelper
                     (spécifique au périmètre)
                               |
          +--------------------+--------------------+
          v                    v                    v
      sélection          MerkleTreeAlgo       TimeStampService
(_lastPersistedDate)            |            + TimestampGenerator
          |                     |                    |
          v                     v                    v
      data.txt           merkleTree.json          token.tsp
                               |
                               v
             archive zip sur les offres + TraceabilityEvent
                       (evDetData de l'opération)

Le journal des opérations est sécurisé directement dans le serveur logbook ; les cycles de vie passent par un workflow, leur volumétrie et les accès aux métadonnées et aux offres imposant de distribuer le travail. À partir de TraceabilityService, le traitement est strictement le même.

7.10.2.13.2. Algorithme générique

L’algorithme est unique, porté par TraceabilityService. Il est paramétré par une implémentation de LogbookTraceabilityHelper propre à chaque périmètre. Le point d’entrée est secureData(strategyId).

7.10.2.13.2.1. Déroulement

Vue synthétique de l’enchaînement, du point d’entrée jusqu’à la journalisation :

secureData(strategyId)
    |
    +-- helper.startTraceability()
    |
    +-- création du TraceabilityFile (zip temporaire, sans compression)
    |
    +-- helper.saveDataInZip(merkleAlgo, traceabilityFile)
    |       |
    |       +-- sélection des entrées (par _lastPersistedDate)
    |       +-- sérialisation CanonicalJsonFormatter
    |       +-- écriture d'une ligne dans data.txt
    |       +-- merkleAlgo.addLeaf(...)        <-- feuille ajoutée au vol
    |
    +-- merkleAlgo.generateMerkle()            --> merkleTree.json
    |       (si aucune entrée : suppression du zip, helper.saveEmpty(), arrêt)
    |
    +-- timeStampService.getDigestFrom(racine, jetons précédent / M-1 / A-1)
    |
    +-- TimestampGenerator (tampon RFC 3161)   --> token.tsp
    |
    +-- écriture de computing_information.txt et additional_information.txt
    |
    +-- helper.storeAndDeleteZip(...)
    |
    +-- helper.createLogbookOperationEvent(...) --> TraceabilityEvent en evDetData

Le détail de chaque étape :

  1. helper.startTraceability() : ouverture ou rattachement de l’opération de traçabilité dans le journal des opérations.
  2. Création du zip temporaire dans le dossier de travail, nommé <tenant>_<zipName>_<yyyyMMdd_HHmmss>.zip, où zipName est fourni par le helper et la date est la date de fin de traçabilité. TraceabilityFile écrit l’archive sans compression (setLevel(0)), le fichier étant destiné à être re-parcouru ligne à ligne lors des vérifications.
  3. helper.saveDataInZip(merkleAlgo, traceabilityFile) : écriture de data.txt, une ligne par entrée sécurisée sérialisée en JSON canonique (CanonicalJsonFormatter). Chaque ligne est simultanément ajoutée comme feuille de l’arbre de Merkle.
  4. merkleAlgo.generateMerkle() : construction de l’arbre, sérialisé dans merkleTree.json. Si l’arbre est null, c’est-à-dire s’il n’y a aucune entrée à sécuriser, le zip est supprimé, helper.saveEmpty(tenantId) est appelé et le traitement s’arrête.
  5. Horodatage : l’empreinte à horodater est calculée par TimeStampService sur la concaténation de la racine de Merkle et des tampons des traçabilités précédente, M-1 et A-1, les valeurs nulles étant ignorées. Le tampon RFC 3161 est produit par TimestampGenerator puis écrit en base 64 dans token.tsp. Les valeurs d’entrée du calcul sont conservées en clair dans computing_information.txt.
  6. additional_information.txt : numberOfElements, startDate, endDate et securisationVersion.
  7. helper.storeAndDeleteZip(...) : dépôt du zip, directement sur les offres pour le journal des opérations, via le workspace pour les cycles de vie, puis suppression du fichier temporaire.
  8. helper.createLogbookOperationEvent(...) : journalisation de l’événement final, portant le TraceabilityEvent en evDetData.

7.10.2.13.2.2. Contenu du zip

data.txt                     # une ligne JSON canonique par entrée sécurisée
merkleTree.json              # arbre de Merkle sérialisé
token.tsp                    # tampon RFC 3161 (base 64)
computing_information.txt    # racine courante + tampons J-1 / M-1 / A-1
additional_information.txt   # nombre d'entrées, bornes, SecurisationVersion

Le détail de chaque fichier est donné dans Contenu de l’archive sécurisée.

Le TraceabilityEvent enregistré en evDetData reprend ces informations sous forme exploitable : LogType, StartDate et EndDate, Hash (racine de Merkle), TimeStampToken, les identifiants et dates des traçabilités précédentes (PreviousLogbookOperationId, MinusOneMonthLogbookOperationId, MinusOneYearLogbookOperationId, …), NumberOfElements, FileName, Size, DigestAlgorithm, MaxEntriesReached, SecurisationVersion et Statistics.

7.10.2.13.2.3. Rôle de LogbookTraceabilityHelper

L’interface concentre tout ce qui dépend du périmètre sécurisé :

  • sélection et écriture des données : saveDataInZip, getDataSize ;
  • bornes temporelles : getTraceabilityStartDate, getTraceabilityEndDate ;
  • chaînage : getPrevious*OperationId, getPrevious*StartDate, getPrevious*TimestampToken, pour les trois profondeurs (précédente, M-1, A-1) ;
  • journalisation : startTraceability, createLogbookOperationEvent, saveEmpty, getStepName, getTimestampStepName ;
  • stockage : storeAndDeleteZip, getZipName ;
  • métadonnées de l’événement : getTraceabilityType, getMaxEntriesReached, getTraceabilityStatistics, getSecurisationVersion.

La méthode par défaut decorateZipName(zipName, operationId) construit le nom de base de l’archive. Le nom historique est conservé pour la version de sécurisation par défaut ; il est suffixé par _<version>_<GUID> pour toute autre version. La date étant ajoutée ensuite par l’appelant, l’analyse de date de StorageFileNameHelper n’est pas impactée.

7.10.2.13.3. Périmètre sécurisé

Le périmètre sécurisé est l’ensemble des entrées prises en compte par une opération de traçabilité. Il n’est pas défini par une liste d’identifiants, mais par un intervalle temporel évalué sur la date de dernière persistance de chaque entrée (_lastPersistedDate), et non sur sa date d’événement (evDateTime).

_lastPersistedDate est la date d’écriture effective en base, mise à jour à chaque modification du document. Une entrée ne peut donc pas apparaître dans un intervalle déjà traité après le passage de la traçabilité, ce que la date d’événement ne garantirait pas : une opération peut être journalisée avec un evDateTime antérieur à son écriture.

7.10.2.13.3.1. Bornes de l’intervalle

Borne inférieure
Date de fin (EndDate) de la dernière traçabilité OK du même périmètre et de la même version de sécurisation, ce qui assure la continuité du chaînage. Pour la première traçabilité, la borne est l’époque (LogbookTraceabilityHelper.INITIAL_START_DATE).
Borne supérieure
now diminué du délai de temporisation, 300 s par défaut. Les documents du journal sont enrichis au fil des événements. Sécuriser une entrée trop récente reviendrait à figer un document encore en cours d’écriture, dont l’empreinte évoluerait ensuite. La temporisation limite la sécurisation aux documents stabilisés.

Les deux bornes sont incluses (>= et <=). Une entrée dont la date de persistance tombe exactement sur la borne peut donc être sécurisée par deux opérations consécutives. Ce recouvrement est volontaire, un doublon étant préférable à un trou dans la couverture.

7.10.2.13.3.2. Continuité de la couverture

La borne supérieure d’une opération devient la borne inférieure de la suivante. Bout à bout, les périmètres couvrent la totalité du journal sans discontinuité.

Une traçabilité vide, sans aucune entrée dans l’intervalle, ne produit ni archive ni empreinte, et ne peut donc pas servir de borne. La recherche de la borne inférieure des cycles de vie porte pour cette raison sur la dernière traçabilité disposant d’un zip (paramètre traceabilityWithZipOnly), et non sur la dernière traçabilité OK.

7.10.2.13.3.3. Réduction du périmètre par le plafond

Le nombre d’entrées retenues par opération est plafonné : operationTraceabilityMaxEntries pour le journal des opérations, lifecycleTraceabilityMaxEntries pour les cycles de vie, 100 000 par défaut dans les deux cas. Lorsque le plafond est atteint, la borne supérieure est ramenée au _lastPersistedDate de la dernière entrée traitée et MaxEntriesReached est positionné dans le TraceabilityEvent. La traçabilité suivante repart de cette borne.

Le plafond est une limite souple. Une fois atteint, la sélection continue de renvoyer les entrées dont la date de persistance est identique à celle de la dernière entrée retenue, dans la limite d’une réserve de 10 000 entrées (OVERFLOW_LIMIT). Un groupe d’entrées partageant la même date de persistance n’est ainsi jamais coupé en deux, la borne de reprise étant cette même date.

La sélection est réalisée par lecture directe des collections MongoDB (selectRawByLastPersistenceDateInterval), sans passer par l’index de recherche ni par le DSL, dont la limite de résultats par défaut est de 10 000 documents.

7.10.2.13.3.4. Cloisonnement et limites

La sélection est filtrée sur le tenant. Il y a donc un périmètre, un chaînage et une version de sécurisation par tenant et par périmètre : journal des opérations, cycles de vie des unités, cycles de vie des groupes d’objets.

Le périmètre ne couvre pas :

  • les objets binaires, qui ne sont jamais intégrés à l’archive ; seules leurs empreintes le sont, via la traçabilité des cycles de vie de groupes d’objets ;
  • les métadonnées pour elles-mêmes ; elles sont couvertes indirectement, par l’empreinte portée dans la ligne du cycle de vie correspondant ;
  • le journal des écritures de stockage, qui relève d’un périmètre distinct porté par le module storage.

7.10.2.13.4. Contenu de l’archive sécurisée

L’archive est un zip non compressé de cinq fichiers. Les fichiers traceabilityEvent.json et traceabilityStats.json, présents dans les workflows de cycle de vie, n’en font pas partie : ce sont des fichiers de travail du workspace, dont le contenu est repris dans l”evDetData de l’opération.

7.10.2.13.4.1. data.txt

data.txt est le seul fichier porteur de données ; les quatre autres portent la preuve qui s’y rapporte. Il contient une ligne par entrée sécurisée, séparée par \n, chaque ligne étant sérialisée par CanonicalJsonFormatter.

La sérialisation canonique rend l’empreinte reproductible : clés d’objet triées, aucune indentation ni espacement, échappement restreint à un jeu fixe de caractères, caractères non imprimables en \uXXXX, entiers écrits tels quels. Cet encodage ne doit pas évoluer, sous peine de rendre non reproductibles, donc invérifiables, les empreintes des archives antérieures.

L’ordre des lignes est celui de la sélection, par date de persistance croissante. Il est signifiant, chaque ligne étant une feuille de l’arbre de Merkle dans cet ordre.

Le contenu d’une ligne dépend du périmètre.

7.10.2.13.4.1.1. Cas du journal des opérations

La ligne est le document logbook brut et complet, tel qu’il est stocké en base : aucun champ n’est retiré, aucune valeur n’est transformée. On y trouve :

  • les identifiants et le contexte d’appel : _id, evId, evParentId, evIdProc (identifiant du processus), evIdReq (identifiant de requête) ;
  • le typage : evType, evTypeProc ;
  • la date de l’événement : evDateTime ;
  • le résultat : outcome, outDetail, outMessg ;
  • les agents : agId, agIdApp, agIdPers, agIdExt, evIdAppSession ;
  • les objets concernés : obId, obIdReq, obIdIn ;
  • les données de détail : evDetData, rightsStatementIdentifier ;
  • le tableau events, contenant tous les sous-événements de l’opération, chacun portant les mêmes champs ;
  • les champs techniques : _tenant, _v et _lastPersistedDate.

_lastPersistedDate étant le champ qui délimite le périmètre, son inclusion dans l’empreinte interdit de déplacer une entrée d’un périmètre à l’autre après sécurisation.

Pour la liste exhaustive des champs et leur correspondance métier (PREMIS), se référer au Modèle de données de l’architecture fonctionnelle du logbook.

7.10.2.13.4.1.2. Cas des cycles de vie

La ligne n’est pas le document de cycle de vie, mais un condensé structuré (LifeCycleTraceabilitySecureFileObject). La volumétrie des cycles de vie interdit de les recopier intégralement, et le périmètre doit également couvrir la métadonnée associée ainsi que, pour les groupes d’objets, les objets binaires.

Champ Contenu
lEvtIdProc identifiant de l’opération (evIdProc) portée par le cycle de vie
lEvTypeProc type de processus du dernier événement du cycle de vie
lEvDTime date du dernier événement du cycle de vie
lfcId identifiant du cycle de vie, égal à celui de l’unité ou du groupe d’objets
mdType UNIT ou OBJECTGROUP
version version (_v) du document de cycle de vie
ltEvtOutcome outcome du dernier événement du cycle de vie
hLFC empreinte du document de cycle de vie complet
hLFCEvts empreinte du seul tableau events du cycle de vie
hMetadata empreinte de la métadonnée, après retrait des champs calculés
hGlobalFStorage empreinte de la métadonnée retenue après croisement avec les offres
hGlobalDetails strategyId et offerIds interrogées ; en cas d’incohérence seulement, dbHash et offerHash (empreinte par offre)
hOGDocsStorage groupes d’objets uniquement : par objet binaire, id, hObject et hDetails (même structure que hGlobalDetails). Les objets physiques sont ignorés, n’ayant pas de contenu stocké
up identifiants des parents directs (_up) de l’unité
idOG unités uniquement : identifiant du groupe d’objets rattaché (_og)
{
  "lEvtIdProc": "aeeaaaaaa...", "lEvTypeProc": "INGEST", "lEvDTime": "2026-05-12T09:41:07.213",
  "lfcId": "aeaqaaaaa...", "mdType": "UNIT", "version": 3, "ltEvtOutcome": "OK",
  "hLFC": "...", "hLFCEvts": "...", "hMetadata": "...",
  "hGlobalFStorage": "...",
  "hGlobalDetails": { "strategyId": "default", "offerIds": ["offer-fs-1"] },
  "up": ["aeaqaaaaa..."], "idOG": "aebaaaaaa..."
}

hLFC couvre le document de cycle de vie entier, hLFCEvts uniquement ses événements. La présence des deux empreintes permet, lors d’un contrôle, de distinguer une altération du corps du cycle de vie d’une altération de sa liste d’événements.

Les champs calculés sont retirés de la métadonnée avant calcul de hMetadata. Il s’agit des champs recalculables par la plateforme, dont la valeur peut changer sans que l’archive ait été modifiée : champs de graphe (_us, _sps, _graph, _glpd, _min, _max, _uds, _computedInheritedRules, _validComputedInheritedRules, _vups) et champs temporaires ou techniques (_elimination, _av, _acd, _aud, _opts, _batchId, _uploadPath, _errors, _ogInfo) pour les unités ; _sps, _us, _glpd, _av, _acd, _aud, _batchId et _errors pour les groupes d’objets.

Note

Pourquoi retirer ces champs ? Ils sont recalculés par la plateforme et peuvent donc changer sans qu’aucune donnée métier n’ait été modifiée. S’ils étaient inclus dans hMetadata, un simple recalcul de graphe suffirait à faire diverger l’empreinte de la valeur scellée, et donc à faire échouer une vérification sur une archive pourtant intacte.

hGlobalFStorage vaut l’empreinte calculée en base lorsque toutes les offres de la stratégie renvoient la même valeur, et la chaîne littérale INVALID_HASH dès qu’une offre diverge, une empreinte absente étant traitée comme divergente. Une incohérence constatée lors de la sécurisation est ainsi scellée dans l’archive, et pas seulement signalée dans les journaux.

7.10.2.13.4.2. merkleTree.json

Arbre de Merkle complet des lignes de data.txt, sérialisé en JSON. Voir Arbre de Merkle pour sa construction et son format.

7.10.2.13.4.3. token.tsp

Jeton d’horodatage RFC 3161 de la traçabilité courante, encodé en base 64. Il contient la date d’horodatage, l’empreinte horodatée et la chaîne de certificats du signataire. Voir Horodatage et signature.

7.10.2.13.4.4. computing_information.txt

Fichier de propriétés (clé=valeur) donnant les quatre valeurs en entrée du calcul de l’empreinte horodatée. Il permet de refaire ce calcul sans accès à la base :

currentHash=<racine de Merkle de la traçabilité courante, base 64>
previousTimestampToken=<jeton de la traçabilité précédente, base 64>
previousTimestampTokenMinusOneMonth=<jeton de la traçabilité de M-1, base 64>
previousTimestampTokenMinusOneYear=<jeton de la traçabilité de A-1, base 64>

Les trois dernières valeurs sont sérialisées à null lorsqu’il n’existe pas de traçabilité correspondante, cas des premières sécurisations d’une chaîne.

7.10.2.13.4.5. additional_information.txt

Fichier de propriétés décrivant le périmètre couvert par l’archive :

numberOfElements=<nombre de lignes de data.txt>
startDate=<borne inférieure du périmètre>
endDate=<borne supérieure du périmètre>
securisationVersion=<version de sécurisation, ex. V1>

Ces informations sont redondantes avec le TraceabilityEvent journalisé. L’archive reste ainsi exploitable indépendamment de la base.

7.10.2.13.5. Arbre de Merkle

Package : fr.gouv.vitam.common.security.merkletree

L’arbre de Merkle est un arbre binaire d’empreintes qui condense l’ensemble des entrées sécurisées en une valeur unique, sa racine, tout en permettant de localiser les entrées responsables d’une divergence. C’est cette racine, et non les données, qui est horodatée : la preuve porte sur un condensé de taille constante, quelle que soit la volumétrie sécurisée.

7.10.2.13.5.1. Construction

MerkleTreeAlgo est alimenté en flux, pendant l’écriture de data.txt. Chaque ligne, une entrée sérialisée en JSON canonique, est ajoutée par addLeaf(byte[]), qui stocke non pas la donnée mais son empreinte. L’algorithme de hachage est celui de la plateforme (VitamConfiguration.getDefaultDigestType(), SHA512 par défaut).

generateMerkle() construit ensuite l’arbre :

  1. Complétion (addPadding) : le nombre de feuilles est complété jusqu’à la puissance de deux immédiatement supérieure, avec la feuille vide MerkleTree.EMPTY_LEAF, de racine de longueur nulle. Un nombre de feuilles déjà égal à une puissance de deux n’est pas modifié.
  2. Remontée par paires : à chaque niveau, les feuilles sont consommées deux par deux et l’empreinte du nœud parent est calculée sur la concaténation des racines gauche puis droite. L’opération est répétée jusqu’à ne conserver qu’un seul nœud, dont la racine est la racine de l’arbre.
           racine = H(H12 || H34)
             /                  \
    H12 = H(h1 || h2)     H34 = H(h3 || h4)
     /          \           /          \
h1=H(l1)     h2=H(l2)   h3=H(l3)    h4=H(l4)   # l1..l4 : lignes de data.txt

L’ordre des feuilles est celui des lignes de data.txt. Toute modification, suppression, insertion ou permutation de ligne change la racine.

7.10.2.13.5.2. Propagation d’une altération

L’intérêt de la structure tient à cette propagation : une modification, même d’un seul caractère d’une seule ligne, remonte mécaniquement jusqu’à la racine. En reprenant l’arbre ci-dessus, si la ligne l2 est altérée en l2' :

  1. h2' = H(l2') != h2 : l’empreinte de la feuille change ;
  2. H12' = H(h1 || h2') != H12 : l’empreinte du nœud parent change ;
  3. racine' = H(H12' || H34) != racine : la racine change.

Le contrôle CHECK_MERKLE_TREE recalcule la racine à partir du data.txt de l’archive et constate l’écart avec merkleTree.json et avec le Hash journalisé : l’action est KO avec l’erreur INCORRECT_MERKLE_TREE. Comme l’arbre entier est conservé, la comparaison branche par branche localise la ou les lignes en cause.

7.10.2.13.5.3. Sérialisation

L’arbre est sérialisé en entier dans merkleTree.json, et non seulement sa racine, ce qui permet à un contrôle ultérieur de descendre dans l’arbre pour isoler la branche fautive. Les nœuds fils portent les noms courts l et r afin de limiter la taille du fichier, la racine étant sérialisée en base 64 :

{
  "Root": "eqA0h...=",
  "l": { "Root": "...", "l": { "Root": "..." }, "r": { "Root": "..." } },
  "r": { "Root": "...", "l": { "Root": "..." }, "r": { "Root": "..." } }
}

La racine est également reprise, en base 64, dans computing_information.txt (currentHash) et dans le TraceabilityEvent (Hash). Elle est donc présente à la fois dans l’archive sécurisée et dans le journal indexé, ce qui rend les deux comparables.

7.10.2.13.5.4. Vérification

VerifyMerkleTreeActionHandler (action CHECK_MERKLE_TREE du workflow de contrôle) recalcule l’arbre à partir du data.txt extrait de l’archive, avec l’algorithme d’empreinte déclaré dans le TraceabilityEvent (DigestAlgorithm) et non avec celui configuré au moment du contrôle, de façon à ce que les anciennes archives restent vérifiables. Le découpage en feuilles se fait sur le séparateur de ligne ; un reliquat sans saut de ligne final est traité comme une dernière feuille.

La racine recalculée est confrontée à deux références, chacune donnant lieu à une sous-action :

  • COMPARE_MERKLE_HASH_WITH_SAVED_HASH : comparaison avec la racine de merkleTree.json, donc avec l’archive elle-même ;
  • COMPARE_MERKLE_HASH_WITH_INDEXED_HASH : comparaison avec le champ Hash du TraceabilityEvent journalisé.

Un écart sur l’une des deux comparaisons place l’action en KO, avec l’erreur INCORRECT_MERKLE_TREE dans le rapport, et pose un drapeau d’erreur qui court-circuite les plugins suivants pour cette archive.

7.10.2.13.6. Horodatage et signature

Packages : fr.gouv.vitam.common.timestamp, fr.gouv.vitam.logbook.common.traceability

L’horodatage produit un jeton RFC 3161 signé, attestant qu’une empreinte donnée existait à une date donnée. VITAM ne s’appuie pas sur une autorité d’horodatage externe : il tient le rôle de TSA avec un couple clé privée et certificat, stocké dans un keystore PKCS#12 local.

7.10.2.13.6.1. Empreinte horodatée et chaînage

L’empreinte soumise à l’horodatage n’est pas la seule racine de Merkle. TimeStampService calcule un condensé sur la concaténation de quatre valeurs, dans cet ordre :

byte[] hashDigest = timeStampService.getDigestFrom(
    merkleRootHash,          // racine de l'arbre de la traçabilité courante
    previousTimestampToken,  // jeton de la traçabilité précédente
    previousMonthToken,      // jeton de la traçabilité de M-1
    previousYearToken);      // jeton de la traçabilité de A-1

Soit :

empreinte horodatée = H( racine de Merkle || jeton J-1 || jeton M-1 || jeton A-1 )

Les valeurs nulles sont ignorées, ce qui couvre le cas des premières traçabilités d’une chaîne. L’intégration des jetons antérieurs dans l’empreinte fait que chaque jeton scelle la chaîne qui le précède : une traçabilité passée ne peut pas être réécrite sans invalider les jetons postérieurs. Les trois profondeurs (précédente, M-1, A-1) rendent le chaînage robuste à la perte d’un maillon intermédiaire.

7.10.2.13.6.2. Génération du jeton

TimestampGenerator construit la requête d’horodatage et délègue la signature à une implémentation de l’interface TimeStampSignature :

  • la requête porte l’OID de l’algorithme d’empreinte (VitamConfiguration.getDefaultTimestampDigestType(), SHA512 par défaut) et l’empreinte à horodater ;
  • setCertReq(true) demande l’inclusion du certificat signataire dans le jeton, nécessaire à une vérification autonome ultérieure ;
  • le nonce n’est pas utilisé ; sa mise en place impliquerait d’adapter la vérification (VerifyTimeStampActionHandler) ;
  • la réponse est validée contre la requête (TimeStampResponse.validate) avant retour du jeton encodé.

TimeStampSignatureWithKeystore est l’implémentation utilisée en production :

  • chargement d’un keystore PKCS#12, qui doit contenir un seul alias ; dans le cas contraire l’initialisation échoue, la clé de signature à employer étant ambiguë ;
  • récupération de la clé privée et de la chaîne de certificats associée ;
  • l’algorithme de signature est déduit du couple algorithme d’empreinte et algorithme de clé, sous la forme <digest>With<algorithmeDeClé>, par exemple SHA512WithRSA ;
  • la chaîne de certificats complète est ajoutée au jeton, la politique d’horodatage est portée par l’OID 1.1 et la date d’horodatage (genTime) est l’heure courante du serveur.

Le jeton est écrit en base 64 dans token.tsp et repris dans le TraceabilityEvent (champ TimeStampToken). L’événement OP_SECURISATION_TIMESTAMP, ou son équivalent pour les cycles de vie, porte le statut de cette étape. Un échec d’horodatage est FATAL et interrompt la sécurisation.

7.10.2.13.6.3. Certificats et configuration

La signature est réalisée là où la traçabilité est calculée, donc dans deux composants :

  • le logbook, pour le journal des opérations : p12LogbookFile et p12LogbookPassword dans logbook.conf ;
  • le worker, pour les cycles de vie (plugin GENERATE_*_LFC_TRACEABILITY) : les mêmes clés, lues dans verify-timestamp.conf.

Les certificats de timestamping sont produits par la PKI de déploiement à partir de environments/certs/timestamping/, qui porte des CA racine et intermédiaire dédiées et les certificats sous vitam/. Ils sont convertis en keystores keystores/timestamping/keystore_<usage>.p12 par generate_stores.sh. Le fichier déployé auprès des composants est keystore_secure-logbook.p12, son mot de passe étant porté par le vault Ansible.

7.10.2.13.6.4. Vérification du jeton

VerifyTimeStampActionHandler (action VERIFY_TIMESTAMP) procède en trois temps, chacun matérialisé par une sous-action et un code d’erreur dans le rapport :

COMPARE_TOKEN_TIMESTAMP
Le jeton contenu dans token.tsp est comparé à celui journalisé dans l”evDetData de l’opération. Une divergence signale une incohérence entre l’archive et le journal (INEQUAL_TIMESTAMP).
VALIDATE_TOKEN_TIMESTAMP

Validation cryptographique du jeton :

  • extraction du certificat signataire embarqué dans le jeton, sélectionné par l’identifiant du signataire ; son absence donne MISSING_SIGNING_CERTIFICATE ;
  • vérification de la signature du jeton avec ce certificat ;
  • contrôle de l’attribut signé SigningCertificate ou SigningCertificateV2 : l’empreinte du certificat qu’il référence doit correspondre au certificat effectivement utilisé, ce qui interdit la substitution d’un certificat dans le jeton ;
  • lorsqu’un truststore est configuré, validation de la confiance accordée au certificat.
VERIFY_TOKEN_TIMESTAMP
Vérification que le jeton horodate les données de l’archive : la racine de merkleTree.json doit être égale au currentHash de computing_information.txt, puis l’empreinte est recalculée sur les quatre valeurs de chaînage lues dans computing_information.txt et comparée au messageImprintDigest extrait du jeton. Tout écart donne INVALID_TIMESTAMP.

7.10.2.13.6.5. Validation de la confiance

TimestampCertificateValidator évalue le certificat signataire vis-à-vis d’un truststore fourni par l’exploitant. La date de référence est la date de génération du jeton (genTime) et non la date du contrôle : un certificat expiré depuis la sécurisation reste valide pour les traçabilités qu’il a signées. Deux contrôles sont enchaînés :

  1. validité temporelle du certificat à la date genTime ; un certificat non encore valide ou déjà expiré à cette date donne CERTIFICATE_NOT_VALID_AT_SIGNATURE_DATE ;
  2. confiance : soit le certificat signataire est lui-même présent dans le truststore (confiance directe, cas d’un certificat feuille non auto-signé), soit sa chaîne est validée par PKIX contre les ancres du truststore, avec la révocation désactivée et genTime comme date de validation. Un échec donne UNSUPPORTED_CERTIFICATE.

Le truststore est optionnel et désactivé par défaut. Il est déclaré par timestampTrustStoreFile et timestampTrustStorePassword dans verify-timestamp.conf, généré par generate_stores.sh à partir des certificats déposés dans environments/certs/timestamping/trusted/, et activé par la variable Ansible vitam.worker.timestamp_truststore_enabled. En l’absence de truststore, la signature du jeton est vérifiée sans ancrage de confiance : un jeton signé par un certificat arbitraire passe le contrôle. Lorsqu’un truststore est configuré, tout échec de chargement est bloquant au démarrage du worker, afin de ne pas dégrader le contrôle sans que ce soit visible.

7.10.2.13.7. Journal des opérations

La sécurisation du journal des opérations est exécutée directement dans le serveur logbook, sans workflow : le job TraceabilityJob appelle l’API du logbook, qui invoque LogbookAdministration.

7.10.2.13.7.1. Déclenchement et sélection

generateSecureLogbook(tenantId) génère le GUID de l’opération, le pose comme identifiant de requête de la session, résout la version de sécurisation du tenant, puis instancie LogbookOperationTraceabilityHelper. generateSecureLogbooks(tenants) parallélise le traitement sur les tenants, avec un pool dimensionné par operationTraceabilityThreadPoolSize, et lève une alerte pour chaque tenant en échec.

initialize() détermine les bornes :

  • startDate = date de fin (EndDate) de la dernière traçabilité OK de la même version, ou l’époque s’il n’en existe aucune ;
  • endDate = now diminué du délai de temporisation (operationTraceabilityTemporizationDelay, 300 s par défaut).

isTraceabilityOperationRequired() décide de l’exécution. La traçabilité est requise si la dernière est plus ancienne que le délai de renouvellement maximal (operationTraceabilityMaxRenewalDelay, 690 minutes par défaut), ou s’il existe de nouvelles opérations éligibles dans l’intervalle. Sinon la sécurisation est ignorée et aucune opération n’est produite.

Les entrées sont sélectionnées par date de persistance (selectOperationsByLastPersistenceDateInterval) et sécurisées en tant qu’opérations complètes : le document logbook entier, avec l’ensemble de ses événements, est écrit dans data.txt.

7.10.2.13.7.2. Plafonnement

La sélection est bornée par operationTraceabilityMaxEntries, 100 000 par défaut. Le mécanisme de réduction de la borne haute et de reprise est décrit dans Réduction du périmètre par le plafond. À la différence des cycles de vie, aucun enchaînement automatique n’est déclenché : le reliquat est traité à l’exécution suivante du job.

7.10.2.13.7.3. Événements produits

  • STP_OP_SECURISATION : événement maître, STARTED à l’ouverture, OK en fin de traitement, WARNING lorsqu’il n’y avait rien à sécuriser ;
  • OP_SECURISATION_TIMESTAMP : horodatage ;
  • OP_SECURISATION_STORAGE : dépôt du zip. Le fichier transite par un conteneur workspace nommé d’après l’identifiant de requête, puis est copié en DataCategory.LOGBOOK sur la stratégie de stockage par défaut, avant suppression du conteneur.

7.10.2.13.8. LFC Unit et LFC GOT

À la différence du journal des opérations, la sécurisation des cycles de vie passe par un workflow. La volumétrie et les accès aux métadonnées et aux offres nécessitent la distribution du travail sur les workers.

7.10.2.13.8.1. Déclenchement

Le job TraceabilityLFCJob (@DisallowConcurrentExecution, paramétré par un item UNIT ou OBJECTGROUP) traite tous les tenants en parallèle et appelle, pour chacun, traceabilityLfcUnit() ou traceabilityLfcObjectGroup() sur le client logbook, ce qui aboutit à LogbookLFCAdministration.generateSecureLogbookLFC(guid, lfcTraceabilityType).

Cette méthode ne démarre pas si un processus du même workflow est encore en cours. Elle évalue ensuite isNewTraceabilityRequired à partir de la dernière traçabilité de la version courante et de la dernière traçabilité disposant d’un zip, une traçabilité vide n’ayant pas de FileName et ne pouvant donc pas servir de borne. La traçabilité est requise si :

  • c’est la première traçabilité de ce type ; ou
  • la dernière est plus ancienne que lifecycleTraceabilityMaxRenewalDelay ; ou
  • la dernière avait atteint son plafond (MaxEntriesReached) ; ou
  • de nouveaux cycles de vie existent depuis la borne, à la temporisation près (lifecycleTraceabilityTemporizationDelay).

Le workflow est alors démarré : création du conteneur workspace, événement STARTED, initVitamProcess puis RESUME. Les délais de temporisation et le plafond d’entrées sont transmis en paramètres d’exécution.

Le job attend la fin de l’opération en interrogeant le logbook (checkLifecycleTraceabilityWorkflowStatus), avec une temporisation doublée à chaque itération, de 1 s à 60 s au maximum. L’attente est abandonnée au bout de 30 minutes, ou dès que le processus est en PAUSE. Si le statut retourné indique maxEntriesReached, le job relance un tour complet, jusqu’à épuisement du reliquat.

7.10.2.13.8.2. Workflows

Les workflows LOGBOOK_UNIT_LFC_TRACEABILITY et LOGBOOK_OBJECTGROUP_LFC_TRACEABILITY comportent deux étapes, toutes deux en distribution REF :

STP_UNIT_LFC_TRACEABILITY (resp. STP_OG_LFC_TRACEABILITY)
  CHECK_CONCURRENT_WORKFLOW_LOCK   # verrou sur l'identifiant de workflow
  PREPARE_UNIT_LFC_TRACEABILITY    # -> lastOperation.json, traceabilityInformation.json,
                                   #    lfcWithMetadata.jsonl
  BUILD_UNIT_LFC_TRACEABILITY      # -> traceabilityData.jsonl, traceabilityStats.json
  GENERATE_UNIT_LFC_TRACEABILITY   # -> traceabilityEvent.json, traceabilityFile.zip

STP_UNIT_LFC_TRACEABILITY_FINALIZATION (non annulable)
  FINALIZE_UNIT_LFC_TRACEABILITY   # dépôt du zip en DataCategory.LOGBOOK

CHECK_CONCURRENT_WORKFLOW_LOCK garantit qu’une seule sécurisation d’un même type de cycle de vie s’exécute à un instant donné. PREPARE sélectionne les cycles de vie à sécuriser et les apparie avec leurs métadonnées. BUILD calcule les empreintes et produit une ligne par entrée. GENERATE exécute l’algorithme générique, TraceabilityService avec LogbookUnitLifeCycleTraceabilityHelper ou LogbookObjectGroupLifeCycleTraceabilityHelper, et dépose le zip et l’événement dans le workspace. FINALIZE, non annulable, transfère le zip du workspace vers les offres et clôt l’opération. Les fichiers intermédiaires transitent en MEMORY, sauf lastOperation.json, traceabilityEvent.json et traceabilityFile.zip, portés par le workspace.

7.10.2.13.8.3. Données produites

PREPARE produit lfcWithMetadata.jsonl, où chaque ligne apparie un cycle de vie brut et sa métadonnée (LfcMetadataPair), traceabilityInformation.json, qui porte le périmètre retenu (startDate, endDate, nbEntries, maxEntriesReached), et lastOperation.json, copie de la dernière traçabilité utilisée comme borne.

BUILD transforme cet appariement en traceabilityData.jsonl, dont chaque ligne est un LifeCycleTraceabilitySecureFileObject. C’est ce fichier qui devient le data.txt de l’archive. Le détail de ses champs est donné dans Cas des cycles de vie.

7.10.2.13.8.4. Croisement d’empreintes

DigestValidator compare, pour chaque métadonnée et chaque objet, l’empreinte recalculée en base, par sérialisation canonique de la métadonnée accompagnée de son cycle de vie, aux empreintes retournées par chacune des offres de la stratégie :

  • toutes les empreintes concordent : comptabilisé OK ;
  • une partie des offres diverge ou ne renvoie pas d’empreinte : WARNING, une alerte est émise et le détail des empreintes (dbHash, offerHashes) est conservé dans la ligne sécurisée, où il n’est écrit qu’en cas d’incohérence ;
  • toutes les offres divergent de la base : l’étape est KO et la liste des identifiants fautifs est reportée en evDetData.

Le bilan est agrégé dans traceabilityStats.json (TraceabilityStatistics), puis repris dans le TraceabilityEvent.

7.10.2.13.8.5. Plafonnement des cycles de vie

La sécurisation est bornée par lifecycleTraceabilityMaxEntries, 100 000 par défaut, transmis au workflow en paramètre d’exécution et appliqué dès l’étape PREPARE. Le dépassement remonte jusqu’au job via MaxEntriesReached, qui enchaîne alors les exécutions tant que du reliquat subsiste.

7.10.2.13.9. SecurisationVersion

7.10.2.13.9.1. Définition

La version de sécurisation est un marqueur porté par chaque opération de traçabilité, défini par tenant et par périmètre. VitamConfiguration conserve trois tables, pour le journal des opérations, les cycles de vie d’unités et les cycles de vie de groupes d’objets, et retourne DEFAULT_TRACEABILITY_VERSION (V1) pour tout tenant non configuré :

String version = VitamConfiguration.getLogbookOperationTraceabilityVersion(tenantId);
String unitVersion = VitamConfiguration.getLfcUnitTraceabilityVersion(tenantId);
String gotVersion = VitamConfiguration.getLfcGotTraceabilityVersion(tenantId);

La configuration est portée par TenantTraceabilityVersionConfiguration, qui valide le format attendu (V1, V2, …).

7.10.2.13.9.2. Stockage

La valeur est écrite dans le journal et dans l’archive sécurisée :

  • evDetData.SecurisationVersion de l’événement de traçabilité (constante TraceabilityEvent.SECURISATION_VERSION), y compris pour les traçabilités vides, où l”evDetData ne contient que ce champ ;
  • securisationVersion=<version> dans additional_information.txt.

Le champ est déclaré dans le mapping Elasticsearch du logbook et dans la description DSL de la collection LogbookOperation, ce qui le rend interrogeable.

7.10.2.13.9.3. Critère de chaînage

La recherche de la traçabilité précédente est filtrée sur la version. Un changement de version ouvre donc une nouvelle chaîne au lieu de se raccrocher à la précédente. Sont concernés findLastTraceabilityOperationOK(version), findLastTraceabilityOperationOKBeforeDate(date, version), utilisés pour la borne de départ et pour les tampons M-1 et A-1, findLastLifecycleTraceabilityOperation(eventType, version, traceabilityWithZipOnly) et la requête équivalente de LogbookLifeCycleTraceabilityHelper.

Une clause de compatibilité est prévue pour V1 dans findLastLifecycleTraceabilityOperation : le filtre accepte alors aussi les opérations dépourvues du champ events.evDetData.SecurisationVersion, les anciennes traçabilités vides, sans zip, ne le portant pas. Le même principe est appliqué côté audit de chaîne de traçabilité (LogbookInternalResourceImpl), où V1 et l’absence du champ sont traités comme équivalents.

7.10.2.13.9.4. Usage côté vérification

Les traitements de vérification résolvent la version attendue du tenant et ne considèrent que les traçabilités correspondantes :

  • EvidenceService : recherche de la traçabilité couvrant une date donnée et détermination de la dernière traçabilité de cycle de vie ;
  • ProbativeCreateReportEntry : recherche de la traçabilité de référence et de la plus proche traçabilité antérieure ;
  • TraceabilityLinkedCheckPreparePlugin : les opérations dont la version diffère de celle attendue sont écartées de la distribution et signalées comme ignorées.

7.10.2.13.9.5. Configuration

Le déploiement Ansible génère la section traceabilityVersionConfiguration de vitam.conf pour chaque tenant, avec un défaut à V1 :

traceabilityVersionConfiguration:
  - tenant: 0
    logbookOperation: V1
    lfcUnit: V1
    lfcGot: V1

Les valeurs sont surchargeables par tenant via extendedConfiguration.custom[<tenant>].traceabilityVersion.*, et globalement via extendedConfiguration.default.traceabilityVersion.*.

7.10.2.13.9.6. Portée du champ

Aucune branche de l’algorithme de sécurisation ne dépend de la valeur de la version : quelle qu’elle soit, les mêmes données sont sélectionnées, le même arbre de Merkle est construit et le même horodatage est appliqué. La version intervient comme critère de filtrage et de chaînage décrit ci-dessus, et comme discriminant dans le nom de l’archive (decorateZipName, qui préserve le nom historique pour V1 et rend le nom unique par opération au-delà, ce qui évite un conflit sur l’offre). Le champ est donc conservatoire : il permet une évolution ultérieure du format ou de l’algorithme sans rompre les chaînes existantes.

7.10.2.13.10. Synthèse de la chaîne de preuve

7.10.2.13.10.1. Chaîne de dépendances

Chaque maillon est vérifiable indépendamment, et chacun dépend du précédent : c’est cet enchaînement qui rend l’archive opposable.

data.txt (une ligne par entrée, ordre significatif)
    |
    v
CanonicalJsonFormatter --> empreinte par ligne --> MerkleTreeAlgo
                                                        |
                                                        v
                                                racine de Merkle
                                                        |
         +----------------------------------------------+
         v                                              v
  merkleTree.json                        computing_information.txt
  (archive)                              (currentHash + jetons J-1 / M-1 / A-1)
                                                        |
                                                        v
                                        TimeStampService.getDigestFrom(...)
                                                        |
                                                        v
                                   TimestampGenerator (keystore PKCS#12)
                                                        |
                                                        v
                                                    token.tsp
                                                        |
                                                        v
                                 TraceabilityEvent (evDetData, indexé)

La vérification remonte cette chaîne en sens inverse : recalcul des empreintes de lignes, de l’arbre et de la racine, comparaison à merkleTree.json et au Hash journalisé (Vérification), puis recalcul de l’empreinte horodatée et validation du jeton (Vérification du jeton).

7.10.2.13.10.2. Récapitulatif pas à pas

  1. Résoudre la version de sécurisation du tenant et déterminer les bornes startDate et endDate du périmètre.
  2. Sélectionner les entrées stabilisées par _lastPersistedDate, dans la limite du plafond et de sa réserve de débordement.
  3. Cycles de vie uniquement : apparier les cycles de vie et leurs métadonnées (PREPARE), puis croiser les empreintes de la base avec celles de chaque offre via DigestValidator (BUILD).
  4. Sérialiser chaque entrée avec CanonicalJsonFormatter.
  5. Écrire chaque ligne dans data.txt et l’ajouter comme feuille de MerkleTreeAlgo.
  6. Appeler generateMerkle() : complétion des feuilles puis remontée par paires jusqu’à la racine, sérialisée dans merkleTree.json.
  7. Récupérer les jetons des traçabilités précédente, M-1 et A-1 de la même version, pour le chaînage.
  8. Calculer l’empreinte à horodater via TimeStampService et conserver ses quatre valeurs d’entrée dans computing_information.txt.
  9. Produire le jeton RFC 3161 avec TimestampGenerator et le keystore PKCS#12, puis l’écrire en base 64 dans token.tsp.
  10. Écrire additional_information.txt (nombre d’entrées, bornes, version de sécurisation).
  11. Déposer l’archive en DataCategory.LOGBOOK sur les offres, directement pour le journal des opérations, via le workspace et l’étape FINALIZE pour les cycles de vie, puis supprimer le fichier temporaire.
  12. Journaliser le TraceabilityEvent en evDetData de l’opération, ce qui la clôt et rend l’archive retrouvable et comparable depuis le journal indexé.

7.10.2.13.11. Vérifications aval

Les contrôles portant sur l’arbre de Merkle et sur le jeton d’horodatage sont décrits plus haut (Vérification et Vérification du jeton). Les traitements qui les orchestrent ne sont pas détaillés ici :

  • le contrôle de cohérence de la chaîne de traçabilité (workflow LINKED_CHECK_SECURISATION, LinkedCheckTraceability.json), qui enchaîne récupération et extraction du zip, contrôle des empreintes, CHECK_MERKLE_TREE et VERIFY_TIMESTAMP ;
  • l’audit de cohérence (EvidenceService) ;
  • le relevé de valeur probante (ProbativeCreateReportEntry).

Se référer à la documentation de ces traitements et à vitam-doc pour leur description fonctionnelle.