Skip to content
Lacre

How to verify a seal

Every Lacre seal is a JSON record with nine keys: the schema version, the seal type, the state of the issue, the checklist, the risk level, the approval, who signs, when, and the hash of the previous seal. Its hash is the SHA-256 of that record in canonical form. Recomputing it needs neither Lacre nor Jira: the verifier, a single file with no dependencies, is enough, and so is any tool that follows the specification on this page.

In short

What you need

The commands on this page run in the folder that holds the verifier and the files. The first one works in any terminal; the others are written for a Linux or macOS terminal (on Windows, Git Bash or WSL).

Where the seal comes from

Every seal is also copied to a property of its issue, lacre.seal.<hash>, visible at /rest/api/3/issue/<key>/properties in the Jira API. That copy is for reference: like any issue property, anyone who can edit the issue can change or delete it. The reference record is the one Lacre keeps in its storage; the export and the sheet copy it to a file, and what that file proves when verified outside Jira is set out in the last section, what it proves and what it does not.

Verifying a seal

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

It prints «coincide» (matches, exit code 0) or «no coincide» (does not match, 1), the recomputed hash, the expected one, the identifier and, below, the canonical JSON it hashed: that text is the only thing the hash proves, not how the file is laid out. The expected hash is taken from the file's hash key or from the second argument, which overrides it; that is how the file is checked against a hash obtained from another source:

node verify-seal.mjs record.json e137f2cd7ac20f56d546dd6654bc04fab6da529bb1a85f7ddf6335a70936b4de

A record that lacks a key, belongs to another schema version or has a field of another type or shape is not verified: it is a format error (exit code 2) that names the field's path ($.checklist[1].checkedAt) and never its value, even if its hash matches. So is a file that repeats a key within the same object: that JSON is ambiguous (RFC 7493) and each program would read it its own way.

Verifying a whole export

A single command verifies every seal of an export and its chain: it recomputes the hash of each record and, within each issue and by sequence number (seq), checks that the prevHash of each record is the recomputed hash of the previous one. If the file repeats a key within the same object, it stops without verifying anything.

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?'matches':'DOES NOT MATCH',s.seq===1?(r==='GENESIS'?'first (GENESIS)':'NOT CHAINED'):b&&b.seq===s.seq-1?(r===b.hash?'chained':'NOT CHAINED'):r==='GENESIS'?'NOT CHAINED':b&&b.seq===s.seq?'REPEATED SEQ':b?'MISSING SEAL '+(s.seq-1):'the previous one is not in the export');a.set(i,{seq:s.seq,hash:v.hash})}console.log(x.length+' seals')}else{console.error('The export repeats the key '+k+': ambiguous JSON, not verified.');process.exitCode=2}" lacre-LAB-2026-10-01-2026-10-31.json

Each line gives the issue, the sequence number, the recomputed identifier, «matches» or «DOES NOT MATCH» and how it links to the previous seal: «chained»; «first (GENESIS)», the issue's first seal; «the previous one is not in the export», when it is from before the period or from another project (the issue was moved); «MISSING SEAL» and the number, when one is missing between two in the export; or «NOT CHAINED» and «REPEATED SEQ», which should never appear. The last line says how many seals it read, to compare with the number in the export.

To also have each seal in a file of its own, for instance to see its canonical form with the verifier, the first of these commands writes them to a new seals folder (it stops if the folder exists or if two seals would get the same name) and the second runs the verifier on each one; every line gives the file and «coincide», «no coincide» or the format error:

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('seals');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('seals/'+n+'.json',JSON.stringify(s,null,2)+'\n',{flag:'wx'})}console.log(x.length+' seals → seals/')}else{console.error('The export repeats the key '+k+': ambiguous JSON, not verified.');process.exitCode=2}" lacre-LAB-2026-10-01-2026-10-31.json

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

The evidence sheet runs these checks inside Jira for a seal and the next one, and says so in its verdict.

The issue description

The record does not carry the issue description but its hash (issue.descriptionHash): the description is stored separately so that it can be deleted with the project's data. When it is stored, the export and the sheet include it in description (kind stored and the document in document), and the verifier checks that it is the sealed one:

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?'matches':'does not match'):'description: '+d.kind)" seals/lacre-LAB-6-L-0123456789.json

kind can also be none (the issue had no description and descriptionHash is null), tooLarge (not stored because it exceeded 200 KiB), missing (a seal from before Lacre stored it) or unreadable. descriptionHashOf is the whole rule: it gives the same result on the description the Jira API returns today (fields.description from GET /rest/api/3/issue/<key>), if it has not changed.

Test vectors

Seven records with their canonical JSON, their hash and their identifier, to check any implementation of this specification; they are the ones the app and the verifier are tested with, and their hashes never change in schema 1. The accounts, issues and texts are fictitious. Each file carries the record in record, its canonical form in canonical, the hash in hash and the identifier in sealId, so the verifier takes it as it is:

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

And the canonical form, with another tool (on macOS, shasum -a 256 instead of sha256sum):

node -e "process.stdout.write(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).canonical)" 03-check-campos-ausentes.json | sha256sum
Vector What it shows Hash
01 a check in ASCII with no description, no custom fields, from GENESIS, with an optional item left unticked e137f2cd7ac20f56d546dd6654bc04fab6da529bb1a85f7ddf6335a70936b4de
02 accents, ñ and emoji; decomposed accents (NFD) in the file and composed (NFC) in the canonical form; a description 297680ecf6c4f6841b861910da2df262f541b72be5abeb207a9b74b9f6838462
03 missing optional fields as null, empty lists and configured custom fields with no value 175d63b63bf695c96c8a22c5af7cd28d2de0752d42c3cd91651917355799b43b
04 a second check on the same issue: prevHash is the hash of 01 eff830727a40d0a5fdd74f1f6da2c92b9d7ed56555120e13eecd4fda5dcbfe21
05 a subsequent approval chained to 04, with the same issue state and the same checklist 4ccbedd6dfd65e69ef14cc7598ce71f7db1f362e4fc3de46f6d148cc4fcb0b3c
06 a prior approval at HIGH risk from GENESIS, with the checklist half done eab71d4b7e5142878b2a75c24d8aa3a82ce86cb3732252d5d9c96b3f2a5f3063
07 a check chained to 06 with numbers not in their shortest form, null, objects and lists in custom fields, and keys out of order e5c07f2404af5446380725599003f34f3e73987f5f58e70031c6c3467660c619

The canonical form of vector 03, which is what gets hashed:

{"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"}

And six descriptions (ADF documents) with their canonical form and their hash, the descriptionHash that goes with them. Each file carries the document in adf, its canonical form in canonical and the hash in hash:

Vector What it shows Hash
01 a paragraph in ASCII: the simplest case 4709b4392a05cf9147c488d00fbb7c3c472cbe80dab571fb803a9f4ce89d1077
02 the document of 01 with its keys in another order at every level: same canonical form and same hash 4709b4392a05cf9147c488d00fbb7c3c472cbe80dab571fb803a9f4ce89d1077
03 a decomposed «é» (NFD), ñ, Spanish quotation marks, a dash and an emoji outside the basic plane: the canonical form is in NFC faf525093931891df42c429c45d19fc192ea598ae937bc8adf3bfe6e93a37404
04 the text of 03 already composed (NFC): same canonical form and same hash as 03 faf525093931891df42c429c45d19fc192ea598ae937bc8adf3bfe6e93a37404
05 heading, link, bold, list and code block: nested attributes and numbers in their shortest form 69ef5777a5950aa2b0575346ddad72a0ae6bcc965a4ce0c1ef8c2dd82686fae7
06 the description of a test issue as Jira returns it bad5b54321230033b4b81eb6a1fd5ec774c4cd93ffa8b0669add2fe0f830851c

Schema 1 specification

This is the rule that gives the hash of every seal with schemaVersion 1. It does not change: should another one ever be needed, it would be schema 2, with its own rule and its own vectors, and schema 1 seals would still be verified with this one.

The state of the issue (issue)

Every field is always present; whatever the issue lacks is null (a field is never left out). The source is the response of GET /rest/api/3/issue/<key> from the Jira API at the time of the seal.

Field Type Source and rule
issueId string the issue's id
issueKey string key
projectKey string fields.project.key
issueType string fields.issuetype.name
summary string fields.summary
descriptionHash string or null SHA-256 of the ADF document in fields.description in canonical form; null if the issue has no description, which Jira returns as null or as the document with no nodes {"type":"doc","version":1,"content":[]} (any other document, even an empty paragraph, goes in by its hash)
status object { id, name } fields.status.id and fields.status.name
assignee string or null fields.assignee.accountId
reporter string or null fields.reporter.accountId
priority string or null fields.priority.name
resolution string or null fields.resolution.name
labels list of strings fields.labels, sorted by UTF-16 code units (the same order as keys)
components list of strings names of fields.components, same order
fixVersions list of strings names of fields.fixVersions, same order
issueUpdated string fields.updated converted to UTC, ISO 8601 with milliseconds and a Z suffix (2026-10-16T09:15:42.000Z)
customFields object id → value only the fields chosen by the project's administrators, with the value as the API returns it (number, string, object, list or null), lists not reordered; a chosen field the issue does not have is null; with no fields chosen, {}

The sealed record

A record is a JSON object with exactly these nine keys, all present:

Key Value
schemaVersion the number 1
sealType "check" or "approve" (approval)
issue the state of the issue above at the time of the seal
checklist list, in the configured order, of { id, text, required, checked, checkedBy, checkedAt }: id and text strings (text, as shown to the signer, in their language); required and checked booleans; checkedBy, the accountId of whoever ticked the item, and checkedAt, when (same format as signedAt), both null if it is not ticked
riskLevel "BAJO", "MEDIO" or "ALTO" (low, medium, high): the change's risk level chosen in the panel
approval null on a check seal; on an approve seal, { approverRole, mode }: approverRole, the name of the project role that approves, and mode, "posterior" (approves an earlier check) or "previa" (prior approval, before the check, which the project can require at HIGH risk)
signer accountId of the signer, that of the Atlassian account used to seal
signedAt the seal's date in UTC, ISO 8601 with milliseconds and a Z suffix
prevHash hash of the immediately preceding seal on the same issue, of whatever type, or the string "GENESIS" for the first one

Whatever accompanies the record in the export or on the sheet is not part of it and does not go into the hash. The shape of each field, which the verifier checks before hashing:

Chain: every seal points to the previous one on its issue through prevHash. A subsequent approval follows in the chain the check it approves; with a prior approval, the check follows the approval. The order is checked through the prevHash values and compared with the dates (signedAt).

The canonical form

The record is serialised as canonical JSON according to RFC 8785 (JSON Canonicalization Scheme):

Rule added to the RFC: every string, values and key names alike, is normalised to Unicode NFC before serialising (and keys are sorted once normalised), so that cafe followed by the combining accent U+0301 and café with U+00E9 give the same hash. NFC is not NFKC: fi (U+FB01) stays as it is; U+FB33 (dalet with dagesh), on the other hand, is decomposed into U+05D3 U+05BC, because Unicode excludes it from composition. These are errors, never resolved silently: malformed strings (lone UTF-16 surrogates, which UTF-8 cannot encode), values that are not JSON, two keys of an object that are equal after NFC, and JSON text that repeats a key within the same object (it must be I-JSON, RFC 7493).

The hash and the identifier

Hash: SHA-256 of the UTF-8 text of the canonical JSON, in lowercase hexadecimal (64 characters). The seal's identifier, to talk about it: L- followed by the first ten characters of the hash (L-e137f2cd7a).

What it proves and what it does not

The verifier checks that a file is consistent: that each record gives exactly its hash and that each seal links to the previous one. It does not know where the file comes from: whoever holds it can change a record and recompute its hash and those of the following seals, and the file would still say «coincide». To know that it is the one Lacre stored, its hashes must be compared with a copy that does not depend on whoever hands over the file: what Lacre shows inside Jira (the panel's history with "Verify", the evidence sheet or an export made in front of the auditor), an earlier export the auditor already has, or a hash noted somewhere else, passed to the verifier as the second argument.

With that, the seal proves that the record has not changed since it was stored (the hash and the chain) and what the issue looked like when it was sealed. It does not prove the identity of whoever signed beyond the Atlassian account they were logged in with, and it adds no third-party timestamp. What counts as evidence in an audit is for the auditor to decide.