Keeps a history of runs (one manifest per run) as a hash chain: each
entry records the digest of the entry before it, so the chain's
integrity covers the ORDER and COMPLETENESS of the history, not merely
the contents of each manifest. Deleting an entry, inserting one, or
editing one breaks the links from that point onward, and
chnvrf() reports the first index where the break
occurs. Use it
when the record of which analyses were run, and in what order, has to be
auditable.
Usage
chnnew()
chnapp(chain, entry, label = NA_character_)
chnhead(chain)
chnseal(chain)
chnvrf(chain)Arguments
- chain
A chain from
chnnew()orchnapp().- entry
Any object to record. It is digested through its own deterministic serialization, so lists and data frames are accepted as readily as strings.
- label
Optional short label for the entry (the first element of
as.character(label)is kept);NAby default. Covered by the entry's digest.
Value
chnnew() and chnapp() return an object of
class c("bricklayer_chain", "list") with entries (a list of
entries, each with label, payload, prev,
scheme and digest) and genesis (64 zeros).
chnvrf() returns a
list of class bricklayer_chain_check with valid (logical),
n (integer, entries), broken_at (integer index of the first
bad entry, NA when intact) and head. chnhead()
returns the head digest (64 hex characters; the genesis value for an
empty chain). chnseal() returns a single digest over the verified
chain's length and links, or NA if the chain does not verify.
Details
chnnew() starts an empty chain, chnapp() adds an
entry, chnhead() returns the last link, chnvrf()
checks every link and chnseal() returns one digest to sign.
This is the structure behind an append-only audit log, and it is what a per-manifest digest alone cannot give you: individually valid manifests say nothing about whether any were removed.
Each entry stores payload, the SHA-256 of the entry (of its UTF-8
bytes for a single string, otherwise of serialize(entry, version =
2, xdr = TRUE) with the four bytes that name the writer's R version set
to zero), prev, the previous link (64 zeros for the first),
scheme (2) and digest = SHA-256 of
"v2|<prev>|<payload>|<label>", where <label> is "0"
for a missing label and "1" followed by the label otherwise. The
entry itself is not kept, only its digest; the label is covered, so
editing it breaks the link. Zeroing the writer-version bytes makes the
payload the same on every R version, for any R object (a canonical JSON
encoding would cover only the objects JSON can hold); the rest of a
version-2 stream depends only on the object.
Entries written by 0.5.12 and earlier carry no scheme. Their digest is
the SHA-256 of the string "<prev>:<payload>" (the label not covered,
and the payload of a non-string entry taken over serialized bytes that
include the writer's R version). chnvrf() and chnseal() still
accept them under those rules, with a warning that they were sealed with the
weaker scheme. Appending to such a chain adds scheme-2 entries.
chnvrf() walks the chain from the start: an entry fails when
its prev is not the previous link, its scheme is neither 2
nor absent, or its digest does not recompute (compared in constant
time with cdgsteq()).
What the head covers, and what it does not
chnhead() is the STORED digest of the last entry. Deleting an
entry from the MIDDLE leaves that value untouched – the stored digests
do not change, only the links between them stop agreeing – so the head
alone will not notice. chnvrf() will,
and names the index.
Conversely, truncating from the END leaves a perfectly valid prefix that
chnvrf() accepts, while the head
changes.
The two failures are complementary, which is why chnseal()
exists: it verifies the links AND folds the entry count and every link
digest into one value (SHA-256 of the count and the links joined by
"|"), so a single signature over the seal detects an edit, a
deletion, a reordering, an insertion and a truncation alike. Sign the
seal, not the head.
Signing is what turns tamper-EVIDENT into tamper-PROOF: without a signature an attacker who rewrites the whole chain leaves it internally consistent, because recomputing every link is cheap.
Error: a chain that does not come from chnnew() (for
chnapp(), chnhead() and chnvrf()).
Examples
# keep this example's
op <- options(rmoriebricklayer.xmssidxfile = tempfile())
# one-time index record out of your configuration directory; leave the
# default in use
ch <- chnnew()
ch <- chnapp(ch, "manifest for run 1", label = "run-1")
ch <- chnapp(ch, "manifest for run 2", label = "run-2")
ch <- chnapp(ch, "manifest for run 3", label = "run-3")
ch
#> ╭── Manifest chain ───────────────────────────────────────────────────────────╮
#> │ Status ✓ chain intact (3 entries) │
#> │ Entries 3 │
#> │ Head b9ccb96106a68f3d4d2a0cfb8e2ae160f7996393e28e93d7f4650c2bbd81ff49 │
#> │ Seal 940721e3704268b570bcbf1c9ae841bde02927f584ec44dc3cf053ef7404d8a0 │
#> │ │
#> │ Every entry links to the one before it, so none was edited, dropped or │
#> │ reordered after it was added. │
#> │ │
#> │ # label digest │
#> │ 1 run-1 cb25fa776c693a106b2d08a1 │
#> │ 2 run-2 03a645a531c93fbb12bb2c07 │
#> │ 3 run-3 b9ccb96106a68f3d4d2a0cfb │
#> ╰─────────────────────────────────────────────────────────────────────────────╯
# An intact chain verifies.
chnvrf(ch)$valid
#> [1] TRUE
# Editing an entry breaks it, and names where.
edited <- ch
edited$entries[[2]]$digest <- csha256("something else")
chnvrf(edited)$valid
#> [1] FALSE
chnvrf(edited)$broken_at
#> [1] 2
# So does deleting one, which a per-manifest digest would not catch.
dropped <- ch
dropped$entries[[2]] <- NULL
chnvrf(dropped)$valid
#> [1] FALSE
# Sign the SEAL, which covers the links and the length together.
key <- pqckg(height = 2)
sig <- cpssign(chnseal(ch), key)
cpsvrf(chnseal(ch), sig, signpubkey(key))
#> [1] TRUE
# Truncating the chain still seals, but to a different value, so the
# signature no longer verifies.
truncated <- ch
truncated$entries[[3]] <- NULL
cpsvrf(chnseal(truncated), sig, signpubkey(key))
#> [1] FALSE
# A chain whose links disagree has no seal to present at all.
chnseal(dropped)
#> [1] NA
# any object can be an entry, such as a whole manifest list
ch2 <- chnapp(chnnew(), list(run = 1, mean = 2.5), label = "list entry")
chnhead(ch2)
#> [1] "05bb042d04eb7378d536c4197e61f280f2e9f99d33d0c9cc28a475c488a4a206"
# only chains are accepted
try(chnvrf(list(entries = list())))
#> Error : chnvrf: `chain` must come from chnnew()
options(op)
