A manifest with a SHA-256 in it proves the data was not corrupted. It proves nothing about who produced it, because anyone who can edit the data can recompute the digest and write it back.
This vignette covers the three things that close that gap: signing a manifest, pinning a capsule chunk by chunk, and making the history of runs tamper-evident rather than only each run.
For a verifier who should be able to check but not forge, the signature has to be asymmetric. The classical options (RSA, Ed25519) fall to Shor’s algorithm.
Worth being precise about what that threatens. Hashing is fine: Grover’s algorithm only halves the security exponent, so SHA-256 retains roughly 2^128 preimage resistance, and HMAC with a 256-bit key is likewise fine. Signatures are the part that breaks.
pqckg() builds a hash-based signing key – a Merkle tree
over Winternitz one-time keys, the RFC 8391 construction, over the
SHA-256 this package already ships. Its security rests on the hash
function alone: no lattice assumption, no elliptic curve, and no new
system dependency.
# Small for the vignette; the default height 10 gives 1024 signatures.
signingkey <- pqckg(height = 3)
signingkey
#> ── Signing key (post-quantum) ────────────────────────────────────
#> scheme xmss-sha256
#> root 8e7fd9ef2cdcf92fe14e04dfc62547880ff87e729d3fc3cfbc52c6cdb46bcbd5
#> height 3
#> used 0 of 8 signatures
#> remaining 8
#> secret <withheld>
#> ! one signature per index; never sign twice at one index
#> ──────────────────────────────────────────────────────────────────Publish the public half. It carries no secret:
pub <- signpubkey(signingkey)
pub
#> ── Public verification key ───────────────────────────────────────
#> scheme xmss-sha256
#> root 8e7fd9ef2cdcf92fe14e04dfc62547880ff87e729d3fc3cfbc52c6cdb46bcbd5
#> height 3
#> ──────────────────────────────────────────────────────────────────s1 <- cpssign(csha256(manifest), signingkey)
cpsvrf(csha256(manifest), s1, pub)
#> [1] TRUE
# A verifier holding only `pub` cannot forge one.
cpsvrf(csha256(edited), s1, pub)
#> [1] FALSEA Winternitz key signs once. Signing two different
messages at one leaf index leaks enough chain material to forge a third.
So the key is stateful, and xmssadv(key, signature) returns
the advanced state for you to carry forward:
signingkey <- xmssadv(signingkey, s1)
signingkey$next_index
#> [1] 1
s2 <- cpssign(csha256("a second manifest"), signingkey)
cpsvrf(csha256("a second manifest"), s2, pub)
#> [1] TRUE
# and forward again, for the next signature
signingkey <- xmssadv(signingkey, s2)An exhausted key is an error rather than a silent wrap-around:
k <- pqckg(height = 1) # signs exactly 2 messages
k <- xmssadv(k, cpssign("one", k))
k <- xmssadv(k, cpssign("two", k))
#> Warning: this key has used all 2 of its one-time indices: generate a new one
#> with pqckg()
cpssign("three", k)
#> Error:
#> ! this key is exhausted: a height-1 key signs 2 messages and all of them are used. Generate a new key -- reusing an index would break the signature scheme.On the standardised schemes: ML-DSA (FIPS 204) and SLH-DSA (FIPS 205)
are implemented here too, at every parameter set both standards define –
three for ML-DSA, twelve for SLH-DSA – with no system dependency, so the
list pqcbknd() reports is the same on every machine.
fipskg() takes any of them, fipskey() wraps
key material that came from elsewhere, and both are stateless: one key
signs any number of messages.
What makes that defensible is the cross-check rather than care. In deterministic mode this package and OpenSSL 3.5 produce the same bytes for every parameter set, which is a test a subtly wrong implementation cannot pass: it would have to be wrong in exactly the same way, in a construction where a single misplaced field changes every output.
pqcbknd()
#> [1] "xmss-sha256" "ML-DSA-44" "ML-DSA-65"
#> [4] "ML-DSA-87" "SLH-DSA-SHA2-128s" "SLH-DSA-SHA2-128f"
#> [7] "SLH-DSA-SHA2-192s" "SLH-DSA-SHA2-192f" "SLH-DSA-SHA2-256s"
#> [10] "SLH-DSA-SHA2-256f" "SLH-DSA-SHAKE-128s" "SLH-DSA-SHAKE-128f"
#> [13] "SLH-DSA-SHAKE-192s" "SLH-DSA-SHAKE-192f" "SLH-DSA-SHAKE-256s"
#> [16] "SLH-DSA-SHAKE-256f"cpssign() signs a string, which proves the string was
signed and leaves three things implicit: which key, which scheme, which
bytes. A verifier handed a directory still has to be told all three, and
a check that depends on being told what to check is a formality.
cpsatst() records them inside the signed payload.
m <- mkmnf(list(dataset = "otis", rows = 1200L),
env = FALSE)
key <- fipskg("ML-DSA-65")
att <- cpsatst(m, key, note = "counts as published")
cpschkatst(att, m)
#> ── Attestation check: OK ─────────────────────────────────────────
#> attestation_complete ok
#> manifest_digest ok d61525e7f8b685bb5eb2cb3d1c7747b4161508c3a255
#> signature ok
#> ──────────────────────────────────────────────────────────────────The check needs the attestation and the manifest, nothing else. Change either – or edit the note, which is covered by the signature because it is part of the digest – and it fails:
What it does not establish is that the key belongs to whoever you
think: an attestation is only as good as the channel the public key
arrived on. Pass keyexp when you know which key should have
signed.
A manifest is meant to be checked against later. That only works if it records what actually happened, at the precision it happened:
onethird <- mkmnf(list(x = 1 / 3), env = FALSE)
back <- fromjs(mnfcanon(onethird))
identical(back$meta$x, 1 / 3)
#> [1] TRUESign mnfdgst() rather than the pretty JSON. R lists keep
insertion order, so building meta before
results or the other way round gives different bytes for
the same content; the canonical form sorts the keys, so the digest
depends on what the manifest says and not on how a script assembled
it.
Everything above is about the record being intact. None of it says the number means anything: a capsule can be signed, hashed, chained and reproduced byte for byte while reporting an artefact. That is a separate question and it needs controls that can fail.
set.seed(1)
d <- data.frame(x = rnorm(200))
d$y <- 0.8 * d$x + rnorm(200)
cpsfals(d, function(z) cor(z$x, z$y), trt = "x",
n = 199, seed = 42)
#> ── Falsification controls ────────────────────────────────────────
#> observed statistic 0.5812737
#> permutations 199 (seed 42)
#> ──────────────────────────────────────────────────────────────────
#> permutation ok p = 0.005 with 199 usable permutations;
#> placebo ok a permuted exposure gives 0.0702711, aga
#> random_common_cause ok adding a column of noise moved the stati
#> subset_stability ok the middle 95% of 199 subsets spans [0.5
#> ──────────────────────────────────────────────────────────────────Four controls, each for a different way of being wrong: permuting the exposure destroys the association, so the statistic should fall to its null; a column of noise cannot matter, so the statistic should not move; a placebo exposure should show nothing; and random subsets should agree.
They can fail, which is the point. A constant fails the permutation test and nothing else, because everything else about a constant is perfectly stable:
cpsfals(d, function(z) 0.5, trt = "x", n = 199,
seed = 42)$controls[, c("control", "passed")]
#> control passed
#> 1 permutation FALSE
#> 2 placebo TRUE
#> 3 random_common_cause TRUE
#> 4 subset_stability TRUENote what the permutation control reports alongside its p-value: the smallest p-value the design could have produced. With 199 permutations that floor is 0.005, and with 19 it is 0.05 – so a p of 0.05 from 19 permutations is not weak evidence, it is no evidence at all.
vrfcps() reads a manifest and confirms it is consistent
with itself. That catches an edited manifest and cannot catch one that
was wrong when it was written, because a manifest written from the wrong
data is perfectly consistent too.
d <- data.frame(x = 1:10)
man <- mkmnf(list(dataset = "demo"), env = FALSE)
man <- recrd(man, "mean_x", obs = mean(d$x), expd = 5.5)
#> mean_x observed = 5.5000 expected = 5.5000 [INFO]
man <- recrd(man, "n", obs = nrow(d), expd = 10)
#> n observed = 10.0000 expected = 10.0000 [INFO]
mnfrcmp(man, d, list(mean_x = function(z) mean(z$x),
n = function(z) nrow(z)))
#> ── Recomputation: everything checked and matched ─────────────────
#> mean_x MATCH |recorded - recomputed| = 0
#> n MATCH |recorded - recomputed| = 0
#> ──────────────────────────────────────────────────────────────────Recompute only some of them and the rest are reported as unchecked rather than passed over, which is the difference between a clean bill of health and a partial one:
mnfrcmp(man, d, list(n = function(z) nrow(z)))
#> ── Recomputation: see below ──────────────────────────────────────
#> n MATCH |recorded - recomputed| = 0
#> ──────────────────────────────────────────────────────────────────
#> 1 recorded result(s) NOT recomputed: mean_x
#> ──────────────────────────────────────────────────────────────────For anything stochastic, record the generator state rather than the
seed. set.seed(1) reproduces a run only if everything
before it does too – one extra draw anywhere upstream shifts every later
value:
The falsification controls above are worth much more when the statistics were named before the data was looked at. A declaration makes the two ways of departing from a plan visible, and they are different failures:
plan <- prrgdecl(c(
ate = "use of force is higher in the exposed division",
n_rows = "the extract has the row count the source publishes"))
# a declared outcome that was not reported
prrgchk(plan, "n_rows")
#> ── Against the declaration of 2026-10-22T12:23:02Z: departures below
#> declared but not reported (outcome switching): ate
#> ──────────────────────────────────────────────────────────────────
# statistics reported that were never declared
prrgchk(plan, c("ate", "n_rows", "byyear", "byprecinct"))
#> ── Against the declaration of 2026-10-22T12:23:02Z: departures below
#> reported but not declared (2 addition(s)): byyear, byprecinct
#> ──────────────────────────────────────────────────────────────────That second case is why a nominal p-value needs correcting. Run the permutation control over several statistics and the smallest is not the finding:
falsfam(c(ate = 0.02, byyear = 0.3, byprecinct = 0.4,
byshift = 0.6, bymonth = 0.7), meth = "holm")
#> ── Family of 5, corrected by holm at alpha 0.05 ──────────────────
#> ate p 0.02 adjusted 0.1
#> byyear p 0.3 adjusted 1
#> byprecinct p 0.4 adjusted 1
#> byshift p 0.6 adjusted 1
#> bymonth p 0.7 adjusted 1
#> ──────────────────────────────────────────────────────────────────And for the confounder nobody measured, the E-value says how strong it would have to be to explain the result away – with both the exposure and the outcome:
A capsule pins a source by its SHA-256, and fetchvrf()
fetches it from wherever a copy with that SHA-256 still exists: the
source, its Wayback snapshot, any mirror you name, archive.rmorie.com,
and folders of captures you made yourself. Some servers refuse archives
(Statistics Canada answers the Wayback Machine’s crawler with an error),
so the copy has to be made from a connection the server does answer:
yours.
arccap() downloads each address, writes it as a WARC/1.1
file with a CDXJ index (a collection pywb and warcio read), and with
upload = "rmorie" keeps it at archive.rmorie.com under your
data key, with the reason you give. upload = "wayback" asks
the Wayback Machine as well.
cap <- arccap("https://www150.statcan.gc.ca/n1/tbl/csv/35100177-eng.zip", "captures",
upload = c("rmorie", "wayback"), reason = "Custody table cited in chapter 3")
cap$archive # its page at archive.rmorie.com
arcfind("https://www150.statcan.gc.ca/n1/tbl/csv/35100177-eng.zip")
arcget(cap$capture_id, "table.zip", sha256 = cap$sha256)When the source is gone and the Wayback Machine cannot be reached,
fetchvrf() takes the copy from the archive, and failing
that from the WARC files in your own folder, still checked against the
pin:
fetchvrf("https://www150.statcan.gc.ca/n1/tbl/csv/35100177-eng.zip", "table.zip",
sha256 = cap$sha256, arcs = c("rmorie", "captures"))From a terminal the same steps are
rmbl archive save URL --reason "...",
rmbl archive find URL and
rmbl archive get ID -o FILE.
In continuous integration,
cpschk(path, "drift", archive = TRUE) (the rmbl GitHub
Action’s archive: true) takes the archived copy with the
pinned SHA-256 when a capsule’s source and its Wayback snapshot are both
down; reading a public copy needs no key. Saving needs one, except in a
GitHub Actions job allowed id-token: write: there the
package asks GitHub for the job’s short-lived OIDC token, the archive
checks GitHub’s signature, and the repository is the saver, so no secret
is stored.
A bundle carries a digest of every file, the manifest digest, and an attestation over both. The file digests are inside the signature, which is the point: a list of hashes that is not itself signed can be rewritten to match whatever the files now say.
dir <- tempfile()
dir.create(dir)
write.csv(data.frame(x = 1:3), file.path(dir, "data.csv"),
row.names = FALSE)
b <- cpsbndl(dir, man, key, note = "as published")
cpsbndlvrf(attr(b, "path"), dir, man = man)
#> ── Bundle check: OK ──────────────────────────────────────────────
#> file:data.csv ok 95aecaa7399a39092c9e716ce1e18bb1c606
#> no_unlisted_files ok
#> attestation:attestation_comple ok
#> attestation:manifest_digest ok 40bcd81749e165872ab5724f4e136f0066d8
#> attestation:signature ok
#> manifest_digest ok 8082eb02d48b11e6e07667e3c024d9596aa7
#> ──────────────────────────────────────────────────────────────────Files sitting in the directory that the bundle does not mention are reported as well: a signed list of what should be there says nothing about what else was put beside it.
The chain proves the order of a sequence of manifests. It does not
prove that any of them existed at a particular time – the whole chain
can be built in an afternoon and dated however one likes. An RFC 3161
timestamp token from a third party supplies the date, and
tsvrf() checks three separate things: that the token is
about these bytes, what time it asserts, and that the authority’s
signature holds.
What it does not check, and this is the difference between it and a browser’s padlock: whether the certificate should be trusted. There is no chain building, no validity dates, no revocation and no check of the timeStamping key usage. Pass the certificate you have independently decided to trust, and read a pass as “this key said so” rather than “this is true”.
A single digest over a file tells you it changed. A Merkle tree over its chunks tells you which chunk changed, and lets you prove one chunk belongs without re-reading the rest.
chunks <- c("id,value", "1,2", "3,4", "5,6")
root <- mrkroot(chunks)
root
#> [1] "80332968a476e13898f2d88c2632603b3fa6bcc746bf05be7ed178aced51ab16"# Editing one chunk moves exactly one leaf.
editedchunks <- chunks
editedchunks[3] <- "3,5"
which(mrklvs(chunks) != mrklvs(editedchunks))
#> [1] 3# Prove chunk 3 belongs, holding only it and log2(n) sibling digests.
proof <- mrkprf(chunks, 3)
proof
#> $sibling
#> [1] "2191ddd39af84a2334704f1bc99d7c53fc4735a5f1f44cdebdf0c3c52f5d5681"
#> [2] "8f71896ada80f49aab101ab39dff71d81b3470c5abc14cb58f1806b16c8fe6af"
#>
#> $side
#> [1] "right" "left"
#>
#> $scheme
#> [1] 2
mrkvrf(chunks[3], proof, root)
#> [1] TRUE
mrkvrf("3,5", proof, root)
#> [1] FALSEchnkfile() produces the chunks from a file.
An unpaired node at an odd level is promoted rather than hashed against a duplicate of itself. That is a correctness requirement, not a preference: duplicating the last leaf lets two different chunk lists produce the same root, which is the CVE-2012-2459 weakness.
Every manifest above verifies on its own. That says nothing about whether any were removed – delete an inconvenient run and the remaining manifests are all still perfectly valid.
A chain fixes that by linking each entry to the digest of the one before:
chain <- chnnew()
chain <- chnapp(chain, "manifest for run 1", label = "run-1")
chain <- chnapp(chain, "manifest for run 2", label = "run-2")
chain <- chnapp(chain, "manifest for run 3", label = "run-3")
chain
#> ── Manifest chain ────────────────────────────────────────────────
#> ✓ chain intact
#>
#> entries 3
#> head b9ccb96106a68f3d4d2a0cfb8e2ae160f7996393e28e93d7f4650c2bbd81ff49
#> seal 940721e3704268b570bcbf1c9ae841bde02927f584ec44dc3cf053ef7404d8a0
#>
#> 1 run-1 cb25fa776c693a106b2d08a1
#> 2 run-2 03a645a531c93fbb12bb2c07
#> 3 run-3 b9ccb96106a68f3d4d2a0cfb
#> ──────────────────────────────────────────────────────────────────# Deleting an entry from the middle breaks the links, and names where.
tampered <- chain
tampered$entries[[2]] <- NULL
chnvrf(tampered)
#> ✗ chain broken at entry 2 of 2Two failure modes, and neither single value catches both:
chnvrf() fails.chnvrf() accepts – but the head changes.truncated <- chain
truncated$entries[[3]] <- NULL
c(headuncbymiddel =
chnhead(tampered) == chnhead(chain),
linksacctru = chnvrf(truncated)$valid)
#> headuncbymiddel linksacctru
#> TRUE TRUEchnseal() folds the entry count and every link digest
into one value, which covers both. It is the thing to sign:
Every primitive with a published test vector is checked against it: SHA-512 against FIPS 180-4, HMAC-SHA-256 against RFC 4231, PBKDF2 against the published vectors, BLAKE2b against RFC 7693, CRC-32 against the ITU V.42 check value.
The XMSS signature scheme is the exception. No official RFC 8391
known-answer vectors ship with the RFC itself, which carries only XDR
formats. The check is instead made against the reference
implementation: the whole 2500-byte signature for
XMSS-SHA2_10_256 – index, randomiser, WOTS+ signature and authentication
path – is byte-identical to what
github.com/XMSS/xmss-reference produces from the same key
material, and those vectors are embedded in the test suite so the check
needs no network. A signature written here can therefore be handed to
another XMSS implementation as bytes.
It is verified against its security properties as well: a genuine signature verifies, and every tampering of the message, the signature, the authentication path, the leaf index or the key fails. The scheme is not certified, which is a statement about process rather than about the bytes.
The bundled Indigenous tables name their sources (Statistics Canada table numbers, retrieval dates and licence) in the package’s COPYRIGHTS file:
cr <- readLines(system.file("COPYRIGHTS", package = "rmoriebricklayer"))
cat(cr[grep("indigenouslifeexpectancy", cr) + 0:4], sep = "\n")With a data key (dlogin()), indtabs() lists
the mirrored tables themselves:
indtabs() lists the Statistics Canada tables on
data.rmorie.com with their source, licence and table number, so a result
can name the exact table it used.