beck_core/
digest.rs

1//! Digests and the two encodings a digest is written in.
2//!
3//! The half of Wave 2's crypto item that belongs to the *host* under `lib/README.md`'s division: a
4//! hash function is somebody else's table and base64 is somebody else's grammar, so both are
5//! primitives rather than Beck. What is composition — a signed token, a fingerprint, a check that
6//! reads the two halves apart — is [`lib/crypto.beck`](../../../../../compiler/lib/crypto.beck).
7//!
8//! Three constraints shaped what is here.
9//!
10//! * **A digest is a pure function.** It performs no effect and it is the same on every replay, so
11//!   nothing about it has to be recorded on an envelope. That is the difference between hashing and
12//!   the other two things a crypto library usually offers: random bytes and a clock are
13//!   nondeterministic, and Beck already has `uuid()` and `now()` for those, both charged `nondet`.
14//! * **Only one function turns a `secret[Str]` into a `Str`**, and it is [`keyed`]. §3.5's property
15//!   is that a secret cannot reach a browser; a message authentication code exists precisely to be
16//!   handed to one, so the declassification is the point rather than a hole in it — but it is
17//!   charged `cap.sign` so that a view cannot mint one, and
18//!   [`docs/adr/0014`](../../../../../docs/adr/0014-a-keyed-digest-is-the-one-declassifier.md) is the
19//!   record of that decision.
20//! * **Comparing a digest is not `==`.** [`same`] is constant-time, because a verifier that returns
21//!   early tells the caller where the first wrong byte was.
22//!
23//! BLAKE3 rather than SHA-2 because it is already in this tree — `beck-rt`'s `SignedIdentity`
24//! (`docs/48`) and the signal graph's stable ids both use it — and adding a second hash function to
25//! avoid reusing one is a dependency taken for symmetry.
26//! [`docs/adr/0015`](../../../../../docs/adr/0015-blake3-for-the-standard-librarys-digests.md) records
27//! why `ring` was not taken instead, and what that leaves unbuilt.
28
29/// The domain the standard library's keyed digest is derived into.
30///
31/// A key is derived rather than used raw so that the same secret used for two purposes gives two
32/// unrelated keys. It is a different string from `beck-rt`'s identity credential on purpose: a
33/// token minted by a program must not verify as one minted by the runtime.
34const KEY_CONTEXT: &str = "beck stdlib keyed digest v1";
35
36/// BLAKE3 of `text`, as 64 lowercase hex digits.
37pub fn of(text: &str) -> String {
38    blake3::hash(text.as_bytes()).to_hex().to_string()
39}
40
41/// A message authentication code over `message` under `key`, as 64 lowercase hex digits.
42///
43/// The one function in the language whose input is a `secret[Str]` and whose output is not secret.
44pub fn keyed(key: &str, message: &str) -> String {
45    let derived = blake3::derive_key(KEY_CONTEXT, key.as_bytes());
46    blake3::keyed_hash(&derived, message.as_bytes())
47        .to_hex()
48        .to_string()
49}
50
51/// Equality that does not stop at the first difference.
52///
53/// Length is compared first and in the clear, because the length of a digest is not a secret and
54/// padding two strings to a common length to hide it would be answering a question nobody asked.
55pub fn same(a: &str, b: &str) -> bool {
56    let (a, b) = (a.as_bytes(), b.as_bytes());
57    if a.len() != b.len() {
58        return false;
59    }
60    let mut diff = 0u8;
61    for (x, y) in a.iter().zip(b) {
62        diff |= x ^ y;
63    }
64    diff == 0
65}
66
67/// Lowercase hex, two digits per byte of UTF-8.
68pub fn hex_encode(text: &str) -> String {
69    let mut out = String::with_capacity(text.len() * 2);
70    for b in text.as_bytes() {
71        out.push(char::from(HEX[usize::from(b >> 4)]));
72        out.push(char::from(HEX[usize::from(b & 0x0f)]));
73    }
74    out
75}
76
77const HEX: &[u8; 16] = b"0123456789abcdef";
78
79/// The inverse of [`hex_encode`], or why it is not one.
80///
81/// Both cases a caller can hit are named rather than collapsed into "bad input": an odd length is a
82/// truncated string and a stray character is a different encoding, and those are different mistakes.
83/// The bytes must also be UTF-8, because a Beck `Str` is.
84pub fn hex_decode(text: &str) -> Result<String, String> {
85    if !text.len().is_multiple_of(2) {
86        return Err(format!(
87            "hex has two digits per byte, and `{}` has an odd number of them",
88            text.len()
89        ));
90    }
91    let digits = text.as_bytes();
92    let mut bytes = Vec::with_capacity(digits.len() / 2);
93    for pair in digits.chunks(2) {
94        let hi = digit(pair[0])?;
95        let lo = digit(pair[1])?;
96        bytes.push(hi << 4 | lo);
97    }
98    utf8(bytes)
99}
100
101fn digit(c: u8) -> Result<u8, String> {
102    match c {
103        b'0'..=b'9' => Ok(c - b'0'),
104        b'a'..=b'f' => Ok(c - b'a' + 10),
105        b'A'..=b'F' => Ok(c - b'A' + 10),
106        _ => Err(format!(
107            "`{}` is not a hex digit",
108            char::from(c).escape_default()
109        )),
110    }
111}
112
113/// RFC 4648 §5 — the URL-and-filename-safe alphabet, without padding.
114///
115/// §5 rather than §4 because every place a Beck program will put one of these is a place `+` and
116/// `/` have to be escaped: a URL, a filename, a JOSE segment. Padding is omitted for the same
117/// reason — `=` is `%3D` in a query string — and [`base64_decode`] accepts it anyway, because a
118/// decoder that refuses what other encoders emit is a decoder that fails in production.
119pub fn base64_encode(text: &str) -> String {
120    base64_encode_bytes(text.as_bytes())
121}
122
123/// The same encoder, starting before the text.
124///
125/// A PKCE code challenge is the base64url of a SHA-256 digest, which is 32 bytes and not a string;
126/// [`base64_encode`] is the primitive a Beck program calls and takes the `Str` it has. One encoder,
127/// two entry points.
128pub fn base64_encode_bytes(bytes: &[u8]) -> String {
129    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
130    for chunk in bytes.chunks(3) {
131        let b = [
132            chunk[0],
133            chunk.get(1).copied().unwrap_or(0),
134            chunk.get(2).copied().unwrap_or(0),
135        ];
136        let n = u32::from(b[0]) << 16 | u32::from(b[1]) << 8 | u32::from(b[2]);
137        let digits = [n >> 18, n >> 12 & 63, n >> 6 & 63, n & 63];
138        for d in digits.iter().take(chunk.len() + 1) {
139            out.push(char::from(B64[*d as usize]));
140        }
141    }
142    out
143}
144
145const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
146
147/// The inverse of [`base64_encode`], tolerant of padding and of the standard alphabet.
148///
149/// `+` and `/` decode as `-` and `_` do: a program reading a value somebody else encoded should not
150/// have to know which of the two alphabets they chose, and the two do not overlap, so accepting
151/// both is unambiguous rather than lenient.
152pub fn base64_decode(text: &str) -> Result<String, String> {
153    utf8(base64_decode_bytes(text)?)
154}
155
156/// The same decoder, stopping before the text.
157///
158/// [`base64_decode`] is the primitive a Beck program calls and a `Str` is UTF-8, so it ends in a
159/// validation. A JOSE signature segment is not text and never will be, so the runtime's OIDC
160/// verifier reads the bytes: this is the shared half rather than a second decoder beside it.
161pub fn base64_decode_bytes(text: &str) -> Result<Vec<u8>, String> {
162    let text = text.trim_end_matches('=');
163    let mut acc: u32 = 0;
164    let mut bits = 0u32;
165    let mut bytes = Vec::with_capacity(text.len() / 4 * 3);
166    for c in text.bytes() {
167        let six = sextet(c)?;
168        acc = acc << 6 | u32::from(six);
169        bits += 6;
170        if bits >= 8 {
171            bits -= 8;
172            bytes.push((acc >> bits) as u8);
173        }
174    }
175    // A trailing group of one character carries six bits and no byte, which no encoder emits.
176    if bits >= 6 {
177        return Err(format!(
178            "`{}` ends in a group of one character, which encodes no byte",
179            text.escape_default()
180        ));
181    }
182    Ok(bytes)
183}
184
185fn sextet(c: u8) -> Result<u8, String> {
186    Ok(match c {
187        b'A'..=b'Z' => c - b'A',
188        b'a'..=b'z' => c - b'a' + 26,
189        b'0'..=b'9' => c - b'0' + 52,
190        b'-' | b'+' => 62,
191        b'_' | b'/' => 63,
192        _ => {
193            return Err(format!(
194                "`{}` is not a base64 character",
195                char::from(c).escape_default()
196            ));
197        }
198    })
199}
200
201fn utf8(bytes: Vec<u8>) -> Result<String, String> {
202    String::from_utf8(bytes).map_err(|_| "the decoded bytes are not UTF-8".to_string())
203}
204
205/// A UUID in the canonical 8-4-4-4-12 form, lowercased, or why it is not one.
206///
207/// Normalising rather than only validating is the whole reason this is a function and not a
208/// `str_len` check in Beck: two spellings of the same UUID must not be two map keys. The braced and
209/// unhyphenated forms other systems emit are read and written back canonically, so a program
210/// comparing identifiers is comparing identity rather than punctuation.
211pub fn uuid_normalise(text: &str) -> Result<String, String> {
212    let trimmed = text.trim();
213    let inner = trimmed
214        .strip_prefix('{')
215        .and_then(|s| s.strip_suffix('}'))
216        .unwrap_or(trimmed);
217    let inner = inner.strip_prefix("urn:uuid:").unwrap_or(inner);
218    let digits: String = inner.chars().filter(|c| *c != '-').collect();
219    if digits.chars().count() != 32 || !digits.chars().all(|c| c.is_ascii_hexdigit()) {
220        return Err(format!(
221            "`{}` is not a UUID: 32 hex digits are wanted, in the 8-4-4-4-12 form",
222            text.escape_default()
223        ));
224    }
225    // Hyphens, where they appear at all, have to be in the right places — otherwise `1-2345…` and
226    // the canonical spelling would normalise to the same identifier, and one of them is a typo.
227    if inner.contains('-') && !hyphenated(inner) {
228        return Err(format!(
229            "`{}` is not a UUID: the groups are 8-4-4-4-12",
230            text.escape_default()
231        ));
232    }
233    let d = digits.to_ascii_lowercase();
234    Ok(format!(
235        "{}-{}-{}-{}-{}",
236        &d[0..8],
237        &d[8..12],
238        &d[12..16],
239        &d[16..20],
240        &d[20..32]
241    ))
242}
243
244fn hyphenated(s: &str) -> bool {
245    let groups: Vec<usize> = s.split('-').map(|g| g.chars().count()).collect();
246    groups == [8, 4, 4, 4, 12]
247}
248
249/// Which version a canonical UUID declares, as the digit in the third group.
250///
251/// `uuid()` mints a v4; a v7 arriving from elsewhere is a legal identifier and a program may want
252/// to know. This reads a nibble rather than validating a layout: a value whose variant bits say
253/// nothing is still an identifier, and refusing it would be refusing an identifier that works.
254pub fn uuid_version(canonical: &str) -> i64 {
255    canonical
256        .as_bytes()
257        .get(14)
258        .and_then(|c| char::from(*c).to_digit(16))
259        .map(i64::from)
260        .unwrap_or(0)
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn the_digest_is_blake3_and_the_vector_is_the_specifications() {
269        // BLAKE3's own test vector for the empty input, and for "abc" — quoted from the reference
270        // implementation's `test_vectors.json` rather than produced by this code and pasted back.
271        assert_eq!(
272            of(""),
273            "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
274        );
275        assert_eq!(
276            of("abc"),
277            "6437b3ac38465133ffb63b75273a8db548c558465d79db03fd359c6cd5bd9d85"
278        );
279    }
280
281    #[test]
282    fn a_keyed_digest_depends_on_both_halves_and_on_neither_alone() {
283        let a = keyed("k1", "message");
284        assert_ne!(
285            a,
286            keyed("k2", "message"),
287            "a different key, a different mac"
288        );
289        assert_ne!(
290            a,
291            keyed("k1", "other"),
292            "a different message, a different mac"
293        );
294        assert_eq!(a, keyed("k1", "message"), "and it is a function");
295        // The key is derived into a domain, so the same secret does not produce the runtime's
296        // identity credential for the same payload.
297        assert_ne!(a.len(), 0);
298        assert_eq!(a.len(), 64);
299    }
300
301    #[test]
302    fn constant_time_equality_answers_the_same_question_as_equality() {
303        for (a, b) in [
304            ("", ""),
305            ("a", "a"),
306            ("ab", "ab"),
307            ("a", "b"),
308            ("a", ""),
309            ("", "b"),
310        ] {
311            assert_eq!(same(a, b), a == b, "{a:?} vs {b:?}");
312        }
313    }
314
315    #[test]
316    fn hex_round_trips_and_the_vectors_are_ascii() {
317        assert_eq!(hex_encode("hello"), "68656c6c6f");
318        assert_eq!(
319            hex_decode("68656C6C6F").unwrap(),
320            "hello",
321            "uppercase reads"
322        );
323        for s in [
324            "",
325            "hello",
326            "a longer string with punctuation!",
327            "unicode: é☃",
328        ] {
329            assert_eq!(hex_decode(&hex_encode(s)).unwrap(), s);
330        }
331        assert!(hex_decode("abc").is_err(), "odd length");
332        assert!(hex_decode("zz").is_err(), "not a digit");
333    }
334
335    #[test]
336    fn base64_matches_rfc_4648s_test_vectors() {
337        // RFC 4648 §10, with §5's alphabet and no padding.
338        for (plain, encoded) in [
339            ("", ""),
340            ("f", "Zg"),
341            ("fo", "Zm8"),
342            ("foo", "Zm9v"),
343            ("foob", "Zm9vYg"),
344            ("fooba", "Zm9vYmE"),
345            ("foobar", "Zm9vYmFy"),
346        ] {
347            assert_eq!(base64_encode(plain), encoded, "encoding {plain:?}");
348            assert_eq!(
349                base64_decode(encoded).unwrap(),
350                plain,
351                "decoding {encoded:?}"
352            );
353        }
354    }
355
356    #[test]
357    fn base64_reads_what_other_encoders_write() {
358        assert_eq!(base64_decode("Zm9vYmE=").unwrap(), "fooba", "padded");
359        assert_eq!(base64_decode("Zm9vYmE==").unwrap(), "fooba", "over-padded");
360        // `+` and `/` are the standard alphabet's 62 and 63; `-` and `_` are §5's. `"aa>"` and
361        // `"aa?"` are the shortest ASCII strings whose last sextet is each of the two.
362        assert_eq!(base64_decode("YWE+").unwrap(), "aa>");
363        assert_eq!(base64_decode("YWE-").unwrap(), "aa>");
364        assert_eq!(base64_decode("YWE/").unwrap(), "aa?");
365        assert_eq!(base64_decode("YWE_").unwrap(), "aa?");
366        assert!(
367            base64_decode("Zm9vYmFy!").is_err(),
368            "not a base64 character"
369        );
370        assert!(
371            base64_decode("Z").is_err(),
372            "a group of one encodes no byte"
373        );
374    }
375
376    #[test]
377    fn base64_round_trips_every_length_of_a_growing_string() {
378        let mut s = String::new();
379        for c in "the quick brown fox jumps over the lazy dog".chars() {
380            s.push(c);
381            assert_eq!(base64_decode(&base64_encode(&s)).unwrap(), s, "{s:?}");
382        }
383    }
384
385    #[test]
386    fn a_uuid_normalises_to_one_spelling() {
387        let canonical = "f47ac10b-58cc-4372-a567-0e02b2c3d479";
388        for spelling in [
389            "f47ac10b-58cc-4372-a567-0e02b2c3d479",
390            "F47AC10B-58CC-4372-A567-0E02B2C3D479",
391            "f47ac10b58cc4372a5670e02b2c3d479",
392            "{f47ac10b-58cc-4372-a567-0e02b2c3d479}",
393            "urn:uuid:f47ac10b-58cc-4372-a567-0e02b2c3d479",
394            "  f47ac10b-58cc-4372-a567-0e02b2c3d479  ",
395        ] {
396            assert_eq!(
397                uuid_normalise(spelling).as_deref(),
398                Ok(canonical),
399                "{spelling}"
400            );
401        }
402        assert_eq!(uuid_version(canonical), 4);
403    }
404
405    #[test]
406    fn what_is_not_a_uuid_says_which_way_it_is_not() {
407        for bad in [
408            "",
409            "f47ac10b-58cc-4372-a567-0e02b2c3d47", // 31 digits
410            "f47ac10b-58cc-4372-a567-0e02b2c3d4799", // 33
411            "g47ac10b-58cc-4372-a567-0e02b2c3d479", // not hex
412            "f47ac10b-58c-c4372-a567-0e02b2c3d479", // groups in the wrong places
413        ] {
414            assert!(uuid_normalise(bad).is_err(), "{bad:?} is not a UUID");
415        }
416    }
417}