use sha2::Digest as _;
pub const STH_NAMESPACE: &str = "gunnar-transparency";
const LEAF_PREFIX: u8 = 0x00;
const NODE_PREFIX: u8 = 0x01;
pub type Hash = [u8; 32];
pub fn hex(hash: &Hash) -> String {
let mut out = String::with_capacity(64);
for byte in hash {
out.push_str(&format!("{byte:02x}"));
}
out
}
pub fn unhex(text: &str) -> Option<Hash> {
if text.len() != 64 {
return None;
}
let mut out = [0u8; 32];
for (index, slot) in out.iter_mut().enumerate() {
*slot = u8::from_str_radix(text.get(index * 2..index * 2 + 2)?, 16).ok()?;
}
Some(out)
}
pub fn leaf_hash(record: &[u8]) -> Hash {
let mut hasher = sha2::Sha256::new();
hasher.update([LEAF_PREFIX]);
hasher.update(record);
hasher.finalize().into()
}
pub fn node_hash(left: &Hash, right: &Hash) -> Hash {
let mut hasher = sha2::Sha256::new();
hasher.update([NODE_PREFIX]);
hasher.update(left);
hasher.update(right);
hasher.finalize().into()
}
pub fn root(leaves: &[Hash]) -> Hash {
if leaves.is_empty() {
return sha2::Sha256::digest([]).into();
}
let mut level: Vec<Hash> = leaves.to_vec();
while level.len() > 1 {
let mut next = Vec::with_capacity(level.len().div_ceil(2));
let mut pairs = level.chunks_exact(2);
for pair in &mut pairs {
next.push(node_hash(&pair[0], &pair[1]));
}
if let [odd] = pairs.remainder() {
next.push(*odd);
}
level = next;
}
level[0]
}
pub fn inclusion_proof(leaves: &[Hash], index: usize) -> Option<Vec<Hash>> {
if index >= leaves.len() {
return None;
}
let mut proof = Vec::new();
let mut level: Vec<Hash> = leaves.to_vec();
let mut at = index;
while level.len() > 1 {
let sibling = if at % 2 == 0 { at + 1 } else { at - 1 };
if sibling < level.len() {
proof.push(level[sibling]);
}
let mut next = Vec::with_capacity(level.len().div_ceil(2));
let mut pairs = level.chunks_exact(2);
for pair in &mut pairs {
next.push(node_hash(&pair[0], &pair[1]));
}
if let [odd] = pairs.remainder() {
next.push(*odd);
}
level = next;
at /= 2;
}
Some(proof)
}
pub fn verify_inclusion(
leaf: &Hash,
index: usize,
size: usize,
proof: &[Hash],
expected_root: &Hash,
) -> bool {
if index >= size {
return false;
}
let mut computed = *leaf;
let mut at = index;
let mut width = size;
let mut steps = proof.iter();
while width > 1 {
let sibling = if at % 2 == 0 { at + 1 } else { at - 1 };
if sibling < width {
let Some(step) = steps.next() else {
return false;
};
computed = if at % 2 == 0 {
node_hash(&computed, step)
} else {
node_hash(step, &computed)
};
}
at /= 2;
width = width.div_ceil(2);
}
steps.next().is_none() && computed == *expected_root
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TreeHead {
pub size: u64,
pub root: String,
pub at_unix_ms: i64,
}
impl TreeHead {
pub fn signing_bytes(&self) -> Vec<u8> {
format!(
"gunnar-transparency-v1\nsize {}\nroot {}\nat {}\n",
self.size, self.root, self.at_unix_ms
)
.into_bytes()
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SignedTreeHead {
#[serde(flatten)]
pub head: TreeHead,
pub key_fingerprint: String,
pub signature: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TreeHeadProblem {
Malformed(String),
WrongKey {
signed_by: String,
offered: String,
},
NotVerified,
}
impl std::fmt::Display for TreeHeadProblem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TreeHeadProblem::Malformed(why) => write!(
f,
"the tree head's signature is not a \
well-formed sshsig: {why}"
),
TreeHeadProblem::WrongKey { signed_by, offered } => write!(
f,
"the tree head says it was signed by {signed_by} and was checked against {offered}"
),
TreeHeadProblem::NotVerified => {
write!(f, "the tree head's signature does not verify")
}
}
}
}
pub fn sign_tree_head(
key: &ssh_key::PrivateKey,
head: TreeHead,
) -> Result<SignedTreeHead, ssh_key::Error> {
let signature = key.sign(
STH_NAMESPACE,
ssh_key::HashAlg::Sha512,
&head.signing_bytes(),
)?;
Ok(SignedTreeHead {
key_fingerprint: key
.public_key()
.fingerprint(ssh_key::HashAlg::Sha256)
.to_string(),
signature: signature.to_pem(ssh_key::LineEnding::LF)?,
head,
})
}
pub fn verify_tree_head(
sth: &SignedTreeHead,
key: &ssh_key::PublicKey,
) -> Result<(), TreeHeadProblem> {
let offered = key.fingerprint(ssh_key::HashAlg::Sha256).to_string();
if offered != sth.key_fingerprint {
return Err(TreeHeadProblem::WrongKey {
signed_by: sth.key_fingerprint.clone(),
offered,
});
}
let signature = ssh_key::SshSig::from_pem(sth.signature.as_bytes())
.map_err(|err| TreeHeadProblem::Malformed(err.to_string()))?;
if signature.namespace() != STH_NAMESPACE {
return Err(TreeHeadProblem::Malformed(format!(
"namespace is {:?}, not {STH_NAMESPACE:?}",
signature.namespace()
)));
}
key.verify(STH_NAMESPACE, &sth.head.signing_bytes(), &signature)
.map_err(|_| TreeHeadProblem::NotVerified)
}
#[cfg(test)]
mod tests {
use super::*;
fn leaves(n: usize) -> Vec<Hash> {
(0..n)
.map(|i| leaf_hash(format!("record {i}").as_bytes()))
.collect()
}
#[test]
fn a_leaf_and_a_node_over_the_same_bytes_are_different_hashes() {
let a = leaf_hash(b"x");
let b = leaf_hash(b"y");
let mut concatenated = Vec::new();
concatenated.extend_from_slice(&a);
concatenated.extend_from_slice(&b);
assert_ne!(
leaf_hash(&concatenated),
node_hash(&a, &b),
"a leaf whose bytes are two hashes hashes like an interior node, so a proof can be \
forged for a record the log never held"
);
}
#[test]
fn an_odd_leaf_is_promoted_and_not_duplicated() {
let three = leaves(3);
let promoted = root(&three);
let duplicated = {
let pair = node_hash(&three[0], &three[1]);
let doubled = node_hash(&three[2], &three[2]);
node_hash(&pair, &doubled)
};
assert_ne!(
promoted, duplicated,
"promotion and duplication produced the same root, so this test cannot tell them \
apart and the rule below is unasserted"
);
}
#[test]
fn every_leaf_of_every_size_proves_against_its_own_root() {
for size in 1..=33 {
let set = leaves(size);
let expected = root(&set);
for index in 0..size {
let proof = inclusion_proof(&set, index).expect("a leaf that is in the tree");
assert!(
verify_inclusion(&set[index], index, size, &proof, &expected),
"leaf {index} of {size} did not verify"
);
}
}
}
#[test]
fn a_forged_or_truncated_or_misplaced_proof_is_refused() {
let set = leaves(8);
let expected = root(&set);
let proof = inclusion_proof(&set, 3).expect("in the tree");
assert!(
!verify_inclusion(&set[3], 4, 8, &proof, &expected),
"a proof for leaf 3 verified at position 4"
);
assert!(
!verify_inclusion(&set[4], 3, 8, &proof, &expected),
"a different leaf verified with leaf 3's proof"
);
assert!(
!verify_inclusion(&set[3], 3, 8, &proof[..proof.len() - 1], &expected),
"a TRUNCATED proof verified; a short proof must be a refusal"
);
let mut too_long = proof.clone();
too_long.push([0u8; 32]);
assert!(
!verify_inclusion(&set[3], 3, 8, &too_long, &expected),
"a proof with a step left over verified; it describes a different tree"
);
assert!(
!verify_inclusion(&set[3], 3, 8, &proof, &[0u8; 32]),
"a proof verified against a root nobody signed"
);
assert!(
inclusion_proof(&set, 8).is_none(),
"a proof was produced for a leaf past the end of the log"
);
}
#[test]
fn changing_one_record_changes_the_root() {
let mut set = leaves(9);
let before = root(&set);
set[4] = leaf_hash(b"record 4, tampered with");
assert_ne!(
before,
root(&set),
"a record was rewritten and the root did not move, so a signed head over the old \
root still covers the new file"
);
}
#[test]
fn hex_round_trips_and_refuses_what_is_not_a_digest() {
let h = leaf_hash(b"x");
assert_eq!(unhex(&hex(&h)), Some(h));
assert_eq!(unhex("short"), None);
assert_eq!(unhex(&"z".repeat(64)), None);
}
#[test]
fn a_tree_head_verifies_and_a_tampered_one_does_not() {
let pem = crate::mint_ed25519().expect("mint a key");
let key = ssh_key::PrivateKey::from_openssh(&pem).expect("parse");
let other = ssh_key::PrivateKey::from_openssh(crate::mint_ed25519().expect("a second key"))
.expect("parse");
let head = TreeHead {
size: 7,
root: hex(&root(&leaves(7))),
at_unix_ms: 1_700_000_000_000,
};
let sth = sign_tree_head(&key, head.clone()).expect("sign");
assert_eq!(verify_tree_head(&sth, key.public_key()), Ok(()));
assert!(matches!(
verify_tree_head(&sth, other.public_key()),
Err(TreeHeadProblem::WrongKey { .. })
));
let mut moved = sth.clone();
moved.head.size = 8;
assert_eq!(
verify_tree_head(&moved, key.public_key()),
Err(TreeHeadProblem::NotVerified),
"a head whose size was changed still verified"
);
let mut rerooted = sth.clone();
rerooted.head.root = hex(&[0u8; 32]);
assert_eq!(
verify_tree_head(&rerooted, key.public_key()),
Err(TreeHeadProblem::NotVerified),
"a head whose root was changed still verified"
);
let elsewhere = key
.sign("git", ssh_key::HashAlg::Sha512, &head.signing_bytes())
.expect("sign under another namespace");
let smuggled = SignedTreeHead {
head,
key_fingerprint: sth.key_fingerprint.clone(),
signature: elsewhere.to_pem(ssh_key::LineEnding::LF).expect("armour"),
};
assert!(matches!(
verify_tree_head(&smuggled, key.public_key()),
Err(TreeHeadProblem::Malformed(_))
));
}
}