
Hashing, keyed digests, key derivation and Merkle trees
Source:vignettes/hashing-and-integrity.Rmd
hashing-and-integrity.RmdEverything a capsule pins is pinned by a digest. This vignette covers the hashing primitives, how to compare digests safely, how to derive a key from a passphrase, how to get randomness, and how a Merkle tree lets a large file be pinned and partially verified.
Digests of text and files
core_sha256("hello capsule")
#> [1] "97cdf3978fc8755453e42fb3d0384a6612f1dde79c46cf422abc0563cf8426e2"
core_sha512("hello capsule")
#> [1] "aa92b1aa2bf977ec1c4b3a47795d24e98ab595464236b9c59696c1999794714639df0fa5e66fb7be86bf2d05e4a45b6207a40fc4ae1f3458ca40ceed87a9d76e"
core_blake2b("hello capsule")
#> [1] "061431a711a23d1745664ba6d36ad517ab6f48db421d492f13cd110f2067b9ce"
core_crc32("hello capsule")
#> [1] 1607001718Files are hashed in blocks, so a multi-gigabyte file does not need to fit in memory:
f <- tempfile()
writeLines("hello capsule", f)
sha256_file(f)
#> [1] "55d6110230c260319d580bb6274db530b7e751c4f687fc00d0736ec531260a02"
sha512_file(f)
#> [1] "358665f1505b7dced4ab0e1d7cf5b634bc60f8cbdf7c59f8ec6957f8e1a0df990fbaf92e6dc662662cca542e475b0862453a932d9fa58c2bc7a4b3de8cecdfc4"
crc32_file(f)
#> [1] 2347637372
before <- sha256_file(f)
writeLines("hello capsule (edited)", f)
sha256_file(f) == before
#> [1] FALSECRC-32 is a checksum, not a cryptographic hash: it detects accidental
corruption and nothing more. SHA-256 and SHA-512 (FIPS 180-4) and
BLAKE2b are collision-resistant; verify_sha256() checks a
file against a pinned digest.
Digests of R objects
digest_object() serialises an object canonically and
hashes the bytes, so attributes, names and types are part of the
identity:
digest_object(list(a = 1L, b = "x"))
#> [1] "bf5d21a8b26f6cec8503a46f69e854bf98fea03b62dfaf90cb780feac7165b27"
identical(digest_object(1:10), digest_object(1:10))
#> [1] TRUE
digest_object(1:10) == digest_object(1:11)
#> [1] FALSE
digest_object(matrix(1:6, nrow = 2)) == digest_object(1:6) # dimensions count
#> [1] FALSE
digest_object(data.frame(x = 1:3))
#> [1] "24b0995a368ea8d1178b7f6e44c6d8956db4aadfc91d3e6f6911868b38c06d21"Keyed digests and constant-time comparison
An HMAC (RFC 2104) is a digest that only the holder of the key can produce, so a manifest cannot be re-signed without the key:
core_hmac_sha256("Jefe", "what do ya want for nothing?") # RFC 4231 test case 2
#> [1] "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
core_hmac_sha256("key-a", "manifest")
#> [1] "463b8b2c47caadd6334c53e4d9492fdac6fcaca185f56229043e1a025138ceae"
core_hmac_sha256("key-b", "manifest")
#> [1] "f3ebaf1b8fad504c57fd4cf82b163e30eac6eabd31b7c41a2a6e268ec445b134"Never compare a tag with ==. A string
comparison stops at the first differing character, so its running time
tells an attacker how many leading characters were right.
core_digest_equal() compares every byte regardless and
returns FALSE for a length mismatch without erroring:
tag <- core_hmac_sha256("k", "m")
core_digest_equal(tag, core_hmac_sha256("k", "m"))
#> [1] TRUE
core_digest_equal(tag, core_hmac_sha256("k", "tampered"))
#> [1] FALSE
core_digest_equal(tag, "too-short")
#> [1] FALSEDeriving a key from a passphrase
A passphrase is not a key: it is short, low-entropy and chosen by a person. PBKDF2 (RFC 8018) stretches it with a salt and an iteration count that makes each guess expensive:
derive_key("password", "salt", iterations = 1) # the published test vector
#> [1] "120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b"
identical(derive_key("pw", "s", 1000), derive_key("pw", "s", 1000))
#> [1] TRUE
derive_key("pw", "salt-a", 1000) == derive_key("pw", "salt-b", 1000)
#> [1] FALSEThe salt must be random and stored beside the ciphertext or signature; the default of 100,000 iterations is the floor for interactive use. A derived key signs a manifest symmetrically:
salt <- paste(format(random_bytes(16)), collapse = "")
key <- derive_key("correct horse battery staple", salt)
sig <- capsule_sign("manifest-digest", key, scheme = "hmac")
capsule_verify("manifest-digest", sig, key)
#> [1] TRUERandomness
random_bytes() draws from the operating system
(getrandom or /dev/urandom on Unix,
RtlGenRandom on Windows) and does not
consume R’s seeded stream, so a reproducible analysis is unaffected by
key generation happening beside it:
set.seed(1)
a <- stats::runif(1)
set.seed(1)
invisible(random_bytes(32))
identical(stats::runif(1), a)
#> [1] TRUEFor tests that need reproducible “random” bytes, the package’s AES-256 CTR_DRBG (SP 800-90A) gives the same bytes from the same seed, exactly as NIST’s known-answer files use it; a personalization string separates streams:
d <- drbg_new(as.raw(0:47))
drbg_generate(d, 16)
#> [1] 06 15 50 23 4d 15 8c 5e c9 55 95 fe 04 ef 7a 25
a <- drbg_new(as.raw(1:48), personalization = charToRaw("session 1"))
b <- drbg_new(as.raw(1:48), personalization = charToRaw("session 2"))
identical(drbg_generate(a, 16), drbg_generate(b, 16))
#> [1] FALSEMerkle trees: pin a large file, verify a piece
A Merkle tree hashes a file in chunks, then hashes pairs of digests
up to one root. The root pins the whole file; a proof of
log2(n) digests shows that one chunk belongs without
holding the others, and a diff of the leaves names exactly which chunk
changed.
chunks <- c("row1,row2", "row3,row4", "row5,row6", "row7,row8")
root <- merkle_root(chunks)
root
#> [1] "3b1537c90e21c2e66f341b946388abbb78bf249fb433f961fba223b67141be31"
before <- merkle_leaves(chunks)
after <- merkle_leaves(c(chunks[1:2], "row5,row6-EDITED", chunks[4]))
which(before != after)
#> [1] 3
pr <- merkle_proof(chunks, 3)
merkle_verify(chunks[3], pr, root)
#> [1] TRUE
merkle_verify("row5,row6-EDITED", pr, root)
#> [1] FALSEchunk_file() splits a real file into fixed-size chunks
that concatenate back to its bytes:
p <- tempfile()
writeLines(rep("some capsule content", 50), p)
ch <- chunk_file(p, chunk_bytes = 128)
length(ch)
#> [1] 9
merkle_root(ch)
#> [1] "3f565b39becbebbc336618294525d7a1ff45394d68ecaec899d292bb0fd357c1"
identical(unlist(ch), readBin(p, "raw", file.size(p)))
#> [1] TRUEcapsule_bundle() pins each data file by one whole-file
digest (sha256_file()); the Merkle tree is for pinning the
parts of a large file, so a mismatch identifies which part of it changed
rather than only that something did.
How these implementations are checked
The digests are compared with OpenSSL at every block boundary, run against the NIST ACVP sets for SHA-3, SHAKE, HMAC and PBKDF2, and fuzzed differentially against OpenSSL 3.5. The keyed-digest comparison and PBKDF2 run under valgrind with the key marked undefined to prove their timing does not depend on it, and dudect measures the comparison and PBKDF2 on real CPUs.