zucrypt has seven functions:
| Function | What it is for |
|---|---|
crypt_hash() |
A digest of some bytes: SHA-1, SHA-256, SHA-384 or SHA-512 |
crypt_hmac() |
A keyed digest (HMAC) of some bytes |
crypt_hex() |
Lower-case hex of some bytes, for printing or storing a digest |
crypt_equal() |
Comparing a secret value in constant time |
crypt_info() |
What the installed build contains |
crypt_aes_cbc_encrypt_nopad(),
crypt_aes_cbc_decrypt_nopad()
|
Unauthenticated AES-CBC, for formats that specify it |
This article walks through each of them. The short version is that there is not much to learn, on purpose: every function takes raw vectors and returns raw vectors, algorithm names are matched exactly, and anything wrong is an error with a class you can catch.
Bytes in, bytes out
Every argument that holds data or a key is a raw vector. zucrypt never guesses how to turn something else into bytes, so a character string is an error rather than a silent conversion:
e <- tryCatch(crypt_hash("the quick brown fox"), error = function(e) e)
class(e)
#> [1] "zucrypt_invalid_argument" "zucrypt_error"
#> [3] "error" "condition"
conditionMessage(e)
#> [1] "`data` must be a raw vector or a connection, not character. Use charToRaw() to convert a string deliberately; this package never guesses an encoding, and never treats a string as a file name (pass file(path) for a file)."The conversion is always yours to make, and it is always one line:
crypt_hash(charToRaw("the quick brown fox"))
#> [1] 9e cb 36 56 13 41 d1 8e b6 54 84 e8 33 ef ea 61 ed c7 4b 84 cf 5e 6a e1 b8
#> [26] 1c 63 53 3e 25 fc 8fThe same goes for anything else you want to hash. Each of these is a deliberate choice about which bytes, which is exactly the choice a package should not make for you:
# An R object: serialize() decides the bytes, and its `version` and `ascii`
# arguments change them, so pin what you rely on.
digest_of_object <- crypt_hash(serialize(mtcars, NULL, version = 3))
# A file's contents, read as bytes, never as text.
path <- tempfile()
writeLines("some file contents", path)
digest_of_file <- crypt_hash(readBin(path, "raw", n = file.size(path)))A string is text in some encoding. charToRaw() gives you
the bytes R holds for it; if the string might not be UTF-8, convert it
first with enc2utf8() so that the same text always hashes
the same way.
crypt_hash(): digests
The default is SHA-256, and unless an external format requires something else, that is the right choice:
msg <- charToRaw("abc")
crypt_hash(msg)
#> [1] ba 78 16 bf 8f 01 cf ea 41 41 40 de 5d ae 22 23 b0 03 61 a3 96 17 7a 9c b4
#> [26] 10 ff 61 f2 00 15 adHex is an explicit step at the call site, not a default.
crypt_hex() does it in C, so it costs little beside the
hash:
crypt_hex(crypt_hash(msg))
#> [1] "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"That is the FIPS 180-2 test vector for SHA-256(“abc”). The other
algorithms are named exactly as crypt_info()$algorithms
lists them:
crypt_info()$algorithms
#> [1] "sha1" "sha256" "sha384" "sha512"
for (a in crypt_info()$algorithms) {
cat(sprintf("%-6s %s\n", a, crypt_hex(crypt_hash(msg, a))))
}
#> sha1 a9993e364706816aba3e25717850c26c9cd0d89d
#> sha256 ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
#> sha384 cb00753f45a35e8bb5a03d699ac65007272c32ab0eded1631a8b605a43ff5bed8086072ba1e7cc2358baeca134c825a7
#> sha512 ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49fExact means exact: there is no partial matching and no fallback, so a typo cannot quietly select a different digest.
e <- tryCatch(crypt_hash(msg, "sha"), error = function(e) e)
class(e)
#> [1] "zucrypt_unsupported_algorithm" "zucrypt_error"
#> [3] "error" "condition"SHA-1 is there because some existing formats specify it. Do not choose it for anything new.
The digest of empty input is well defined, and you get it:
crypt_hex(crypt_hash(raw(0)))
#> [1] "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"A file is hashed by passing a connection, never its path as a string. It is read in 1 MiB chunks, so its size does not matter:
path <- tempfile()
writeBin(msg, path)
crypt_hex(crypt_hash(file(path)))
#> [1] "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
unlink(path)An unopened connection is opened in binary mode and closed again; one
you have already opened must be in binary mode ("rb"),
because a text-mode connection may change the bytes before they are
hashed. crypt_hmac() takes connections in the same way.
crypt_hmac(): keyed digests
An HMAC is a digest that only someone holding the key can compute, which makes it the standard way to check that data has not been changed by someone who does not hold the key. The key is raw too, of any length:
key <- charToRaw("Jefe")
tag <- crypt_hmac(charToRaw("what do ya want for nothing?"), key)
crypt_hex(tag)
#> [1] "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"That is test case 2 of RFC 4231. The algorithm argument works as it
does for crypt_hash():
crypt_hex(crypt_hmac(charToRaw("what do ya want for nothing?"), key, "sha512"))
#> [1] "164b7a7bfcf819e2e395fbe73b56e0a387bd64222e831fd610270cd7ea2505549758bf75c05a994a6d034f65f8f0e6fdcaeab1a34d4a6b4b636e070a38bce737"In real use the key should be random bytes from a cryptographic
source, at least as long as the digest, and kept secret. zucrypt does
not generate randomness; openssl::rand_bytes() or
sodium::random() do. R’s own sample() and
runif() do not, whatever the seed.
crypt_equal(): checking a secret
When you check a tag you received against the one you expect, compare
with crypt_equal(), never identical() or
==:
received <- tag
crypt_equal(received, crypt_hmac(charToRaw("what do ya want for nothing?"), key))
#> [1] TRUE
tampered <- crypt_hmac(charToRaw("what do ya want for nothing!"), key)
crypt_equal(tampered, tag)
#> [1] FALSEAn ordinary comparison stops at the first byte that differs, so how
long it takes tells an attacker how many leading bytes of their guess
were right — and a tag can then be guessed one byte at a time.
crypt_equal() takes the same time whatever the
contents.
The length is not treated as secret: inputs of different lengths are simply not equal.
crypt_equal(tag, tag[1:16])
#> [1] FALSE
crypt_info(): what is installed
crypt_info() reports what this build actually contains.
Everything in it is read from the compiled library, so it describes the
code you are running, not a file that says what was meant to be
compiled:
str(crypt_info())
#> List of 5
#> $ version :Classes 'package_version', 'numeric_version' hidden list of 1
#> ..$ : int [1:3] 0 1 0
#> $ abi_version: int 1
#> $ algorithms : chr [1:4] "sha1" "sha256" "sha384" "sha512"
#> $ vendored :'data.frame': 1 obs. of 2 variables:
#> ..$ source : chr "TF-PSA-Crypto"
#> ..$ version: chr "1.1.1"
#> $ build_flags:List of 3
#> ..$ random_backend : chr "getrandom"
#> ..$ aes_implementation : chr "aesni"
#> ..$ hardware_acceleration: logi TRUEvendored names the bundled cryptography library and its
version. That matters because a vendored library is updated by updating
the package, not by updating your operating system.
abi_version is the version of the C interface that other
packages link against (see ?zucrypt_c_api).
AES-CBC: for formats that specify it
crypt_aes_cbc_encrypt_nopad() and
crypt_aes_cbc_decrypt_nopad() are interoperability
tools, for reading and writing file formats that are defined in
terms of AES-CBC. They are deliberately low-level:
- No padding is added or removed. The data must already be a multiple of 16 bytes.
- Nothing is authenticated. Anyone who can reach a ciphertext can change it, and decryption will return changed plaintext without complaint.
- The key must be 16, 24 or 32 bytes (AES-128, -192 or -256), and the IV exactly 16.
If you want to encrypt something of your own, use a construction that
authenticates — sodium::data_encrypt(), for example —
rather than assembling one from these. The rest of this section shows
what these functions do and what “doing it correctly” requires, which is
also the argument for not doing it yourself.
Reproducing a published vector
This is the first two blocks of the NIST SP 800-38A F.2.1 example, which is the kind of thing these functions exist for: matching bytes that another implementation specifies.
unhex <- function(x) {
as.raw(strtoi(substring(x, seq(1, nchar(x), 2), seq(2, nchar(x), 2)), 16L))
}
key <- unhex("2b7e151628aed2a6abf7158809cf4f3c")
iv <- unhex("000102030405060708090a0b0c0d0e0f")
pt <- unhex(paste0("6bc1bee22e409f96e93d7e117393172a",
"ae2d8a571e03ac9c9eb76fac45af8e51"))
ct <- crypt_aes_cbc_encrypt_nopad(pt, key, iv)
crypt_hex(ct)
#> [1] "7649abac8119b246cee98e9b12e9197d5086cb9b507219ee95db113a917678b2"
identical(crypt_aes_cbc_decrypt_nopad(ct, key, iv), pt)
#> [1] TRUEThe IV is fixed here only so that the output can be compared with the standard. An IV used to encrypt real data must be unpredictable and never reused with the same key.
Padding is yours
Data that is not a multiple of 16 bytes is refused, not padded:
e <- tryCatch(crypt_aes_cbc_encrypt_nopad(charToRaw("hello"), key, iv),
error = function(e) e)
class(e)
#> [1] "zucrypt_bad_length" "zucrypt_error" "error"
#> [4] "condition"If your format uses PKCS#7 padding, apply and check it yourself:
pad_pkcs7 <- function(x) {
n <- 16L - length(x) %% 16L
c(x, as.raw(rep(n, n)))
}
unpad_pkcs7 <- function(x) {
n <- as.integer(x[length(x)])
stopifnot(n >= 1L, n <= 16L, all(x[length(x) - seq_len(n) + 1L] == as.raw(n)))
x[seq_len(length(x) - n)]
}
padded <- pad_pkcs7(charToRaw("hello"))
length(padded)
#> [1] 16
rawToChar(unpad_pkcs7(crypt_aes_cbc_decrypt_nopad(
crypt_aes_cbc_encrypt_nopad(padded, key, iv), key, iv)))
#> [1] "hello"Formats that restart the chain
Some formats split data into segments and restart CBC at each boundary with its own IV — Office’s agile encryption does this in 4096-byte segments. Each call starts from the IV it is given, so a segmented format is one call per segment:
# The NIST plaintext above as two 16-byte "segments", each from the same IV.
segments <- split(pt, rep(1:2, each = 16))
segmented <- unlist(lapply(segments, crypt_aes_cbc_encrypt_nopad, key = key, iv = iv),
use.names = FALSE)
# The first segment is the first block of the stream, so it matches:
identical(segmented[1:16], ct[1:16])
#> [1] TRUE
# The second does not continue the chain; it restarted from the IV:
identical(segmented[17:32], ct[17:32])
#> [1] FALSE
identical(segmented[17:32], crypt_aes_cbc_encrypt_nopad(pt[17:32], key, iv))
#> [1] TRUEIn a real format each segment gets its own IV, derived as the format specifies; here both use one IV only to keep the example short.
Authenticating what you encrypt
If you must build on CBC, the minimum is
encrypt-then-MAC: compute an HMAC over the IV and the
ciphertext with a second, independent key, send the tag
alongside, and check it with crypt_equal() before
decrypting anything.
enc_key <- unhex("603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4")
mac_key <- as.raw(1:32) # in real use: 32 random bytes, kept secret
seal <- function(plaintext, iv) {
ct <- crypt_aes_cbc_encrypt_nopad(pad_pkcs7(plaintext), enc_key, iv)
list(iv = iv, ct = ct, tag = crypt_hmac(c(iv, ct), mac_key))
}
open_sealed <- function(box) {
expected <- crypt_hmac(c(box$iv, box$ct), mac_key)
if (!crypt_equal(box$tag, expected)) stop("authentication failed")
unpad_pkcs7(crypt_aes_cbc_decrypt_nopad(box$ct, enc_key, box$iv))
}
box <- seal(charToRaw("meet at noon"), iv)
rawToChar(open_sealed(box))
#> [1] "meet at noon"
box$ct[1] <- xor(box$ct[1], as.raw(1)) # one bit flipped in transit
tryCatch(open_sealed(box), error = function(e) conditionMessage(e))
#> [1] "authentication failed"Without the tag, that flipped bit would have decrypted into altered plaintext and nothing would have noticed. Getting every detail of this right — separate keys, the IV inside the MAC, checking before decrypting, a fresh random IV each time — is exactly why a ready-made authenticated construction is the better tool for your own data.
When something goes wrong
Every error is a condition with a specific class, then
zucrypt_error, then error. Branch on the
class, never on the message, which may be reworded:
e <- tryCatch(crypt_aes_cbc_encrypt_nopad(raw(32), raw(15), raw(16)),
error = function(e) e)
class(e)
#> [1] "zucrypt_bad_length" "zucrypt_error" "error"
#> [4] "condition"
e$algorithm
#> [1] "aes-cbc"| Class | Raised when |
|---|---|
zucrypt_invalid_argument |
An argument has the wrong type, for example a string where raw bytes are needed |
zucrypt_unsupported_algorithm |
An algorithm name is not one of
crypt_info()$algorithms
|
zucrypt_bad_length |
A key, IV or data length is not allowed |
zucrypt_connection_error |
A connection passed as data could not be opened, or ran
dry before its end |
zucrypt_memory_error |
An allocation failed |
zucrypt_backend_error,
zucrypt_internal_error
|
The cryptographic library refused, or an invariant broke; please report these |
Every condition also carries algorithm and
native_status fields. None of them ever contains a key, an
IV or any of your data, so an error message is safe to paste into a bug
report.
handled <- tryCatch(
crypt_hash(msg, "md5"),
zucrypt_unsupported_algorithm = function(e) "md5 is not provided; use sha256"
)
handled
#> [1] "md5 is not provided; use sha256"From C
The same primitives are available to other packages’ compiled code,
either through a registered function table or by linking a static
library, with no R involved at run time. See
?zucrypt_c_api.
Is this the right package?
zucrypt is small today. For most R work another package is the better choice:
| You want | Reach for |
|---|---|
| Keys, certificates, signatures, broad algorithm coverage | openssl |
| Modern authenticated encryption that is hard to misuse | sodium, or rmonocypher with no system library |
| A cache key for an R object, or a fast non-cryptographic hash | digest |
| SHA-2 and HMAC with no system library, a constant-time compare, or cryptography callable from your package’s C code | zucrypt |