1use sha2::Digest as _;
44
45pub const STH_NAMESPACE: &str = "gunnar-transparency";
51
52const LEAF_PREFIX: u8 = 0x00;
54const NODE_PREFIX: u8 = 0x01;
56
57pub type Hash = [u8; 32];
59
60pub 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
70pub 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
82pub 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
90pub 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
99pub 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 if let [odd] = pairs.remainder() {
117 next.push(*odd);
118 }
119 level = next;
120 }
121 level[0]
122}
123
124pub 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 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
157pub 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 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 steps.next().is_none() && computed == *expected_root
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
204#[serde(deny_unknown_fields)]
205pub struct TreeHead {
206 pub size: u64,
208 pub root: String,
210 pub at_unix_ms: i64,
212}
213
214impl TreeHead {
215 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
232#[serde(deny_unknown_fields)]
233pub struct SignedTreeHead {
234 #[serde(flatten)]
236 pub head: TreeHead,
237 pub key_fingerprint: String,
240 pub signature: String,
242}
243
244#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum TreeHeadProblem {
247 Malformed(String),
249 WrongKey {
251 signed_by: String,
253 offered: String,
255 },
256 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
279pub 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
299pub 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 #[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 #[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 #[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 #[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 #[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 #[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 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}