Specification · version 1.0 · 23 September 2026
Open evidence specification
Every byte RiskRouter’s evidence commits to, written down so that anyone can check it without us, and anyone can build a second implementation. Two already exist that share no code: the JavaScript verifier and a Python one, held to identical verdicts by the repository’s tests. Anyone may implement this specification without asking us: the specification, its test vectors, both verifiers and the conformance suite are published under the Apache License 2.0.
The completeness, disclosure and sampling constructions, and the SCITT profile, are also written up for the
IETF as an individual Internet-Draft,
draft-jangra-scitt-digest-evidence,
posted on 8 October 2026. No working group has adopted it. Where the draft and this page differ, this
page and spec-vectors.json are what was published.
0 · Conventions
Conventions
- Hashes are SHA-256, written as 64 lowercase hexadecimal characters.
- Strings are UTF-8. Fields are joined with
|, with no spaces and no escaping; no field may contain|. - Timestamps inside hashed strings are UTC,
YYYY-MM-DDTHH:MM:SS.ffffffZ, six fraction digits. A reader that receives+00:00, a space instead ofT, or fewer digits must normalise to that form first. - Signatures are ECDSA over P-256 with SHA-256, encoded as the 64-byte
r||s(IEEE P1363), base64. Public keys are JWK. The published key is in the public verifier repository, key iddd4b4b394c95586a, and stays there after any rotation. - “Frozen” means: changing it would invalidate evidence people already hold, so it will never change within a version.
1 · v1 quote ledger
The quote ledger: a hash chain
1.1 Canonical form (frozen)
chain_index|id|created_at|active_vertical|components|premium|compliance_status|matrix_version|distributor_id|prev_hash
components: every component key in ascending order, askey=trueorkey=false, joined with commas. A value istrueonly if it is the JSON literaltrue.premium: exactly two decimals, such as2.00.matrix_versionanddistributor_id: empty when absent.row_hash= SHA-256 of the canonical form. The first entry’sprev_hashis 64 zeros; every other entry’s is the previous entry’srow_hash. The lastrow_hashis the head.
1.2 Attestation (frozen payload)
riskrouter-ledger-attestation|v1|as_of|entries|head_hash
The signed statement that the ledger had entries entries and that head at as_of. A verifier
rebuilds this payload from the attestation’s own fields; it never trusts a signed_payload field alone.
1.3 Export and single-entry proof
An export (riskrouter-ledger-export|v1) carries the holder’s entries in full, the
(chain_index, row_hash, prev_hash) of every entry, and the signed attestation. A single-entry proof
(riskrouter-entry-proof|v1) carries one entry, the links from it to the head, and the attestation.
What these prove, precisely: the holder’s entries are rebuilt from content; other entries are followed by
digest only and bind because anyone recomputing the full ledger must reach the signed head. A rebuilt ledger is
caught by comparing with a head recorded earlier, which is why heads are anchored (section 4).
2 · v2 evidence log
The evidence log: digests of any regulated decision, in a Merkle tree
2.1 What the client sends
The client hashes its own record and sends only record_digest and a kind tag
(^[a-z0-9][a-z0-9.-]{0,39}$). The record never leaves the client.
The client must salt the record. Hash a random value of at least 128 bits together with the record,
and keep both. A digest of a guessable record, such as “approved”, can be confirmed by anyone who guesses it.
The salted construction this specification recommends is SHA-256(salt || record), but any construction the
client can reproduce is valid: the log commits to the digest, not to how it was made.
2.1b Canonical record and bundle (frozen)
How the record is hashed is the client’s choice, but a record another party must be able to re-hash, such as one written to a regime pack, uses this construction:
record = a JSON object; keys ^[a-z][a-z0-9_]{0,63}$ at every level; values are strings (valid
Unicode), integers within ±(2^53 − 1), true, false, null, arrays and objects,
nested at most 32 deep; no fractions (money is integer cents)
canonical = RFC 8785 (JSON Canonicalization Scheme) of the record
record_digest = SHA-256(salt || UTF-8(canonical)), salt 16 to 64 random bytes as lowercase hex
bundle = { "format": "riskrouter-record-bundle|1", "record", "salt_hex", "proof" }
A record outside the profile is refused rather than coerced. A bundle is intact when the record
and salt produce the entry’s record_digest, the record’s kind
is the entry’s, and the proof verifies as below. The vectors carry records chosen for the
places two languages differ (escapes, astral characters, key order, edge integers) and records
that must be refused.
2.2 Leaf (frozen)
leaf string = riskrouter-evidence-leaf|v2|leaf_index|created_at|distributor_id|kind|record_digest
leaf hash = SHA-256(0x00 || leaf string)
leaf_index counts from 0 with no gaps. created_at is set by the log, never by the client.
Every entry carries leaf_version, 2 or 3; a verifier rebuilds the leaf string for the version it sees.
2.2b Signed leaf (frozen, v3)
A v2 leaf proves that the log sealed a digest at a time. It does not prove that the distributor asserted anything: for that, the distributor signs its own claim, and the signature goes inside the leaf, so it cannot be swapped after the fact. Optional per entry; an unsigned entry is a v2 leaf exactly as above. Both versions sit in the same tree under the same head.
claim payload = riskrouter-evidence-claim|v3|kind|record_digest|signer_key_id|claimed_at
signed by the client: ECDSA P-256 over SHA-256, IEEE P1363 (r || s), base64
leaf string = riskrouter-evidence-leaf|v3|leaf_index|created_at|distributor_id|kind|record_digest|signer_key_id|claimed_at|client_signature
leaf hash = SHA-256(0x00 || leaf string)
signer_key_id = first 16 hex characters of SHA-256(JSON [x, y] of the public JWK), as a witness id is derived
claimed_at = the client's own timestamp, YYYY-MM-DDTHH:MM:SS.ffffffZ, exactly as it was signed
The client signs only what it knows before sending; leaf_index and created_at remain the
log’s. The log verifies the signature before recording and refuses a claim that does not verify, with
nothing written; the database then commits to the signature as presented, and every verifier checks it again. The
public key is registered once (POST /api/v2/evidence/keys) and served to anyone
(GET /api/v2/evidence/keys?key_id=); a proof of a v3 leaf carries it as signer_public_key.
The signer should publish the same key under its own control, exactly as a witness does, so that
“the client signed it” is not the log’s word alone. A key id belongs to one distributor forever;
registering a new key never removes an old one, so leaves signed before a rotation stay verifiable.
What a v3 leaf proves: this distributor, holding this key, asserted this digest and this kind at the time it
claimed, and the log sealed that assertion at created_at in a tree whose head is signed. What it does
not prove: that the record behind the digest is true, or that the key was not stolen.
2.3 Tree
RFC 6962 / RFC 9162, unchanged: node hash = SHA-256(0x01 || left || right), with the split at the largest
power of two smaller than the number of leaves; the root of the empty tree is SHA-256 of the empty string. Inclusion
and consistency proofs are the RFC 9162 section 2.1.3 and 2.1.4 constructions, verified by the algorithms given there.
The implementation is checked against RFC 6962’s own reference roots.
2.4 Signed tree head (frozen payload)
riskrouter-evidence-head|v2|tree_size|root_hash|timestamp
Signed with the same key as v1 attestations. A verifier must take tree_size and
root_hash from the same signed head: an inclusion proof does not bind the tree size on its own.
A head with no key bound is returned unsigned and says so; it is never given a signature it does not have.
2.5 Endpoints
POST /api/v2/evidence, POST /api/v2/evidence/batch, GET /api/v2/evidence/head,
GET /api/v2/evidence/proof, GET /api/v2/evidence/consistency, GET /api/v2/evidence/chain
(2b), and for signed leaves
POST and GET /api/v2/evidence/keys, described in the API reference and
openapi.json. A consistency proof is checked against the root of a head the verifier
saved, never a root the log supplies.
2b · Completeness
Chains, spot checks, disclosure and co-sealing
A proof of one record answers “was this changed?”. An auditor’s harder questions are “is this all of it?”, “did you keep it?”, “what did this one field say?” and “did both of you agree to it?”. The four constructions below answer them. Each is optional per record, none changes a leaf, and all four are frozen from their first published vectors (settled 7 October 2026).
2b.1 Completeness chains (frozen)
A firm puts records in numbered chains. The record carries three fields inside itself, and the entry carries two of them beside the digest, outside the leaf:
record fields chain_tag 64 hex characters; recommended: HMAC-SHA256(secret, "riskrouter-chain-tag|1|" || name)
chain_seq 1, 2, 3, ... with no gap
chain_prev the record_digest of the record at chain_seq - 1; 64 zeros at position 1
entry fields chain_tag, chain_seq sent with record_digest; never part of the leaf string
link line chain_seq|leaf_index|created_at|record_digest\n
links_digest SHA-256(UTF-8(the link lines from `from` to `to`, concatenated))
chain payload riskrouter-evidence-chain|v1|chain_tag|length|tree_size|root_hash|timestamp|from|to|links_digest
The log refuses a chain_seq that is not the next position, and a chain_tag another distributor
uses, with nothing written. GET /api/v2/evidence/chain?chain_tag= answers what the log holds in that chain as
of the current signed head: its length and, for each position, the leaf, the time and the digest, signed over
the chain payload with the head-signing key. A holder checks the signature, recomputes links_digest, and then
that each record shown produces the digest at its position, names that position, and points at the one before. A
position the statement lists and nobody showed is a record left out. A firm alone cannot hide one; doing so would
need the log to sign a statement its own tree contradicts. On a log the firm runs itself, this guarantee is the firm’s
own word.
completeness bundle = { "format": "riskrouter-completeness-bundle|1", "chain": [statement, ...], "records": [{ record, salt_hex }, ...], "as_of"? }
With as_of, only the positions sealed by that time must be shown.
2b.2 Spot checks (frozen)
An auditor announces, and seals in the log, a future Bitcoin block height and a sample size. When the block is mined its hash is the seed, and neither side could have chosen it:
for counter = 0, 1, 2, ...
h = SHA-256(UTF-8("riskrouter-spot-check|v1|" seed "|" population "|" counter))
x = first 8 bytes of h, big-endian unsigned
skip if x >= 2^64 - (2^64 mod population)
position = 1 + (x mod population), kept unless already drawn
until min(sample, population) positions are drawn
spot-check bundle = { "format": "riskrouter-spot-check-bundle|1", "chain", "seed", "bitcoin_height", "sample_size", "records" }
population is the chain’s length in the signed statement. The firm shows the records at
the drawn positions; nobody sends anyone a bulk copy. The verifier recomputes the draw; that the block at that height has
that hash, and was mined after the plan was sealed, the auditor checks on any Bitcoin node.
2b.3 Selective disclosure (frozen)
field salt HMAC-SHA256(secret, UTF-8("riskrouter-disclosable|1|" || name)) 32 bytes, one per top-level field
commitment SHA-256(field salt || UTF-8(canonical({name: value})))
sealed record { "format": "riskrouter-disclosable|1", "kind": <the record's kind>, "fields": { name: commitment, ... } }
record_digest SHA-256(salt || UTF-8(canonical(sealed record))) as for any record (2.1b)
disclosure { "format": "riskrouter-disclosure-bundle|1", "record": sealed record, "salt_hex", "proof",
"disclosed": { name: { "value", "field_salt_hex" }, ... } }
The secret never leaves the firm: a disclosure carries the salts of the fields it shows and nothing that derives the others, so a hidden low-entropy value (a score, an outcome) cannot be guessed. Field names are visible; values are not.
2b.4 Co-sealing
Two parties who share a record and its salt each record the same digest as a signed (v3) entry, under their own key and
their own account. A co-seal (riskrouter-coseal|1: bundles, optionally
expected_signers, the keys each party published) holds when every bundle verifies, the digests are equal, every
entry is v3, and the signers and the distributors are all different. Nothing new is frozen: it is a rule over leaves that
already exist.
Both verifiers and the conformance suite check all four; spec-vectors.json carries a chain statement and its
payload, four spot-check draws (one seeded with Bitcoin’s genesis block hash), and a disclosure with its field salts,
commitments and sealed record. Design notes: docs/completeness.md, docs/spot-checks.md,
docs/selective-disclosure.md, docs/co-sealing.md.
3 · Witnesses
Independent witnesses
A witness is anyone who is not the operator. Each time it runs it fetches the current signed head, checks the operator’s signature, requests a consistency proof from the last head it saved, and checks that proof against the root it saved. Only if every check passes does it co-sign:
riskrouter-evidence-cosign|v2|witness_id|tree_size|root_hash|cosigned_at
If a check fails, the witness holds two heads, both signed by the operator, that cannot both be true. That pair is
evidence of a rewritten history that the operator cannot disown, produced by someone the operator does not control.
The reference witness is tools/witness.mjs; it saves the pair and exits with status 2.
The portable witness in witness/ of the verifier repository does the same for many logs at once,
on Cloudflare Workers, Node or GitHub Actions. Its cosignature file is the same format, with one added member,
log ({ id, api }), which a verifier ignores. It pins a log’s head-signing keys the first
time it sees them and raises an alarm if a later list drops or changes one; an alarm is never overwritten, and the
witness stops co-signing that log. It serves, read-only, /public-key.json, /logs.json,
/cosignatures/<log>/latest.json and /alarms/<log>.json. Logs and witnesses are listed
at /registry.json (riskrouter-witness-registry|1); a witness is listed only once
it publishes its key under its own control. There are no independent witnesses yet running this tool;
six staging witnesses of the public witness network cosign the same tree's C2SP checkpoints (below).
How to become one: /witnesses.
The same log in the C2SP formats
The v2 tree is also published in the formats the public witness network speaks, so that witnesses who have never
run our code can check it: c2sp.org/signed-note,
tlog-checkpoint, tlog-cosignature
and tlog-tiles. Two things are frozen, on the same terms as the forms above: the
origin, api.riskrouter.eu/tlog/evidence-v2, and the checkpoint text, three lines and no extension lines:
api.riskrouter.eu/tlog/evidence-v2
<tree_size>
<base64 of the RFC 6962 root>
The root is the same 32 bytes as root_hash in the v2 head, in base64 instead of hex, and one read of the tree
gives both. The note is signed with the log’s Ed25519 key (signature type 0x01), whose key name is the origin
and whose key ID is the first four bytes of SHA-256(name || 0x0A || 0x01 || public key). That is a separate key from the
P-256 head-signing key, published as a vkey in anchors/checkpoint/log.vkey. A checkpoint is served at
GET /tlog/evidence-v2/checkpoint only when that key is bound; otherwise the answer is 503, never an
unsigned note. Witnesses of the network return cosignatures of type 0x04 (cosignature/v1, Ed25519 over
cosignature/v1\ntime <seconds>\n and the checkpoint text), and we keep each one that verifies in
anchors/checkpoint/cosigned/.
Tiles of hashes are served at /tlog/evidence-v2/tile/<L>/<N>[.p/<W>], so anyone can mirror the whole
tree and compute any proof without our API (node tools/checkpoint.mjs mirror). Entry bundles are not published:
every leaf names a distributor and a kind, and a distributor holds its own entries with their proofs. Both verifiers check
checkpoints and cosignatures; spec-vectors.json carries a checkpoint and a cosignature made with the
RFC 8032 test keys, and the conformance suite derives cases from them.
3b · SCITT
Signed Statements and COSE Receipts
The log also serves as a Transparency Service in the sense of the IETF SCITT architecture
(RFC 9943), returning receipts as defined by RFC 9942 (COSE Receipts). Registration,
POST /api/v2/evidence/statement, applies the policy riskrouter-scitt-profile|1: a COSE_Sign1 signed
ES256 with a key the caller registered (kid = its 16-hex id as UTF-8), CWT claims iss and
sub (text, neither an email address), and a hash envelope (RFC 9995: protected 258 =
-16, a 32-byte payload), so the artifact never reaches the log. What the log keeps is one v2 leaf of kind
scitt.statement whose record_digest is:
SHA-256( 0xD2 0x84 || bstr(protected) || 0xA0 || bstr(payload) || bstr(signature) )
that is, the statement re-encoded with an empty unprotected header. A receipt (riskrouter-scitt-receipt|1) is a
COSE_Sign1 signed ES256 with the head-signing key (kid = its key id), vds 395 = 1 (RFC9162_SHA256),
vdp 396 holding one inclusion proof [tree_size, leaf_index, [hash, …]] at label -1, and a
detached payload: the root. Its protected header carries CWT claims (iss, sub = the statement
digest, iat) and riskrouter-leaf = [leaf_index, created_at, distributor_id, kind]. To check
one: rebuild the v2 leaf string from those fields and sub, walk the proof to a root, and verify the signature
over ["Signature1", protected, h'', root] with our published key. Both verifiers do this, and pycose, an
independent COSE library, verifies our receipts in our tests. The statement itself stays with its issuer: we keep its
digest only, which is where we depart from the architecture draft, deliberately (rule 15). spec-vectors.json
carries a statement digest and a receipt’s signed bytes.
4 · Anchoring
Anchoring heads in time
Signed v1 heads are committed to OpenTimestamps calendars and upgraded to Bitcoin attestations; the 13 September 2026
head is in block 967830 and later. Since 18 October 2026 the newest v2 head of each day is anchored the same way: its
payload alone, UTF-8 with no trailing newline, is kept as anchors/ots/evidence-head-N.payload, and the
proofs beside it commit to its SHA-256, the same imprint as 4.1; ots verify checks one with no code of
ours, and /age lists every head with its block. This bounds when a head existed, from
above. It says nothing about the moment of each entry before it.
4.1 RFC 3161 time-stamp tokens (frozen imprint)
Every two hours, each new signed head (v2) and each new signed attestation (v1) is sent to the RFC 3161 time-stamping
authorities listed in anchors/tsa/authorities.json. The token binds exactly the payload we signed:
message imprint = SHA-256(UTF-8(signed payload)) hashAlgorithm sha256
signed payload = riskrouter-evidence-head|v2|tree_size|root_hash|timestamp
or riskrouter-ledger-attestation|v1|as_of|entries|head_hash
file = anchors/tsa/<evidence-head-N | ledger-N>.<authority>.json
{ "format": "riskrouter-head-timestamp|1", subject, signed, signature, payload,
tsa: { id, name, url, qualified, trusted_list }, token (DER, base64), gen_time, requested_at }
A verifier rebuilds the payload from the head in the file, checks our signature on it, then the token: the
imprint, the signed attributes (content type and message digest), the authority’s signature with the
certificate the token carries, that the certificate is for time-stamping and valid at genTime, and
the chain the token carries, whose root it compares with the root the authority publishes
(openssl ts -verify -data payload -in token -token_in -CAfile root.pem does the same). Both verifiers
do this offline. None of the authorities used today is a qualified eIDAS time-stamping service:
a token is evidence of time from an independent authority, without the presumption of accuracy that a qualified
time stamp carries under Article 41(2) of Regulation (EU) No 910/2014. A file calls a token qualified only when
its authority is listed with its entry on the EU Trusted List.
5 · Test vectors and implementations
Check your implementation
spec-vectors.json holds deterministic inputs and expected outputs for every frozen form above: timestamp normalisation, a v1 canonical form and row hash, the attestation payload, eight v2 leaves and the roots of every prefix, an inclusion proof, a consistency proof, a head payload, a cosignature payload, a C2SP checkpoint and cosignature with their Ed25519 signatures, a chain statement, spot-check draws and a selective disclosure. It is regenerated from the reference code and both verifiers must agree with it before any change ships.
- JavaScript:
tools/verify-ledger.mjs,tools/merkle.mjs,tools/witness.mjs(Node, standard library). - Python: riskrouter_verify.py (standard library only, including its own P-256 and Ed25519 checks).
- In the browser: the checker on /verify, which sends nothing anywhere.
Run the conformance suite
The conformance suite turns those vectors into cases an implementation in any
language can answer, adds proofs that have been deliberately broken and must be refused, and grades the answers. It ships
with the reference code and the vectors in /kit/conformance/, needs only Node, and contacts nothing.
node conformance.mjs cases > cases.json # one JSON object per frozen form, with the expected answer
# your implementation answers each case: { "<case id>": <answer> }
node conformance.mjs grade answers.json --name "Your product 12.4" > result.json
# or drive a command that reads one case on stdin and prints its answer:
node conformance.mjs run --command "python3 my_impl.py" --name "Your product 12.4" > result.json
# and check what your product produced: an export, an entry proof, an evidence proof, an evidence pack
node conformance.mjs artefacts ./evidence/ --key signing-key.json > artefacts.json
The result file is the implementer’s own statement: that this implementation, on this date, answered these cases as
the vectors say, named by the vector set (riskrouter-spec-vectors|1) and the digest of the cases, so anyone can
run the same cases again. RiskRouter certifies nothing and nobody. An implementation that passes needs no permission from
us to say so, and no relationship with us to keep it true: the vectors and the suite are published, and both are free.
A defect in this specification, or a disagreement between it and any implementation, is a bug we want to hear about: security explains how to report one.