Saltar al contenido
Lacre

Cómo se verifica un sello

Cada sello de Lacre es un registro JSON con nueve claves: la versión del esquema, el tipo de sello, el estado de la incidencia, la lista de comprobación, el riesgo, la aprobación, quién firma, cuándo y el hash del sello anterior. Su hash es el SHA-256 de ese registro en forma canónica. Para recalcularlo no hace falta Lacre ni Jira: basta con el verificador, un único archivo sin dependencias, o con cualquier herramienta que siga la especificación de esta página.

En resumen

Qué hace falta

Los comandos de esta página se ejecutan en la carpeta donde están el verificador y los archivos. El primero vale en cualquier terminal; los demás están escritos para una terminal de Linux o macOS (en Windows, Git Bash o WSL).

De dónde sale el sello

Cada sello se copia además en una propiedad de su incidencia, lacre.seal.<hash>, que se ve en /rest/api/3/issue/<clave>/properties de la API de Jira. Esa copia es de consulta: como toda propiedad de una incidencia, la puede cambiar o borrar cualquiera que pueda editar la incidencia. El registro de referencia es el que guarda Lacre en su almacén; la exportación y la hoja lo copian a un archivo, y lo que demuestra ese archivo verificado fuera de Jira lo dice el último apartado, qué demuestra y qué no.

Verificar un sello

node verify-seal.mjs lacre-LAB-6-L-0123456789.json

Imprime «coincide» (código de salida 0) o «no coincide» (1), el hash recalculado, el esperado, el identificador y, debajo, el JSON canónico que ha hasheado: ese texto es lo único que el hash demuestra, no la presentación del archivo. El hash esperado se toma de la clave hash del archivo o del segundo argumento, que manda sobre ella; así se comprueba el archivo contra un hash que se tenga de otra fuente:

node verify-seal.mjs registro.json e137f2cd7ac20f56d546dd6654bc04fab6da529bb1a85f7ddf6335a70936b4de

Un registro al que le falta una clave, de otra versión del esquema o con un campo de otro tipo o forma no se verifica: es un error de formato (código 2) que nombra la ruta del campo ($.checklist[1].checkedAt) y nunca su valor, aunque su hash cuadre. También lo es un archivo que repite una clave dentro de un mismo objeto: ese JSON es ambiguo (RFC 7493) y cada programa lo leería a su manera.

Verificar una exportación entera

Un solo comando verifica todos los sellos de una exportación y su cadena: recalcula el hash de cada registro y, dentro de cada incidencia y por número de orden (seq), comprueba que el prevHash de cada registro es el hash recalculado del anterior. Si el archivo repite una clave dentro de un mismo objeto, se para sin verificar nada.

node --input-type=module -e "import fs from 'node:fs';import {findDuplicateKey,verify} from './verify-seal.mjs';const t=fs.readFileSync(process.argv[1],'utf8');const k=findDuplicateKey(t);if(k===undefined){const a=new Map();const x=JSON.parse(t).seals;for(const s of [...x].sort((p,q)=>String(p.record.issue.issueId).localeCompare(String(q.record.issue.issueId))||p.seq-q.seq)){let v;try{v=verify(s.record,s.hash)}catch(e){v={matches:false,hash:'',sealId:'('+e.message+')'}}const i=s.record.issue.issueId;const b=a.get(i);const r=s.record.prevHash;console.log(s.issueKey,s.seq,v.sealId,v.matches?'coincide':'NO COINCIDE',s.seq===1?(r==='GENESIS'?'primero (GENESIS)':'NO ENCADENA'):b&&b.seq===s.seq-1?(r===b.hash?'encadena':'NO ENCADENA'):r==='GENESIS'?'NO ENCADENA':b&&b.seq===s.seq?'SEQ REPETIDO':b?'FALTA EL SELLO '+(s.seq-1):'el anterior no está en la exportación');a.set(i,{seq:s.seq,hash:v.hash})}console.log(x.length+' sellos')}else{console.error('La exportación repite la clave '+k+': JSON ambiguo, no se verifica.');process.exitCode=2}" lacre-LAB-2026-10-01-2026-10-31.json

Cada línea da la incidencia, el número de orden, el identificador recalculado, «coincide» o «NO COINCIDE» y cómo enlaza con el anterior: «encadena»; «primero (GENESIS)», el primer sello de la incidencia; «el anterior no está en la exportación», cuando es de antes del periodo o de otro proyecto (la incidencia se movió); «FALTA EL SELLO» y el número, cuando falta uno entre dos de la exportación; o «NO ENCADENA» y «SEQ REPETIDO», que no deberían salir nunca. La última línea dice cuántos sellos ha leído, para compararlo con los que trae la exportación.

Para tener además cada sello en su propio archivo, por ejemplo para ver su canónico con el verificador, el primero de estos comandos los escribe en una carpeta sellos nueva (se para si ya existe o si dos sellos darían el mismo nombre) y el segundo pasa el verificador por cada uno; cada línea dice el archivo y «coincide», «no coincide» o el error de formato:

node --input-type=module -e "import fs from 'node:fs';import {findDuplicateKey} from './verify-seal.mjs';const t=fs.readFileSync(process.argv[1],'utf8');const k=findDuplicateKey(t);if(k===undefined){fs.mkdirSync('sellos');const x=JSON.parse(t).seals;for(const s of x){const n=('lacre-'+s.issueKey+'-'+s.sealId).replace(/[^A-Za-z0-9_-]/g,'_');fs.writeFileSync('sellos/'+n+'.json',JSON.stringify(s,null,2)+'\n',{flag:'wx'})}console.log(x.length+' sellos → sellos/')}else{console.error('La exportación repite la clave '+k+': JSON ambiguo, no se verifica.');process.exitCode=2}" lacre-LAB-2026-10-01-2026-10-31.json

for f in sellos/*.json; do printf '%s: ' "$f"; node verify-seal.mjs "$f" 2>&1 | head -n 1; done

La hoja de evidencia hace estas comprobaciones dentro de Jira para un sello y el siguiente, y lo dice en su veredicto.

La descripción de la incidencia

El registro no lleva la descripción de la incidencia, sino su hash (issue.descriptionHash): la descripción se guarda aparte para poder borrarla con los datos del proyecto. Cuando está guardada, la exportación y la hoja la traen en description (kind stored y el documento en document), y con el verificador se comprueba que es la sellada:

node --input-type=module -e "import {readFileSync} from 'node:fs';import {descriptionHashOf} from './verify-seal.mjs';const s=JSON.parse(readFileSync(process.argv[1],'utf8'));const d=s.description;console.log(d.kind==='stored'?(descriptionHashOf(d.document)===s.record.issue.descriptionHash?'coincide':'no coincide'):'descripción: '+d.kind)" sellos/lacre-LAB-6-L-0123456789.json

kind puede ser también none (la incidencia no tenía descripción y descriptionHash es null), tooLarge (no se guardó por pasar de 200 KiB), missing (un sello de antes de que Lacre la guardara) o unreadable. descriptionHashOf es la regla entera: el mismo resultado sale con la descripción que devuelve hoy la API de Jira (fields.description de GET /rest/api/3/issue/<clave>), si no ha cambiado.

Vectores de prueba

Siete registros con su JSON canónico, su hash y su identificador, para comprobar cualquier implementación de esta especificación; son los mismos con los que se prueban la app y el verificador, y sus hashes no cambian nunca en el esquema 1. Las cuentas, incidencias y textos son ficticios. Cada archivo lleva el registro en record, su canónico en canonical, el hash en hash y el identificador en sealId, así que el verificador lo acepta tal cual:

node verify-seal.mjs 03-check-campos-ausentes.json

Y el canónico, con otra herramienta (en macOS, shasum -a 256 en lugar de sha256sum):

node -e "process.stdout.write(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).canonical)" 03-check-campos-ausentes.json | sha256sum
Vector Qué demuestra Hash
01 comprobación en ASCII sin descripción, sin campos personalizados, desde GENESIS, con un punto opcional sin marcar e137f2cd7ac20f56d546dd6654bc04fab6da529bb1a85f7ddf6335a70936b4de
02 tildes, eñes y emoji; tildes descompuestas (NFD) en el archivo y compuestas (NFC) en el canónico; descripción presente 297680ecf6c4f6841b861910da2df262f541b72be5abeb207a9b74b9f6838462
03 campos opcionales ausentes como null, listas vacías y campos personalizados configurados sin valor 175d63b63bf695c96c8a22c5af7cd28d2de0752d42c3cd91651917355799b43b
04 segunda comprobación de la misma incidencia: prevHash es el hash de 01 eff830727a40d0a5fdd74f1f6da2c92b9d7ed56555120e13eecd4fda5dcbfe21
05 aprobación posterior encadenada a 04, con el mismo estado de la incidencia y la misma lista 4ccbedd6dfd65e69ef14cc7598ce71f7db1f362e4fc3de46f6d148cc4fcb0b3c
06 aprobación previa en riesgo ALTO desde GENESIS, con la lista a medias eab71d4b7e5142878b2a75c24d8aa3a82ce86cb3732252d5d9c96b3f2a5f3063
07 comprobación encadenada a 06 con números en forma no mínima, null, objetos y listas en campos personalizados, y claves en desorden e5c07f2404af5446380725599003f34f3e73987f5f58e70031c6c3467660c619

El canónico del vector 03, lo que se hashea:

{"approval":null,"checklist":[{"checked":true,"checkedAt":"2026-10-16T10:31:00.000Z","checkedBy":"712020:1b2c3d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d","id":"p1","required":true,"text":"Nivel de riesgo del cambio valorado"},{"checked":true,"checkedAt":"2026-10-16T10:32:00.000Z","checkedBy":"712020:1b2c3d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d","id":"p2","required":true,"text":"Pruebas en entorno separado de producción con resultado enlazado"},{"checked":true,"checkedAt":"2026-10-16T10:33:00.000Z","checkedBy":"712020:1b2c3d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d","id":"p3","required":true,"text":"Plan de marcha atrás escrito"},{"checked":false,"checkedAt":null,"checkedBy":null,"id":"p4","required":false,"text":"Comunicación a las partes afectadas 📣"}],"issue":{"assignee":null,"components":[],"customFields":{"customfield_10010":null,"customfield_10020":null},"descriptionHash":"a75599dbc3ad2e4679d25e750891359c1beb3ee8be1a54fbc1278f0fc8de6493","fixVersions":[],"issueId":"10044","issueKey":"LAB-44","issueType":"Tarea","issueUpdated":"2026-10-16T10:30:00.000Z","labels":[],"priority":null,"projectKey":"LAB","reporter":"5f1a2b3c4d5e6f7a8b9c0d1e","resolution":null,"status":{"id":"10000","name":"Tareas por hacer"},"summary":"Deshabilitar el acceso SSH por contraseña"},"prevHash":"GENESIS","riskLevel":"MEDIO","schemaVersion":1,"sealType":"check","signedAt":"2026-10-16T10:35:08.000Z","signer":"712020:1b2c3d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d"}

Y seis descripciones (documentos ADF) con su canónico y su hash, el descriptionHash que les corresponde. Cada archivo lleva el documento en adf, su canónico en canonical y el hash en hash:

Vector Qué demuestra Hash
01 un párrafo en ASCII: el caso más simple 4709b4392a05cf9147c488d00fbb7c3c472cbe80dab571fb803a9f4ce89d1077
02 el documento de 01 con las claves en otro orden en cada nivel: mismo canónico y mismo hash 4709b4392a05cf9147c488d00fbb7c3c472cbe80dab571fb803a9f4ce89d1077
03 una «é» descompuesta (NFD), eñe, comillas latinas, raya y emoji fuera del plano básico: el canónico va en NFC faf525093931891df42c429c45d19fc192ea598ae937bc8adf3bfe6e93a37404
04 el texto de 03 ya compuesto (NFC): mismo canónico y mismo hash que 03 faf525093931891df42c429c45d19fc192ea598ae937bc8adf3bfe6e93a37404
05 encabezado, enlace, negrita, lista y bloque de código: atributos anidados y números en forma mínima 69ef5777a5950aa2b0575346ddad72a0ae6bcc965a4ce0c1ef8c2dd82686fae7
06 la descripción de una incidencia de prueba tal como la devuelve Jira bad5b54321230033b4b81eb6a1fd5ec774c4cd93ffa8b0669add2fe0f830851c

Especificación del esquema 1

Esta es la regla con la que se calcula el hash de todo sello con schemaVersion 1. No cambia: si algún día hiciera falta otra, sería el esquema 2, con su propia regla y sus propios vectores, y los sellos del esquema 1 se seguirían verificando con esta.

El estado de la incidencia (issue)

Todos los campos están siempre presentes; lo que la incidencia no tiene va como null (un campo nunca se omite). El origen es la respuesta de GET /rest/api/3/issue/<clave> de la API de Jira en el momento del sello.

Campo Tipo Origen y regla
issueId cadena id de la incidencia
issueKey cadena key
projectKey cadena fields.project.key
issueType cadena fields.issuetype.name
summary cadena fields.summary
descriptionHash cadena o null SHA-256 del documento ADF de fields.description en forma canónica; null si la incidencia no tiene descripción, que Jira devuelve como null o como el documento sin nodos {"type":"doc","version":1,"content":[]} (cualquier otro documento, aunque sea un párrafo vacío, entra por su hash)
status objeto { id, name } fields.status.id y fields.status.name
assignee cadena o null fields.assignee.accountId
reporter cadena o null fields.reporter.accountId
priority cadena o null fields.priority.name
resolution cadena o null fields.resolution.name
labels lista de cadenas fields.labels, ordenadas por unidades de código UTF-16 (el mismo orden que las claves)
components lista de cadenas nombres de fields.components, mismo orden
fixVersions lista de cadenas nombres de fields.fixVersions, mismo orden
issueUpdated cadena fields.updated pasada a UTC, ISO 8601 con milisegundos y sufijo Z (2026-10-16T09:15:42.000Z)
customFields objeto id → valor solo los campos que eligió quien administra el proyecto, con el valor tal como lo devuelve la API (número, cadena, objeto, lista o null), sin reordenar las listas; uno elegido que la incidencia no tiene va como null; sin campos elegidos, {}

El registro sellado

Un registro es un objeto JSON con exactamente estas nueve claves, todas presentes:

Clave Valor
schemaVersion el número 1
sealType "check" (comprobación) o "approve" (aprobación)
issue el estado de la incidencia de arriba en el momento del sello
checklist lista, en el orden configurado, de { id, text, required, checked, checkedBy, checkedAt }: id y text cadenas (text, tal como se mostró al firmante, en su idioma); required y checked booleanos; checkedBy, el accountId de quien marcó el punto, y checkedAt, cuándo (formato de signedAt), los dos null si no está marcado
riskLevel "BAJO", "MEDIO" o "ALTO": el riesgo del cambio elegido en el panel
approval null en un sello check; en un sello approve, { approverRole, mode }: approverRole, el nombre del rol de proyecto con el que se aprueba, y mode, "posterior" (aprueba una comprobación anterior) o "previa" (aprobación anterior a la comprobación, que el proyecto puede exigir en riesgo ALTO)
signer accountId de quien firma, el de la cuenta de Atlassian con la que sella
signedAt fecha del sello en UTC, ISO 8601 con milisegundos y sufijo Z
prevHash hash del sello inmediatamente anterior de la misma incidencia, del tipo que sea, o la cadena "GENESIS" si es el primero

Lo demás que acompaña al registro en la exportación o en la hoja no forma parte de él y no entra en el hash. La forma de cada campo, que el verificador comprueba antes de hashear:

Cadena: cada sello apunta al anterior de su incidencia por prevHash. Una aprobación posterior sigue en la cadena a la comprobación que aprueba; con una aprobación previa, la comprobación sigue a la aprobación. El orden se comprueba por los prevHash y se contrasta con las fechas (signedAt).

La forma canónica

El registro se serializa como JSON canónico según la RFC 8785 (JSON Canonicalization Scheme):

Regla añadida a la RFC: toda cadena, valores y nombres de clave, se normaliza a Unicode NFC antes de serializar (y las claves se ordenan ya normalizadas), de modo que cafe seguido del acento combinante U+0301 y café con U+00E9 dan el mismo hash. NFC no es NFKC: fi (U+FB01) se queda como está; en cambio U+FB33 (dalet con daguesh) se descompone en U+05D3 U+05BC, porque Unicode lo excluye de la composición. Son un error, nunca se resuelven en silencio: las cadenas mal formadas (subrogados UTF-16 sueltos, que UTF-8 no puede codificar), los valores que no son JSON, dos claves de un objeto que coinciden tras NFC y un texto JSON con una clave repetida dentro de un mismo objeto (debe ser I-JSON, RFC 7493).

El hash y el identificador

Hash: SHA-256 del texto UTF-8 del JSON canónico, en hexadecimal con minúsculas (64 caracteres). Identificador del sello, para hablar de él: L- seguido de los diez primeros caracteres del hash (L-e137f2cd7a).

Qué demuestra y qué no

El verificador comprueba que un archivo es coherente: que cada registro da exactamente su hash y que cada sello enlaza con el anterior. No sabe de dónde viene el archivo: quien lo tenga puede cambiar un registro y recalcular su hash y los de los sellos siguientes, y el archivo seguiría diciendo «coincide». Para saber que es el que guardó Lacre, hay que contrastar sus hashes con una copia que no dependa de quien entrega el archivo: lo que enseña Lacre dentro de Jira (el historial del panel con «Verificar», la hoja de evidencia o una exportación hecha delante del auditor), una exportación anterior que ya tuviera el auditor o un hash anotado en otro sitio, que se pasa al verificador como segundo argumento.

Con eso, el sello demuestra que el registro no ha cambiado desde que se guardó (el hash y la cadena) y cuál era el estado de la incidencia en el momento del sello. No demuestra la identidad de quien firma más allá de la cuenta de Atlassian con la que estaba autenticado, ni aporta un sello de tiempo de un tercero. Qué vale como evidencia en una auditoría lo decide el auditor.