
Attestations, signed bundles and tamper-evident chains
Source:vignettes/attestations-bundles-chains.Rmd
attestations-bundles-chains.RmdThe Building reproducible data capsules vignette shows how a capsule pins data and describes it in a manifest. This one covers the three ways of putting a signature on that work so a reader can check it: an attestation of a manifest, a bundle of a whole capsule directory, and a chain that makes a sequence of manifests tamper-evident.
Attesting a manifest
An attestation binds a signature to a manifest and to what the manifest covers, with an optional note from the person signing:
m <- make_manifest(list(dataset = "otis", rows = 1200L), environment = FALSE)
key <- fips_keygen("ML-DSA-65")
att <- capsule_attest(m, key, note = "counts as published")
res <- capsule_check_attestation(att, m)
res$ok
#> [1] TRUE
res$checks
#> check ok
#> 1 attestation_complete TRUE
#> 2 manifest_digest TRUE
#> 3 signature TRUE
#> detail
#> 1
#> 2 efa1b7387f225380245de0b5170999e87ad4044f3e8fd2ef6a6bfbc2bd0a9c90
#> 3A verifier needs the attestation and the manifest, nothing else. Any change to the manifest breaks it, and so does presenting a different key:
m2 <- m
m2$meta$rows <- 1201L
capsule_check_attestation(att, m2)$ok
#> [1] FALSE
capsule_check_attestation(att, m, key_expected = fips_keygen("ML-DSA-65")$public)$ok
#> [1] FALSEkey_expected is how a reader pins the signer: the
attestation carries the public key that made it, but a reader who knows
which key to expect should say so, or any key would do.
Bundling a capsule
A bundle is one signed artifact covering a directory: every file is hashed (hidden files included), the hashes and the manifest are signed, and the bundle verifies against the directory later:
dir <- tempfile()
dir.create(dir)
write.csv(data.frame(x = 1:3), file.path(dir, "data.csv"), row.names = FALSE)
m <- make_manifest(list(dataset = "demo"), environment = FALSE)
key <- fips_keygen("ML-DSA-44")
b <- capsule_bundle(dir, m, key, note = "as published")
capsule_bundle_verify(attr(b, "path"), dir, manifest = m)$ok
#> [1] TRUETouch a byte of the data and the bundle no longer holds; the result names the file:
write.csv(data.frame(x = 1:4), file.path(dir, "data.csv"), row.names = FALSE)
v <- capsule_bundle_verify(attr(b, "path"), dir, manifest = m)
v$ok
#> [1] FALSE
capsule_bundle_read(attr(b, "path"))$attestation$note
#> [1] "as published"
unlink(dir, recursive = TRUE)A chain of manifests
A per-manifest signature proves each manifest is intact but says nothing about the sequence: a run could be deleted from the middle of a series, or two swapped, and every remaining signature would still verify. A hash chain links each entry to the one before it, so deletion, insertion and reordering all break it:
ch <- chain_new()
ch <- chain_append(ch, "manifest for run 1", label = "run-1")
ch <- chain_append(ch, "manifest for run 2", label = "run-2")
ch <- chain_append(ch, "manifest for run 3", label = "run-3")
chain_verify(ch)$valid
#> [1] TRUEEditing an entry breaks the chain and names where:
edited <- ch
edited$entries[[2]]$digest <- core_sha256("something else")
chain_verify(edited)$valid
#> [1] FALSE
chain_verify(edited)$broken_at
#> [1] 2Deleting one breaks it too, which a per-manifest digest would never catch:
dropped <- ch
dropped$entries[[2]] <- NULL
chain_verify(dropped)$valid
#> [1] FALSESealing the chain
The seal covers the links and the length together, so one signature over the seal fixes the whole history. Truncating the chain still seals, but to a different value:
key <- fips_keygen("ML-DSA-44")
sig <- capsule_sign(chain_seal(ch), key)
capsule_verify(chain_seal(ch), sig, fips_public_key(key))
#> [1] TRUE
truncated <- ch
truncated$entries[[3]] <- NULL
capsule_verify(chain_seal(truncated), sig, fips_public_key(key))
#> [1] FALSEA chain whose links disagree has no seal to present at all:
chain_seal(dropped)
#> [1] NAWhich to use
- One manifest, one signer:
capsule_attest(). - A directory of files that must travel together:
capsule_bundle(). - A series of runs, releases or data refreshes where order and
completeness matter:
chain_append()after each, signchain_seal()at each release.
All three accept any signing key the package knows: ML-DSA and
SLH-DSA (fips_keygen()) or XMSS (pqc_keygen(),
with the key state carried forward). A shared secret from
derive_key() is for keyed digests
(core_hmac_sha256()), not for attestations: an attestation
names a public key, and a shared secret has none.