Skip to main content

gunnar_core/
transparency.rs

1//! **The transparency log's arithmetic**: a Merkle tree over records, and a
2//! signed tree head over the tree.
3//!
4//! Push attestation already appends one JSON line per push to an append-only file. Append-only
5//! is a *discipline*, not a proof: nothing stops a file being rewritten, and
6//! nothing in the file says it has not been. This module is what turns the
7//! discipline into evidence.
8//!
9//! # The shape, and why it is RFC 6962's
10//!
11//! Certificate Transparency solved this problem, in public, against adversaries,
12//! and its hashing rules are three lines:
13//!
14//! ```text
15//!   leaf(record)     = SHA-256(0x00 ‖ record)
16//!   node(left,right) = SHA-256(0x01 ‖ left ‖ right)
17//!   root([])         = SHA-256("")
18//! ```
19//!
20//! The domain-separating prefixes are the load-bearing part. Without them a leaf
21//! whose bytes happen to be two concatenated hashes could be presented as an
22//! interior node, and an attacker could show a "proof" for a record the log
23//! never held (the second-preimage attack every naive Merkle implementation
24//! has). Reimplementing this without the prefixes would be a Merkle tree with a
25//! hole in it, so the prefixes are here and are tested.
26//!
27//! An odd node is **promoted, not duplicated**: a level with an odd count
28//! carries the last hash up unchanged. Duplicating it instead is the flaw that
29//! let two different trees share a root in early Bitcoin.
30//!
31//! # What signs a tree head, and why it is `sshsig`
32//!
33//! The same format everything else in this repository signs with: commits
34//! (`gunnar-policy`), gRPC challenges (`gunnar-grpc`), and now tree heads. One
35//! signature format means one verifier to trust and one that has already been
36//! looked at — and it means a client can check a tree head with `ssh-keygen -Y
37//! verify` and no gunnar at all.
38//!
39//! The namespace is [`STH_NAMESPACE`], and it is not the namespace anything else
40//! signs under: a signature made for one namespace does not verify in another,
41//! so a commit signature can never be replayed as a tree head and vice versa.
42
43use sha2::Digest as _;
44
45/// The `sshsig` namespace a signed tree head is signed under.
46///
47/// Distinct from `git` (commits) and `gunnar-grpc` (control-plane challenges) on
48/// purpose: `sshsig` binds the namespace into what is signed, so a signature
49/// made under one cannot be presented as another.
50pub const STH_NAMESPACE: &str = "gunnar-transparency";
51
52/// RFC 6962's leaf prefix. Domain separation; see the module docs.
53const LEAF_PREFIX: u8 = 0x00;
54/// RFC 6962's interior-node prefix.
55const NODE_PREFIX: u8 = 0x01;
56
57/// A 32-byte SHA-256 digest.
58pub type Hash = [u8; 32];
59
60/// Render a hash the way every field in this repository renders one: lowercase
61/// hex.
62pub fn hex(hash: &Hash) -> String {
63    let mut out = String::with_capacity(64);
64    for byte in hash {
65        out.push_str(&format!("{byte:02x}"));
66    }
67    out
68}
69
70/// Parse a 64-character lowercase hex digest.
71pub fn unhex(text: &str) -> Option<Hash> {
72    if text.len() != 64 {
73        return None;
74    }
75    let mut out = [0u8; 32];
76    for (index, slot) in out.iter_mut().enumerate() {
77        *slot = u8::from_str_radix(text.get(index * 2..index * 2 + 2)?, 16).ok()?;
78    }
79    Some(out)
80}
81
82/// `SHA-256(0x00 ‖ record)` — one record's leaf hash.
83pub fn leaf_hash(record: &[u8]) -> Hash {
84    let mut hasher = sha2::Sha256::new();
85    hasher.update([LEAF_PREFIX]);
86    hasher.update(record);
87    hasher.finalize().into()
88}
89
90/// `SHA-256(0x01 ‖ left ‖ right)` — one interior node.
91pub fn node_hash(left: &Hash, right: &Hash) -> Hash {
92    let mut hasher = sha2::Sha256::new();
93    hasher.update([NODE_PREFIX]);
94    hasher.update(left);
95    hasher.update(right);
96    hasher.finalize().into()
97}
98
99/// The Merkle root over `leaves`, in order.
100///
101/// An empty log hashes to `SHA-256("")`, which is RFC 6962's rule and is a
102/// *value* rather than an absence — an empty log can be signed, and a signed
103/// empty head is how a verifier learns the log exists and holds nothing.
104pub fn root(leaves: &[Hash]) -> Hash {
105    if leaves.is_empty() {
106        return sha2::Sha256::digest([]).into();
107    }
108    let mut level: Vec<Hash> = leaves.to_vec();
109    while level.len() > 1 {
110        let mut next = Vec::with_capacity(level.len().div_ceil(2));
111        let mut pairs = level.chunks_exact(2);
112        for pair in &mut pairs {
113            next.push(node_hash(&pair[0], &pair[1]));
114        }
115        // Promoted, not duplicated. See the module docs.
116        if let [odd] = pairs.remainder() {
117            next.push(*odd);
118        }
119        level = next;
120    }
121    level[0]
122}
123
124/// The sibling hashes that carry `index` up to the root of a tree of `leaves`.
125///
126/// `None` when `index` is out of range — a proof for a record the log does not
127/// hold is not an empty proof, and returning one would make "verified" the
128/// answer for a leaf that was never there.
129pub fn inclusion_proof(leaves: &[Hash], index: usize) -> Option<Vec<Hash>> {
130    if index >= leaves.len() {
131        return None;
132    }
133    let mut proof = Vec::new();
134    let mut level: Vec<Hash> = leaves.to_vec();
135    let mut at = index;
136    while level.len() > 1 {
137        // The promoted-odd rule again, and it has to be mirrored exactly: a
138        // node with no sibling contributes nothing to the path.
139        let sibling = if at % 2 == 0 { at + 1 } else { at - 1 };
140        if sibling < level.len() {
141            proof.push(level[sibling]);
142        }
143        let mut next = Vec::with_capacity(level.len().div_ceil(2));
144        let mut pairs = level.chunks_exact(2);
145        for pair in &mut pairs {
146            next.push(node_hash(&pair[0], &pair[1]));
147        }
148        if let [odd] = pairs.remainder() {
149            next.push(*odd);
150        }
151        level = next;
152        at /= 2;
153    }
154    Some(proof)
155}
156
157/// Recompute a root from one leaf and its proof, and compare.
158///
159/// This is the whole point of the module: a client with a record, its position,
160/// the tree size and a signed root can check membership **without the log**.
161pub fn verify_inclusion(
162    leaf: &Hash,
163    index: usize,
164    size: usize,
165    proof: &[Hash],
166    expected_root: &Hash,
167) -> bool {
168    if index >= size {
169        return false;
170    }
171    let mut computed = *leaf;
172    let mut at = index;
173    let mut width = size;
174    let mut steps = proof.iter();
175    while width > 1 {
176        let sibling = if at % 2 == 0 { at + 1 } else { at - 1 };
177        if sibling < width {
178            let Some(step) = steps.next() else {
179                // The proof ran out before the tree did. A short proof must be
180                // a refusal and never a pass: padding it with anything at all
181                // would let a truncated proof verify.
182                return false;
183            };
184            computed = if at % 2 == 0 {
185                node_hash(&computed, step)
186            } else {
187                node_hash(step, &computed)
188            };
189        }
190        at /= 2;
191        width = width.div_ceil(2);
192    }
193    // A proof with steps left over describes a different tree.
194    steps.next().is_none() && computed == *expected_root
195}
196
197/// **What a log signs**: how many records it holds and what they hash to.
198///
199/// The pair is the claim. A root alone says nothing about *when* — the same
200/// root can be reissued for ever — and a size alone is not a commitment to
201/// content. Together they say: *at this moment I held exactly these N records,
202/// in this order.*
203#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
204#[serde(deny_unknown_fields)]
205pub struct TreeHead {
206    /// How many records the log held.
207    pub size: u64,
208    /// [`root`] over those records, hex.
209    pub root: String,
210    /// When the head was issued.
211    pub at_unix_ms: i64,
212}
213
214impl TreeHead {
215    /// The exact bytes that are signed.
216    ///
217    /// A fixed, line-oriented rendering rather than the JSON: JSON has no
218    /// canonical form, so signing the serialisation would make the signature
219    /// depend on a serialiser's field order. Three lines and a label cannot
220    /// drift.
221    pub fn signing_bytes(&self) -> Vec<u8> {
222        format!(
223            "gunnar-transparency-v1\nsize {}\nroot {}\nat {}\n",
224            self.size, self.root, self.at_unix_ms
225        )
226        .into_bytes()
227    }
228}
229
230/// A [`TreeHead`] with the signature that makes it evidence.
231#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
232#[serde(deny_unknown_fields)]
233pub struct SignedTreeHead {
234    /// The head the signature is over.
235    #[serde(flatten)]
236    pub head: TreeHead,
237    /// `SHA256:…` of the key that signed it, so a verifier knows which key to
238    /// look up before it does any crypto.
239    pub key_fingerprint: String,
240    /// The `sshsig`, PEM-armoured, newlines and all.
241    pub signature: String,
242}
243
244/// What went wrong verifying a tree head.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum TreeHeadProblem {
247    /// The signature is not a well-formed `sshsig`.
248    Malformed(String),
249    /// The head names a key that is not the one offered.
250    WrongKey {
251        /// The fingerprint the head claims.
252        signed_by: String,
253        /// The fingerprint of the key given to check it.
254        offered: String,
255    },
256    /// The signature does not verify against the key.
257    NotVerified,
258}
259
260impl std::fmt::Display for TreeHeadProblem {
261    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262        match self {
263            TreeHeadProblem::Malformed(why) => write!(
264                f,
265                "the tree head's signature is not a \
266                 well-formed sshsig: {why}"
267            ),
268            TreeHeadProblem::WrongKey { signed_by, offered } => write!(
269                f,
270                "the tree head says it was signed by {signed_by} and was checked against {offered}"
271            ),
272            TreeHeadProblem::NotVerified => {
273                write!(f, "the tree head's signature does not verify")
274            }
275        }
276    }
277}
278
279/// Sign a tree head with `key`, under [`STH_NAMESPACE`].
280pub fn sign_tree_head(
281    key: &ssh_key::PrivateKey,
282    head: TreeHead,
283) -> Result<SignedTreeHead, ssh_key::Error> {
284    let signature = key.sign(
285        STH_NAMESPACE,
286        ssh_key::HashAlg::Sha512,
287        &head.signing_bytes(),
288    )?;
289    Ok(SignedTreeHead {
290        key_fingerprint: key
291            .public_key()
292            .fingerprint(ssh_key::HashAlg::Sha256)
293            .to_string(),
294        signature: signature.to_pem(ssh_key::LineEnding::LF)?,
295        head,
296    })
297}
298
299/// Verify a signed tree head against a public key.
300///
301/// The fingerprint in the record is checked against the key **before** the
302/// signature, so a head signed by key A can never be reported as verified by
303/// key B even if the caller looked up the wrong row.
304pub fn verify_tree_head(
305    sth: &SignedTreeHead,
306    key: &ssh_key::PublicKey,
307) -> Result<(), TreeHeadProblem> {
308    let offered = key.fingerprint(ssh_key::HashAlg::Sha256).to_string();
309    if offered != sth.key_fingerprint {
310        return Err(TreeHeadProblem::WrongKey {
311            signed_by: sth.key_fingerprint.clone(),
312            offered,
313        });
314    }
315    let signature = ssh_key::SshSig::from_pem(sth.signature.as_bytes())
316        .map_err(|err| TreeHeadProblem::Malformed(err.to_string()))?;
317    if signature.namespace() != STH_NAMESPACE {
318        return Err(TreeHeadProblem::Malformed(format!(
319            "namespace is {:?}, not {STH_NAMESPACE:?}",
320            signature.namespace()
321        )));
322    }
323    key.verify(STH_NAMESPACE, &sth.head.signing_bytes(), &signature)
324        .map_err(|_| TreeHeadProblem::NotVerified)
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    fn leaves(n: usize) -> Vec<Hash> {
332        (0..n)
333            .map(|i| leaf_hash(format!("record {i}").as_bytes()))
334            .collect()
335    }
336
337    /// **The prefixes are what stop a second-preimage attack**, so they are
338    /// asserted rather than assumed: a leaf and a node over the same bytes must
339    /// not collide.
340    #[test]
341    fn a_leaf_and_a_node_over_the_same_bytes_are_different_hashes() {
342        let a = leaf_hash(b"x");
343        let b = leaf_hash(b"y");
344        let mut concatenated = Vec::new();
345        concatenated.extend_from_slice(&a);
346        concatenated.extend_from_slice(&b);
347        assert_ne!(
348            leaf_hash(&concatenated),
349            node_hash(&a, &b),
350            "a leaf whose bytes are two hashes hashes like an interior node, so a proof can be \
351             forged for a record the log never held"
352        );
353    }
354
355    /// An odd leaf is **promoted**, not duplicated — and the two rules give
356    /// different roots, which is why it has to be said.
357    #[test]
358    fn an_odd_leaf_is_promoted_and_not_duplicated() {
359        let three = leaves(3);
360        let promoted = root(&three);
361        let duplicated = {
362            let pair = node_hash(&three[0], &three[1]);
363            let doubled = node_hash(&three[2], &three[2]);
364            node_hash(&pair, &doubled)
365        };
366        assert_ne!(
367            promoted, duplicated,
368            "promotion and duplication produced the same root, so this test cannot tell them \
369             apart and the rule below is unasserted"
370        );
371    }
372
373    /// Every leaf in every size up to 33 proves, and every proof is checked
374    /// against the size it was made for.
375    #[test]
376    fn every_leaf_of_every_size_proves_against_its_own_root() {
377        for size in 1..=33 {
378            let set = leaves(size);
379            let expected = root(&set);
380            for index in 0..size {
381                let proof = inclusion_proof(&set, index).expect("a leaf that is in the tree");
382                assert!(
383                    verify_inclusion(&set[index], index, size, &proof, &expected),
384                    "leaf {index} of {size} did not verify"
385                );
386            }
387        }
388    }
389
390    /// The refusals. Each one is a way a verifier could be talked into saying
391    /// yes, and each must say no.
392    #[test]
393    fn a_forged_or_truncated_or_misplaced_proof_is_refused() {
394        let set = leaves(8);
395        let expected = root(&set);
396        let proof = inclusion_proof(&set, 3).expect("in the tree");
397
398        assert!(
399            !verify_inclusion(&set[3], 4, 8, &proof, &expected),
400            "a proof for leaf 3 verified at position 4"
401        );
402        assert!(
403            !verify_inclusion(&set[4], 3, 8, &proof, &expected),
404            "a different leaf verified with leaf 3's proof"
405        );
406        assert!(
407            !verify_inclusion(&set[3], 3, 8, &proof[..proof.len() - 1], &expected),
408            "a TRUNCATED proof verified; a short proof must be a refusal"
409        );
410        let mut too_long = proof.clone();
411        too_long.push([0u8; 32]);
412        assert!(
413            !verify_inclusion(&set[3], 3, 8, &too_long, &expected),
414            "a proof with a step left over verified; it describes a different tree"
415        );
416        assert!(
417            !verify_inclusion(&set[3], 3, 8, &proof, &[0u8; 32]),
418            "a proof verified against a root nobody signed"
419        );
420        assert!(
421            inclusion_proof(&set, 8).is_none(),
422            "a proof was produced for a leaf past the end of the log"
423        );
424    }
425
426    /// **Rewriting one record changes the root**, which is the property the
427    /// whole log exists for.
428    #[test]
429    fn changing_one_record_changes_the_root() {
430        let mut set = leaves(9);
431        let before = root(&set);
432        set[4] = leaf_hash(b"record 4, tampered with");
433        assert_ne!(
434            before,
435            root(&set),
436            "a record was rewritten and the root did not move, so a signed head over the old \
437             root still covers the new file"
438        );
439    }
440
441    #[test]
442    fn hex_round_trips_and_refuses_what_is_not_a_digest() {
443        let h = leaf_hash(b"x");
444        assert_eq!(unhex(&hex(&h)), Some(h));
445        assert_eq!(unhex("short"), None);
446        assert_eq!(unhex(&"z".repeat(64)), None);
447    }
448
449    /// A tree head signs and verifies, and every way of getting it wrong is
450    /// refused: a different key, a moved size, a moved root, another namespace.
451    #[test]
452    fn a_tree_head_verifies_and_a_tampered_one_does_not() {
453        let pem = crate::mint_ed25519().expect("mint a key");
454        let key = ssh_key::PrivateKey::from_openssh(&pem).expect("parse");
455        let other = ssh_key::PrivateKey::from_openssh(crate::mint_ed25519().expect("a second key"))
456            .expect("parse");
457
458        let head = TreeHead {
459            size: 7,
460            root: hex(&root(&leaves(7))),
461            at_unix_ms: 1_700_000_000_000,
462        };
463        let sth = sign_tree_head(&key, head.clone()).expect("sign");
464        assert_eq!(verify_tree_head(&sth, key.public_key()), Ok(()));
465
466        assert!(matches!(
467            verify_tree_head(&sth, other.public_key()),
468            Err(TreeHeadProblem::WrongKey { .. })
469        ));
470
471        let mut moved = sth.clone();
472        moved.head.size = 8;
473        assert_eq!(
474            verify_tree_head(&moved, key.public_key()),
475            Err(TreeHeadProblem::NotVerified),
476            "a head whose size was changed still verified"
477        );
478
479        let mut rerooted = sth.clone();
480        rerooted.head.root = hex(&[0u8; 32]);
481        assert_eq!(
482            verify_tree_head(&rerooted, key.public_key()),
483            Err(TreeHeadProblem::NotVerified),
484            "a head whose root was changed still verified"
485        );
486
487        // A signature made under another namespace must not be accepted here,
488        // which is what stops a commit signature being presented as a head.
489        let elsewhere = key
490            .sign("git", ssh_key::HashAlg::Sha512, &head.signing_bytes())
491            .expect("sign under another namespace");
492        let smuggled = SignedTreeHead {
493            head,
494            key_fingerprint: sth.key_fingerprint.clone(),
495            signature: elsewhere.to_pem(ssh_key::LineEnding::LF).expect("armour"),
496        };
497        assert!(matches!(
498            verify_tree_head(&smuggled, key.public_key()),
499            Err(TreeHeadProblem::Malformed(_))
500        ));
501    }
502}