provenance/src/tlog.rs
//! Rekor entries, checked offline: the signed entry timestamp (SET), the Merkle
//! inclusion proof against a signed checkpoint, and the entry body's binding to
//! the signature being verified.
use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::crypto::{self, PublicKey};
use crate::error::{ProvenanceError, Result};
use crate::material::{InclusionProof, TlogEntry};
use crate::trust::TrustRoot;
/// What an entry's check established.
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TlogCheck {
pub log_index: u64,
pub integrated_time: i64,
pub log: String,
pub set_verified: bool,
pub inclusion_verified: bool,
}
fn unverified(w: impl Into<String>) -> ProvenanceError {
ProvenanceError::TlogUnverified(w.into())
}
/// The canonical JSON a SET signs: `{body, integratedTime, logID, logIndex}`.
pub fn set_payload(e: &TlogEntry) -> Vec<u8> {
format!(
"{{\"body\":\"{}\",\"integratedTime\":{},\"logID\":\"{}\",\"logIndex\":{}}}",
crypto::b64_encode(&e.body),
e.integrated_time,
hex::encode(&e.log_id),
e.log_index
)
.into_bytes()
}
fn h(parts: &[&[u8]]) -> Vec<u8> {
let mut d = Sha256::new();
for p in parts {
d.update(p);
}
d.finalize().to_vec()
}
/// RFC 6962 leaf hash.
pub fn leaf_hash(body: &[u8]) -> Vec<u8> {
h(&[&[0u8], body])
}
/// The root an inclusion proof yields (RFC 9162 §2.1.3.2), or `None`.
pub fn root_from_proof(index: u64, size: u64, leaf: &[u8], proof: &[Vec<u8>]) -> Option<Vec<u8>> {
if index >= size {
return None;
}
let (mut fnn, mut sn) = (index, size - 1);
let mut r = leaf.to_vec();
for p in proof {
if sn == 0 {
return None;
}
if fnn & 1 == 1 || fnn == sn {
r = h(&[&[1u8], p, &r]);
while fnn & 1 == 0 && fnn != 0 {
fnn >>= 1;
sn >>= 1;
}
} else {
r = h(&[&[1u8], &r, p]);
}
fnn >>= 1;
sn >>= 1;
}
(sn == 0).then_some(r)
}
/// A checkpoint (a C2SP signed note): `(origin, size, root hash)` once a
/// signature line by `key` verifies over the note text.
pub fn verify_checkpoint(
note: &str,
key: &PublicKey,
log_id: &[u8],
) -> Result<(String, u64, Vec<u8>)> {
let (text, sigs) = note
.split_once("\n\n")
.ok_or_else(|| unverified("the checkpoint has no signature block"))?;
let signed = format!("{text}\n");
let mut lines = text.lines();
let origin = lines.next().unwrap_or("").to_string();
let size: u64 = lines
.next()
.and_then(|l| l.parse().ok())
.ok_or_else(|| unverified("the checkpoint has no tree size"))?;
let root = crypto::b64("checkpoint root hash", lines.next().unwrap_or(""))
.map_err(|e| unverified(e.to_string()))?;
for line in sigs.lines() {
let Some(rest) = line.strip_prefix("\u{2014} ") else {
continue;
};
let Some((_name, b)) = rest.rsplit_once(' ') else {
continue;
};
let Ok(raw) = crypto::b64("checkpoint signature", b) else {
continue;
};
if raw.len() < 5 {
continue;
}
let (hint, sig) = raw.split_at(4);
// Rekor v1 (ECDSA) hints with its log id; an Ed25519 note key with a hash
// of its name and key. Try the signature either way: the key decides.
if (hint == &log_id[..4.min(log_id.len())] || matches!(key, PublicKey::Ed25519(_)))
&& key.verify(signed.as_bytes(), sig)
{
return Ok((origin, size, root));
}
}
Err(unverified(
"no checkpoint signature verifies with the pinned log key",
))
}
fn verify_proof(e: &TlogEntry, p: &InclusionProof, key: &PublicKey, log_id: &[u8]) -> Result<()> {
let root = root_from_proof(p.log_index, p.tree_size, &leaf_hash(&e.body), &p.hashes)
.ok_or_else(|| {
unverified("the inclusion proof has the wrong shape for its index and tree size")
})?;
if root != p.root_hash {
return Err(unverified(
"the inclusion proof does not reach its root hash",
));
}
let (_, size, cp_root) = verify_checkpoint(&p.checkpoint, key, log_id)?;
if size != p.tree_size || cp_root != p.root_hash {
return Err(unverified(
"the signed checkpoint is for another tree than the proof",
));
}
Ok(())
}
/// ★ Check one entry against the pinned logs: its log is known and was valid at
/// the entry's time, and its SET or its inclusion proof verifies (both, when both
/// are there).
pub fn verify_entry(e: &TlogEntry, trust: &TrustRoot) -> Result<TlogCheck> {
let log = trust.tlog(&e.log_id).ok_or_else(|| {
unverified(format!(
"log id {} is not a pinned transparency log",
hex::encode(&e.log_id)
))
})?;
if !log.valid.covers(e.integrated_time) {
return Err(unverified(format!(
"the log {} was not valid at {}",
log.base_url, e.integrated_time
)));
}
let mut check = TlogCheck {
log_index: e.log_index,
integrated_time: e.integrated_time,
log: log.base_url.clone(),
..Default::default()
};
if let Some(set) = &e.set {
if !log.key.verify(&set_payload(e), set) {
return Err(unverified(
"the signed entry timestamp does not verify with the pinned log key",
));
}
check.set_verified = true;
}
if let Some(p) = &e.proof {
verify_proof(e, p, &log.key, &log.log_id)?;
check.inclusion_verified = true;
}
if !check.set_verified && !check.inclusion_verified {
return Err(unverified(
"the entry carries neither a signed entry timestamp nor an inclusion proof",
));
}
Ok(check)
}
/// What the entry body must be bound to.
pub struct Binding<'a> {
/// The signed payload (DSSE) or message.
pub payload: &'a [u8],
/// The exact envelope bytes, when they are known.
pub envelope: Option<&'a [u8]>,
pub signature: &'a [u8],
/// The signer's certificate DER, or its SPKI DER for a key.
pub verifier_der: &'a [u8],
}
fn same_verifier(field: Option<&str>, der: &[u8]) -> bool {
let Some(f) = field else { return false };
let Ok(pem) = crypto::b64("verifier", f) else {
return false;
};
let text = String::from_utf8_lossy(&pem);
crypto::pem_or_b64(&text).map(|d| d == der).unwrap_or(false)
}
/// ★ Is this entry about THIS signature? (`dsse` 0.0.1, `hashedrekord` 0.0.1, `intoto` 0.0.2.)
pub fn check_binding(e: &TlogEntry, b: &Binding<'_>) -> Result<()> {
let body: Value = serde_json::from_slice(&e.body)
.map_err(|e| unverified(format!("the entry body is not JSON: {e}")))?;
let kind = body.get("kind").and_then(Value::as_str).unwrap_or("");
let spec = body.get("spec").cloned().unwrap_or(Value::Null);
let st = |p: &str| spec.pointer(p).and_then(Value::as_str);
match kind {
"dsse" => {
if st("/payloadHash/value") != Some(crypto::sha256_hex(b.payload).as_str()) {
return Err(unverified(
"the dsse entry's payloadHash is not this payload",
));
}
if let (Some(env), Some(want)) = (b.envelope, st("/envelopeHash/value")) {
if want != crypto::sha256_hex(env) {
return Err(unverified(
"the dsse entry's envelopeHash is not this envelope",
));
}
}
let ok = spec
.get("signatures")
.and_then(Value::as_array)
.into_iter()
.flatten()
.any(|s| {
s.get("signature")
.and_then(Value::as_str)
.and_then(|x| crypto::b64("sig", x).ok())
.as_deref()
== Some(b.signature)
&& same_verifier(s.get("verifier").and_then(Value::as_str), b.verifier_der)
});
if !ok {
return Err(unverified(
"the dsse entry does not record this signature by this signer",
));
}
}
"hashedrekord" => {
if st("/data/hash/value") != Some(crypto::sha256_hex(b.payload).as_str()) {
return Err(unverified("the hashedrekord entry is about other bytes"));
}
if st("/signature/content")
.and_then(|x| crypto::b64("sig", x).ok())
.as_deref()
!= Some(b.signature)
{
return Err(unverified(
"the hashedrekord entry records another signature",
));
}
if !same_verifier(st("/signature/publicKey/content"), b.verifier_der) {
return Err(unverified("the hashedrekord entry records another signer"));
}
}
"intoto" => {
if st("/content/payloadHash/value") != Some(crypto::sha256_hex(b.payload).as_str()) {
return Err(unverified(
"the intoto entry's payloadHash is not this payload",
));
}
}
other => {
return Err(unverified(format!(
"entry kind {other:?} is not one this verifier binds"
)))
}
}
Ok(())
}