Evidence packs: a signed record of a GNSS trust event
A pack is a technical record, not a legal opinion. It states what the Kshana engine computed from a stated receiver log under a stated configuration, and lets anyone check that nothing in it was changed afterwards. It does not say what caused an event, who was responsible, whether any obligation was met, or that the log shows what the receiver really received. The manifest and the summary both say this.
An evidence pack is for an incident report, an insurer, or a regulator's question: "what did your system say about the GNSS signal between these two times, and can we trust that record?"
Make a pack#
# once: a signing key. The private key stays with you; never commit it.
kshana evidence keygen --out signer.key # writes signer.key (0600) and signer.key.pub
kshana receiver-trust evidence session.toml \
--from 100 --to 260 \ # seconds since the first epoch, or ISO-8601 UTC
--key signer.key --out pack/ \
--title "Berth 4, 14 June" # optional
--from and --to are seconds since the first epoch of the log, or an ISO-8601 UTC time
when the log states its own start time. --out must not already hold files.
--created-utc takes a real UTC time such as 2026-01-02T03:04:05Z (anything else is
refused) or none, which leaves the creation time out and makes the pack byte-for-byte
reproducible. The creation time is whatever the signer states; nothing checks it against a
clock, which is what the optional timestamp token is for.
The key file holds a 32-byte Ed25519 seed as 64 hex digits; supply your own (for example
one kept in your key-management system) or let keygen make one. kshana warns if the
file is readable by other users. .gitignore excludes *.evidence-key; name your key that
way if it lives in a checkout.
What is in a pack#
| File | Content |
|---|---|
log-slice.bin |
the raw receiver-log bytes for the window |
config.json |
the scenario as run (every monitor threshold, event and comparison rule), the monitors that ran, the calibration baseline, and the scenario hash; for a vessel also the advisory statement every vessel output carries (advisory software, not type-approved navigation equipment). Directory names and inline payloads are removed. |
epochs.json |
every epoch in the window with the engine's statistics, the alarms (the reasons) and the trust state |
summary.html |
a self-contained, script-free summary: identification, hashes, results, reasons, every threshold, the per-epoch table, and the limits of the record |
manifest.json |
engine version, window, creation time, the log's format and name, the SHA-256 of the full log, the slice's range and SHA-256, the signer's public key and fingerprint, every file's size and SHA-256, and a hash chain over the files |
manifest.sig |
Ed25519 signature over the exact bytes of manifest.json |
timestamp.tsr |
optional RFC 3161 timestamp token |
Log slice. The reader reports the source byte span of each epoch, so the slice is the
smallest byte range that holds every epoch in the window (byte-range): NMEA, u-blox UBX,
RINEX 3 observations and Android logs. It is hashed as raw bytes, never as decoded text.
For a live feed or RINEX 2 there is no span; the whole log is then bundled and the manifest
says whole-log (the window limits which epochs are reported, not which bytes are
included). The summary states which applies. A slice is a record of the window's bytes,
not a stand-alone log: for example a RINEX slice has no file header.
Hash chain. The manifest lists the files in a fixed order. link₀ = SHA-256("kshana- evidence-chain/1"); for each file, linkᵢ = SHA-256(linkᵢ₋₁ ‖ SHA-256(file) ‖ len(name) ‖ name) (length as 4 bytes, big-endian). The last link is chain_head. Reordering,
removing or substituting a file changes the chain; the signature covers the chain.
Verify a pack#
kshana evidence verify pack/ --pubkey signer.key.pub [--log session.nmea] \
[--require-timestamp] [--allow-unpinned] [--json]
Exit status 0 means verified; 1 means something failed; 2 means a usage error; 3 means the pack is intact but its signer was not pinned (see below). Every check reports pass, fail or skipped, and every failure is named:
| Failure code | Meaning |
|---|---|
manifest-missing, manifest-malformed, unsupported-format |
the manifest cannot be read |
signature-missing, signature-malformed, signature-invalid |
no signature, not a 64-byte lower-case hex signature, or it does not verify over manifest.json |
public-key-malformed, public-key-mismatch |
the key is not valid, or differs from the one you supplied |
file-missing, file-size-mismatch, file-hash-mismatch |
a listed file is absent or differs from the manifest; the file is named |
unlisted-file |
a file is in the pack but not in the manifest |
chain-mismatch, chain-head-mismatch |
a link is not what its inputs give; the file is named |
artifact-list-malformed, slice-record-inconsistent, epoch-count-mismatch |
the manifest contradicts itself or epochs.json |
full-log-hash-mismatch, slice-not-from-full-log |
with --log: the log you hold is not the one recorded, or the slice is not that range of it |
timestamp-missing, timestamp-malformed, timestamp-imprint-mismatch |
see below |
Changing one byte of any file fails verification with the matching reason: a changed artifact is reported as that file's hash mismatch and nothing else; a changed manifest fails the signature. Re-computing hashes and the chain after editing a file does not help without the signing key; only the signature then fails.
Pin the key. Without --pubkey, a pack proves only that it is intact against the key
it names itself, which anyone can generate. So verification then exits 3 and says "INTACT,
BUT THE SIGNER IS NOT PINNED" rather than "VERIFIED"; --allow-unpinned accepts the
pack's own key knowingly and prints "VERIFIED against the key the pack names itself
(signer NOT pinned; pass --pubkey)". Get the signer's public key from the signer by a
route you trust (not from the pack) and pass it with --pubkey: 64 hex digits or the
.pub file. Passing the private key by mistake is recognised and refused. The
fingerprint (first 128 bits of the key's SHA-256, 32 hex digits) is printed for comparing
by eye; pinning the full key is the reliable path.
Pack format and verification procedure (normative)#
This section is the specification an independent verifier is written from. Words in capitals are requirements. A pack is a directory (or any map from file name to bytes) of files named exactly as below; a verifier works on the bytes of each file as stored and never re-serialises anything.
Files. The artifact files, in this fixed order, are log-slice.bin, config.json,
epochs.json, summary.html. A pack also has manifest.json and manifest.sig, and
MAY have timestamp.tsr. Any other name is an unlisted file.
Hex. Every hex string is lower-case 0-9a-f only, with no whitespace; upper case is
not accepted. SHA-256 values are 64 digits, the public key is 64 digits, the signature
is 128 digits.
manifest.sig. The 128-digit lower-case hex of the 64-byte Ed25519 signature (RFC
8032, the pure Ed25519 scheme) over the exact bytes of manifest.json, followed by
exactly one \n and nothing else. The signature MUST be checked strictly: a signature
whose S value is not less than the group order L is invalid.
manifest.json. A UTF-8 JSON object. Its members (a verifier MUST find each with
the stated JSON type; additional members are ignored):
| member | type | meaning |
|---|---|---|
format |
string | MUST equal kshana-evidence/1 |
title, engine_version, disclaimer |
string | descriptive |
created_utc |
string or null | creation time, descriptive |
window |
object {from_s, to_s} |
numbers, seconds |
epochs_in_window |
non-negative integer | the number of entries of the JSON array in epochs.json |
log |
object | format string, file_name string, full_sha256 hex, full_bytes non-negative integer, start_label string or null, and slice |
log.slice |
object | kind (exactly byte-range or whole-log; any other value makes the manifest malformed), start and end non-negative integers (byte offsets, end exclusive), sha256 hex |
signer |
object | algorithm string (ed25519), public_key hex (32 bytes), fingerprint string |
artifacts |
array of objects | one per artifact file, in the fixed order: name string, role string, bytes non-negative integer, sha256 hex, link hex |
chain_head |
hex | the last link |
signer.fingerprint is the first 32 hex digits of SHA-256 over the 32 public-key bytes.
Hash chain. link0 is SHA-256 of the 23 ASCII bytes kshana-evidence-chain/1. For
each artifact in order, link = SHA-256( previous_link (32 bytes) ‖ SHA-256(file) (32 bytes) ‖ len(name) as 4 bytes big-endian ‖ name as UTF-8 ), where SHA-256(file)
is taken from the manifest's recorded sha256 for that artifact (so the chain is a
property of the manifest and is checked against it, not against the files).
Procedure. A verifier performs these steps in order and collects every failure; the
result is the set of failures, each a code and, where stated, the file name. It
MUST NOT stop at the first failure except where stated. pin is the public key the
verifier was told to trust, if any.
- No
manifest.json: failuremanifest-missing; stop. - If
manifest.jsonis not a JSON object with every member above in the stated types (includingartifactsentries with every member, andlog.slice): failuremanifest-malformed. Otherwise, ifformatis not the value above:unsupported-format. - Signature phase. This phase is performed whether or not the manifest was readable.
If the manifest was readable, the named key is
signer.public_key; if it is not 64 lower-case hex digits, failurepublic-key-malformed(no named key); otherwise ifsigner.fingerprintis not the fingerprint of it, failurepublic-key-malformed(the key stays usable). If both apinand a named key exist and differ: failurepublic-key-mismatch. The check key ispinif one was given, otherwise the named key if there is one; with neither there is no check key (this includes every case where the manifest was not readable and nopinwas given). If there is nomanifest.sig:signature-missing. Else if the file is not exactly 128 lower-case hex digits plus one\n:signature-malformed. Else if there is a check key that is not a valid Ed25519 public key encoding:public-key-malformed. Else if there is a check key and the signature does not verify strictly over the bytes ofmanifest.json(whatever they are):signature-invalid. (With no check key the signature is not checked and no failure is raised for that.) - If the manifest was not readable (step 2 gave
manifest-malformed): stop here. - Artifacts. If the
artifactsnames are not exactly the four above in order:artifact-list-malformed. Then for every manifest entry, in order: if the pack has no file of that name,file-missing(name); else if its length differs frombytes,file-size-mismatch(name); and, independently, if SHA-256 of the file differs fromsha256,file-hash-mismatch(name). - Unlisted files: for every file in the pack other than
manifest.json,manifest.sig,timestamp.tsrand the names in the manifest'sartifacts:unlisted-file(name). - Chain. Walk the manifest's
artifactsin order fromlink0. If an entry'ssha256is not 64 lower-case hex digits:manifest-malformedand the chain is abandoned (no head check). Otherwise compute the link; if it differs from the entry'slink:chain-mismatch(name). After the walk, if the chain was not abandoned and the last link differs fromchain_head:chain-head-mismatch. - Slice and epochs. Raise
slice-record-inconsistent(at most once per reason, and a verifier reports the code once however many reasons apply) if any of:start > end;end > full_bytes; thelog-slice.binentry'ssha256differs fromslice.sha256; that entry'sbytesdiffers fromend - start; orkindiswhole-logand any ofstart != 0,end != full_bytes,slice.sha256 != full_sha256. Ifepochs.jsonexists and parses as a JSON array whose length differs fromepochs_in_window:epoch-count-mismatch. - Full log. Only if the verifier was given the original log bytes: if their SHA-256
differs from
log.full_sha256,full-log-hash-mismatch; otherwise if the rangestart..endis not inside the log or the log's bytes there differ fromlog-slice.bin,slice-not-from-full-log. - Timestamp. With no
timestamp.tsr: if the verifier was told to require one,timestamp-missing; otherwise nothing. With one: if it cannot be read as an RFC 3161 token or response (see below),timestamp-malformed; otherwise if its message imprint is not the hash, in the imprint's own algorithm (SHA-256, SHA-384 or SHA-512), of the bytes ofmanifest.json:timestamp-imprint-mismatch.
The pack is verified exactly when the failure set is empty. Whether the signer was pinned is reported separately and does not change the failure set.
Timestamp token. A token is a DER ContentInfo whose content type is CMS SignedData
(1.2.840.113549.1.7.2) and whose encapsulated content type is id-ct-TSTInfo
(1.2.840.113549.1.9.16.1.4); a response is a DER TimeStampResp (a PKIStatusInfo
followed by the token) whose status is 0 or 1. The TSTInfo's message imprint algorithm
must be SHA-256, SHA-384 or SHA-512 with a digest of that algorithm's length, and its
genTime a real UTC calendar time. Indefinite-length BER is not accepted. The
authority's signature and certificate chain are NOT checked.
RFC 3161 timestamp (optional)#
A timestamp token from a timestamping authority shows the manifest existed at a time the
authority attests. The token is over manifest.json, so it is requested after the pack is
made (there is no way to supply one when the pack is created, and create_bundle refuses a
token that is not over that exact manifest):
openssl ts -query -data pack/manifest.json -sha256 -cert -out req.tsq
# send req.tsq to your authority, get the response, then:
kshana evidence attach-timestamp pack/ response.tsr
openssl ts -verify -in response.tsr -data pack/manifest.json -CAfile authority-ca.pem
kshana evidence verify reads the token (a bare token or a full response), checks that it
was granted and that its message imprint equals the hash of manifest.json, and prints
the time it states, always followed by the words "timestamp authority signature not verified
by Kshana". It does not check the authority's signature or certificate chain;
this build carries no RSA, ECDSA or X.509 code. The report says so each time
(authority_signature_verified: false). Run the openssl ts -verify line above for that
part. A token whose imprint differs, or one that does not parse, is a failure. The token's
content types, imprint length and generation time are checked for sense.
The token sits beside the signed manifest, not inside it. Nothing in the signature or
the chain covers it, so a token can be removed, or a different valid token substituted,
without the signature failing. verify therefore notes when a pack has none, and
--require-timestamp makes a missing token a failure (timestamp-missing); a substituted
token is caught only if it does not match the manifest. attach-timestamp refuses to
overwrite an existing token unless you pass --replace. If a timestamp matters to you,
keep the token and the verification result with your own records, and require it.
From code#
kshana::evidence::build_receiver_trust_pack is the one entry point the command line and
every other surface call to make a pack: a scenario, the log's bytes (and the navigation
file's, for RINEX), the window as text, a title and an optional creation time, plus the
signing seed, in; the files, the epoch count, the slice, the manifest hash and the signer
fingerprint out. It reads no files and no clock, so the same inputs give the same bytes.
Below it, create_bundle and verify_bundle are pure functions: bytes in
(EvidenceInput, a 32-byte seed, a BTreeMap<String, Vec<u8>> of files), serialisable
results out (Files, VerifyReport). They use no files, clock or process state, and
build for wasm32, so the Python, WebAssembly and server surfaces call them unchanged.
The command line is a thin wrapper over them.
What a pack does not do#
- It does not re-run the assessment. Anyone with the full log and
config.jsoncan re-runkshana receiver-trustand compare withepochs.json; the pack proves what was recorded, not that the engine was right. - It does not prove the log is genuine or complete. The hash of the full log ties the record to one file; where that file came from is for the operator to establish.
- It carries no performance claim about detection or rejection of interference.
- It is a technical record and not a legal opinion or a certification.