Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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(())
}