# Checking a NILSARO-LOGGEN evidence bundle This bundle is one project's log from NILSARO-LOGGEN, Nilsaro's authorship and decision log: every locked entry, its hash, and the chain that ties the entries together. Everything needed to check it is in this file. You need no Nilsaro account and nothing from Nilsaro beyond the bundle itself: the rules below, and any tool that can compute SHA-256. Bundle format 2. Hash rules versions 1 and 2. ## What is in the bundle | File | What it holds | | --- | --- | | `manifest.json` | The bundle format, the rules versions its entries use, the project, when the bundle was made and by whom, the number of entries and how many of them are redacted, the first and last number, and the last chain hash | | `entries.json` | Every locked entry, in number order: its number, its entry hash, and its entry record — or, for a redacted entry, `null` and the redaction | | `chain.json` | For every locked entry, in number order: its chain link and its chain hash | | `VERIFY.md` | This file | Drafts are never in a bundle. A draft is a working copy with no evidential weight until its author locks it. ## What a passing check shows, and what it does not - It shows that the entries in this bundle are consistent with each other: none has been changed, removed, inserted or reordered relative to the chain in this bundle. - It shows the date and time each entry was locked, by the clock of Nilsaro's own server. The author could not set or change that time. - It does not prove those times to someone who does not trust Nilsaro's server. Whoever controls the server could rebuild the whole chain with other dates. - It does not show that a work date is true, that the named authors made the work alone, or that the work is original or protectable. A work date is the author's claim. - A bundle sent to someone else on a given day shows that the log existed in this form by then, as far as that person or service can confirm the date. That is the only check from outside. ## Redacted entries An administrator can redact a locked entry: its content is removed for good, and its number, its time, its entry hash and its place in the chain stay. In `entries.json` a redacted entry has `"record": null` and, beside it, `"redacted"`: when (`at`, in UTC), by whom (`by`) and why (`note`). - A redacted entry's content cannot be checked against its hash: there is no record left to hash. The checks below say which entries those are. - Everything else about it is checked as for any other entry. Its entry hash is part of its chain link, so a redaction can hide what an entry said but cannot change its hash, its number, its time or its place. - What a bundle cannot show is whether a redaction was made by the log or by whoever has handled the bundle since. A copy of the bundle made before the redaction still holds the record, and still checks out. ## The rules Someone checking a bundle must get the same hash from the same entry as the server did. These are the rules the server follows. There are two versions; each record says its own in `v`, and an entry keeps the version it was locked with. Version 2 differs from version 1 only in `files`. ### Step 1: the text is clean Every text field was cleaned when it was saved, so what was stored is what was hashed: - characters in Unicode's composed form (NFC), so å is always one character - line breaks as a single line-feed character Nothing else was changed. No trimming, no spelling fixes. ### Step 2: the entry record The record has exactly these parts, and every part is always present. A part with no value is empty, not left out. | Part | Content | | --- | --- | | `v` | The version of these rules: `1` or `2` | | `entry_id`, `organisation_id` | Ids | | `project` | `id`, `name`, `prefix`, `kind` (whether it is Nilsaro's own or a customer's), `customer_name`, `customer_reference` | | `category` | `key` and `label` | | `fields` | The category's extra fields in their set order: `key`, `label`, `value` | | `title`, `description` | Text | | `work_type` | `key` and `label` | | `decided_by` | Empty, or: `by` (developer or customer), `developer_name`, `customer_person` (who at the customer), `where` (where it was decided) | | `prompt` | Empty, or: `text` and `tool` | | `commits` | A list: `repository`, `commit`, `branch` | | `files` | A list: `path` in version 1; `path` and `sha256` in version 2 | | `work_date` | A date, or empty | | `authors` | A list: `name`, and `member_id` when the author is a member | | `logged_by` | `user_id`, `name`, and `two_factor`: whether two-factor sign-in was used | | `amends` | The id of the entry this one amends, or empty | Names and labels were copied into the record when the entry was locked. That is why a locked entry reads the same after a project is renamed, a category is removed, a customer is changed or a member is erased. The number and the locked-at time are not in the entry record. They are in the chain link, below. The choices version 1 makes where the outline above leaves room: - A part with no value is present and empty: `null` for a missing object, date or id, `[]` for a missing list, `""` for blank text. - `project.kind` is `"own"` or `"customer"`. - `fields` lists every active field of the category in its set order, with `""` as the value of one left blank. - `decided_by` is `null` unless the category asks who decided; otherwise it has the four parts above, with `by` either `"developer"` or `"customer"` and the parts that do not apply `""`. - `prompt` is `null` when both the text and the tool are blank. - Labels are copied as stored. The categories and work types the application starts with are stored with their English names, so a record says `"Decision"` where a Norwegian screen said *Beslutning*. A label an administrator typed is stored and hashed as typed. - Ids are lowercase UUIDs with hyphens. Names of people are their display name on the platform at the moment the entry was locked. - `logged_by.two_factor` is true when the session that locked the entry signed in with an authenticator app or a backup code, not when the account merely could have. What version 2 adds: - Each file is `{"path", "sha256"}`. `sha256` is the file's fingerprint: the SHA-256 of its bytes, as 64 lowercase hexadecimal characters — what `sha256sum` prints — computed on the author's device; the file itself was never sent or stored. A file with no fingerprint has `""`. ### Step 3: the record as bytes - JSON, with the keys in alphabetical order at every level and no spaces - UTF-8, with æ, ø and å written as themselves, not as escape codes - only text, whole numbers, true, false, empty, lists and objects; never decimal numbers - times in UTC to the second, as `2026-10-08T14:22:05Z`; dates as `2026-10-08` In Python this is one line: ```python json.dumps(record, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") ``` `entries.json` is laid out with spaces and line breaks so that people can read it. Those are not part of any record: write each record as bytes by these rules before hashing it. ### Step 4: the hash The entry hash is the SHA-256 of those bytes, written as 64 lowercase hexadecimal characters. ### The chain link When an entry was locked, the server built a second small record, written and hashed by the same rules. Its hash is the entry's chain hash. | Part | Content | | --- | --- | | `v` | The entry's rules version: `1` or `2` | | `project_id` | The project | | `number` | The entry's running number in the project, as a whole number | | `entry_id`, `entry_hash` | The entry | | `locked_at` | The server's time when the entry was locked | | `previous` | The chain hash of the entry locked just before it in the project; `null` for the first | The server did all of this inside one database transaction while holding a lock on the project, so two entries locked in the same instant cannot have the same number or the same predecessor. Entries are chained in the order they were locked. In `chain.json` each line holds these seven parts and, beside them, `chain_hash`. ### When the rules change A change to these rules gets a new version number. Entries keep the version they were locked with, and `manifest.json` lists every version its entries use. A checker must know each of them. This file describes versions 1 and 2. ## Checking a bundle by hand 1. For each entry in `entries.json`, write its record as bytes by the rules and compute SHA-256. It must equal the entry hash. A redacted entry has no record, so this cannot be done for it; note which entries those are. 2. For each line in `chain.json`, build the chain link from its seven parts and compute SHA-256. It must equal the chain hash. 3. Each line's `previous` must equal the chain hash of the line before it (and be `null` on the first), and the numbers must run 1, 2, 3 … without gaps. 4. The last line's chain hash must equal `last_chain_hash` in `manifest.json`. Every entry hash in `chain.json` must also be the hash of an entry in `entries.json`. ## A script that does it NILSARO-LOGGEN's verifier runs the same four checks, and checks what `manifest.json` claims: how many entries there are, how many are redacted, the first and last number, and the rules versions used. It needs Python 3.8 or later and nothing but its standard library, and it reads nothing but the files named on its command line. Nilsaro publishes it at https://logg.nilsaro.com/verify/, where anyone can download it without an account. What follows is a copy of the published script, version 1.0. The published file's SHA-256 is: 490ade0571eacc5b03a775018be7577dd7259e6b276594e5e55b8f365ae02449 Save it as `nilsaro-loggen-verify.py` and run it with the bundle, zipped or unpacked: python3 nilsaro-loggen-verify.py DK-evidence-2026-10-08.zip ```python #!/usr/bin/env python3 """NILSARO-LOGGEN verifier: checks an evidence bundle or a full export. python3 nilsaro-loggen-verify.py DK-evidence-2026-10-08.zip python3 nilsaro-loggen-verify.py DK-evidence-2026-10-08/ python3 nilsaro-loggen-verify.py NILSARO-LOGGEN-full-export-2026-10-12.json python3 nilsaro-loggen-verify.py DK-evidence-2026-10-08.zip --file drawing.pdf For every project it is given, it runs the four checks VERIFY.md describes: 1. Each entry record, written as bytes by the hash rules, hashes to its entry hash. A redacted entry has no record left, so its content cannot be checked; it is named, not counted as a fault. 2. Each chain link, rebuilt from its parts, hashes to its chain hash. 3. Each link's previous hash is the chain hash of the link before it, and the numbers run 1, 2, 3 ... without gaps. 4. The last chain hash is the one the manifest names. It also checks what the manifest claims: how many entries there are, how many are redacted, the first and last number, and the hash rules versions used. --file computes a file's SHA-256 and names every entry whose file references carry that fingerprint. Exit code 0: everything checks out. 1: something does not. 2: it could not check at all -- not a bundle or a full export, unreadable, or a bundle format or rules version this script does not know, which a newer script will. It needs Python 3.8 or later and nothing but the standard library, and reads nothing but the files named on its command line. The rules it follows are in VERIFY.md, in every bundle. """ import argparse import hashlib import json import os import sys import zipfile VERSION = "1.0" BUNDLE_FORMATS = (1, 2) FULL_EXPORT_FORMATS = (1,) RULES_VERSIONS = (1, 2) BUNDLE_FILES = ("manifest.json", "entries.json", "chain.json") LINK_PARTS = ("v", "project_id", "number", "entry_id", "entry_hash", "locked_at", "previous") class CannotCheck(Exception): """What was given cannot be checked at all.""" def as_bytes(value): """A record or a chain link written as bytes, by the hash rules.""" text = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) return text.encode("utf-8") def sha256(data): return hashlib.sha256(data).hexdigest() def listed(numbers): return ", ".join(str(number) for number in numbers) def counted(count, singular, plural): return f"{count} {singular if count == 1 else plural}" def in_words(numbers): words = [str(number) for number in numbers] return " and ".join([", ".join(words[:-1]), words[-1]]) if len(words) > 1 else "".join(words) def parse(data, name): try: return json.loads(data.decode("utf-8")) except (UnicodeDecodeError, ValueError): raise CannotCheck(f"{name} is not readable JSON") from None def read_bundle(path): """The three JSON files of a bundle, zipped or unpacked.""" files = {} if os.path.isdir(path): for name in BUNDLE_FILES: try: with open(os.path.join(path, name), "rb") as handle: files[name] = parse(handle.read(), name) except OSError: raise CannotCheck( f"the folder has no {name}; it is not an unpacked bundle" ) from None return files try: with zipfile.ZipFile(path) as archive: names = archive.namelist() for name in BUNDLE_FILES: if name not in names: raise CannotCheck(f"the ZIP file has no {name}; it is not an evidence bundle") files[name] = parse(archive.read(name), name) except (zipfile.BadZipFile, OSError): raise CannotCheck("the ZIP file cannot be read") from None return files def load(path): """("bundle", files) or ("full export", data), whichever was given.""" if not os.path.exists(path): raise CannotCheck(f"there is no file or folder called {path}") if os.path.isdir(path) or zipfile.is_zipfile(path): return "bundle", read_bundle(path) try: with open(path, "rb") as handle: data = parse(handle.read(), os.path.basename(path)) except OSError: raise CannotCheck(f"{path} cannot be read") from None if not isinstance(data, dict) or not isinstance(data.get("projects"), list): raise CannotCheck( "this is neither an evidence bundle (a ZIP file or its folder) nor a full export" ) return "full export", data def known_formats(kind, formats): return f"this script knows {kind} formats {in_words(formats)}" def check_project(manifest, entries, chain, where): """One project's faults, and what was checked. ``where`` names the project in each fault; it is empty for a bundle, which holds only one. """ faults = [] prefix = f"{where}, " if where else "" def fault(message): faults.append(prefix + message) redacted = [] records: dict = {} numbers = {} for item in entries: number, record = item["number"], item["record"] numbers[item["entry_hash"]] = number if record is None: # Check 1 cannot be done for a redacted entry: there is no record to hash. if "redacted" not in item: fault(f"entry {number}: no record, and not redacted") redacted.append(number) records[item["entry_hash"]] = None continue if record.get("v") not in RULES_VERSIONS: raise CannotCheck( f"{prefix}entry {number} uses hash rules version {record.get('v')}; " f"this script knows versions {in_words(RULES_VERSIONS)}. A newer script is needed." ) # 1. The record, written as bytes by the rules, hashes to its entry hash. if sha256(as_bytes(record)) != item["entry_hash"]: fault(f"entry {number}: the record does not match its entry hash") records[item["entry_hash"]] = record project_id = manifest["project"]["id"] versions = set() previous = None for expected, line in enumerate(chain, start=1): number = line["number"] if line["v"] not in RULES_VERSIONS: raise CannotCheck( f"{prefix}chain line {number} uses hash rules version {line['v']}; " f"this script knows versions {in_words(RULES_VERSIONS)}. A newer script is needed." ) versions.add(line["v"]) # 2. The chain link, rebuilt from its parts, hashes to its chain hash. link = {part: line[part] for part in LINK_PARTS} if sha256(as_bytes(link)) != line["chain_hash"]: fault(f"line {number}: the chain link does not match its chain hash") # 3. Each previous is the chain hash of the line before; no gaps in the numbers. if number != expected: fault(f"line {number}: expected number {expected} here") if line["previous"] != previous: fault(f"line {number}: previous is not the line before's chain hash") if line["project_id"] != project_id: fault(f"line {number}: it belongs to another project") if line["entry_hash"] not in records: fault(f"line {number}: no entry has this entry hash") else: record = records[line["entry_hash"]] if record is not None and record["entry_id"] != line["entry_id"]: fault(f"line {number}: the entry with this hash has another id") if numbers[line["entry_hash"]] != number: fault(f"line {number}: the entry with this hash has another number") previous = line["chain_hash"] # 4. The last chain hash is the one the manifest names. if previous != manifest["last_chain_hash"]: fault("the last chain hash is not the one in manifest.json") # What the manifest claims. if not len(entries) == len(chain) == manifest["entry_count"]: fault("entries.json, chain.json and manifest.json count different numbers") claims_redactions = "redacted_count" in manifest or manifest["format"] >= 2 if claims_redactions and manifest.get("redacted_count") != len(redacted): fault( f"manifest.json says {manifest.get('redacted_count')} entries are redacted; " f"{len(redacted)} are" ) first = chain[0]["number"] if chain else None last = chain[-1]["number"] if chain else None if (manifest["first_number"], manifest["last_number"]) != (first, last): fault("the first and last number in manifest.json are not the chain's") if manifest["rules_versions"] != sorted(versions): fault( f"manifest.json lists hash rules versions {manifest['rules_versions']}; " f"the entries use {sorted(versions)}" ) project = manifest["project"] summary = { "name": f"{project['name']} ({project['prefix']})", "count": len(chain), "first": first, "last": last, "last_hash": previous, "redacted": redacted, "versions": versions, "entries": entries, } return faults, summary def check_bundle(files): manifest = files["manifest.json"] if manifest.get("format") not in BUNDLE_FORMATS: raise CannotCheck( f"this is bundle format {manifest.get('format')}; " f"{known_formats('bundle', BUNDLE_FORMATS)}. A newer script is needed." ) faults, summary = check_project(manifest, files["entries.json"], files["chain.json"], "") return faults, [summary] def check_full_export(data): if data.get("format") not in FULL_EXPORT_FORMATS: raise CannotCheck( f"this is full export format {data.get('format')}; " f"{known_formats('full export', FULL_EXPORT_FORMATS)}. A newer script is needed." ) faults = [] summaries = [] versions = set() for part in data["projects"]: manifest = part["manifest"] if manifest.get("format") not in BUNDLE_FORMATS: raise CannotCheck( f"{manifest['project']['name']} is in bundle format {manifest.get('format')}; " f"{known_formats('bundle', BUNDLE_FORMATS)}. A newer script is needed." ) where = f"{manifest['project']['name']} ({manifest['project']['prefix']})" project_faults, summary = check_project(manifest, part["entries"], part["chain"], where) if part["project"]["id"] != manifest["project"]["id"]: project_faults.append(f"{where}: its details name another project") faults.extend(project_faults) summaries.append(summary) versions |= summary["versions"] if data["rules_versions"] != sorted(versions): faults.append( f"the export lists hash rules versions {data['rules_versions']}; " f"its entries use {sorted(versions)}" ) return faults, summaries def fingerprint(path): digest = hashlib.sha256() try: with open(path, "rb") as handle: for block in iter(lambda: handle.read(1024 * 1024), b""): digest.update(block) except OSError: raise CannotCheck(f"the file {path} cannot be read") from None return digest.hexdigest() def carrying(summaries, digest, single): """Where a fingerprint appears: one line per file reference that has it.""" found = [] for summary in summaries: for item in summary["entries"]: record = item["record"] if record is None: continue # its fingerprints went with its record for reference in record.get("files") or []: if str(reference.get("sha256", "")).lower() == digest: place = f"entry {item['number']}" if not single: place = f"{place} of {summary['name']}" found.append(f"{place}, as {reference.get('path', '')}") return found def report(kind, checked, faults, summaries, fingerprints): single = kind == "bundle" for message in faults: print("FAULT:", message) if not faults: for summary in summaries: if summary["count"]: if summary["first"] == summary["last"]: numbers = f"number {summary['first']}" else: numbers = f"numbers {summary['first']} to {summary['last']}" print( f"OK: {counted(summary['count'], 'entry', 'entries')} in {summary['name']}, " f"{numbers}, last chain hash {summary['last_hash']}" ) else: print(f"OK: no entries locked in {summary['name']}") for summary in summaries: if len(summary["redacted"]) == 1: which, whose = "entry", "its" else: which, whose = "entries", "their" if summary["redacted"]: where = "" if single else f"{summary['name']}, " print( f"REDACTED: {where}{which} {listed(summary['redacted'])}; " f"{whose} content cannot be checked" ) for path, digest in fingerprints: name = os.path.basename(path) found = carrying(summaries, digest, single) if found: print(f"FILE {name}: SHA-256 {digest}") for place in found: print(f" in {place}") else: print(f"FILE {name}: SHA-256 {digest}; no entry here carries this fingerprint") versions = set() for summary in summaries: versions |= summary["versions"] what = checked if versions: rules = "hash rules version" if len(versions) == 1 else "hash rules versions" what = f"{what}, {rules} {in_words(sorted(versions))}" if not single: entries = sum(summary["count"] for summary in summaries) projects = counted(len(summaries), "project", "projects") what = f"{what}, {projects}, {counted(entries, 'entry', 'entries')}" if faults: verdict = f"{counted(len(faults), 'fault', 'faults')} found." else: verdict = "Everything checks out." print(f"Checked by nilsaro-loggen-verify {VERSION}: {what}. {verdict}") def main(argv=None): parser = argparse.ArgumentParser( prog="nilsaro-loggen-verify.py", description="Check a NILSARO-LOGGEN evidence bundle or full export, by the rules in " "VERIFY.md.", ) parser.add_argument("path", help="the bundle (ZIP file or unpacked folder) or full export") parser.add_argument( "--file", action="append", default=[], metavar="PATH", help="a file to look for by its SHA-256 among the entries' file references", ) parser.add_argument("--version", action="version", version=f"%(prog)s {VERSION}") arguments = parser.parse_args(argv) # A name the console cannot show is shown as ? rather than stopping the check. reconfigure = getattr(sys.stdout, "reconfigure", None) if reconfigure is not None: reconfigure(errors="replace") try: fingerprints = [(path, fingerprint(path)) for path in arguments.file] kind, given = load(arguments.path) try: if kind == "bundle": faults, summaries = check_bundle(given) checked = f"evidence bundle, format {given['manifest.json']['format']}" else: faults, summaries = check_full_export(given) checked = f"full export, format {given['format']}" except (KeyError, TypeError, AttributeError, IndexError) as missing: raise CannotCheck( f"the {kind} is not laid out as VERIFY.md describes ({missing!r})" ) from None report(kind, checked, faults, summaries, fingerprints) except CannotCheck as reason: print(f"CANNOT CHECK: {reason}") return 2 return 1 if faults else 0 if __name__ == "__main__": sys.exit(main()) ``` It prints `OK` and what it checked, or each fault it found, and names any redacted entries, whose content it could not check. A fault means that the bundle does not agree with itself: an entry, a link or the manifest has been changed, or a line is missing or out of place. It ends with exit code 0 when everything checks out, 1 when something does not, and 2 when it cannot check at all: the file is not a bundle, cannot be read, or uses a bundle format or rules version this copy does not know. A newer copy of the script knows every version before it. `--file PATH` looks for a file among the entries' file references: it computes the file's SHA-256 and names every entry whose reference carries that fingerprint. Entries locked under rules version 1 carry no fingerprints, and a redacted entry's went with its record.