quantum_sign/transparency/
mod.rs

1//! CT-style Merkle transparency log (client + verification).
2#![forbid(unsafe_code)]
3#![deny(missing_docs)]
4
5use ciborium::{de, ser};
6// Local Merkle verification helpers (binary tree, parent = sha256(left||right))
7use serde::{Deserialize, Serialize};
8use std::fmt;
9
10use crate::crypto::{self as qs_crypto, DigestAlg};
11
12/// Errors returned by transparency operations.
13#[derive(Debug)]
14pub enum Error {
15    /// HTTP error (non-200 or network failure)
16    Http(String),
17    /// CBOR encoding/decoding error
18    Cbor(String),
19    /// Verification error (proofs, signatures)
20    Verify(String),
21}
22
23impl fmt::Display for Error {
24    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25        match self {
26            Error::Http(e) => write!(f, "http: {e}"),
27            Error::Cbor(e) => write!(f, "cbor: {e}"),
28            Error::Verify(e) => write!(f, "verify: {e}"),
29        }
30    }
31}
32
33impl std::error::Error for Error {}
34
35/// Signed Tree Head (STH) metadata signed by the log.
36#[derive(Clone, Debug, Serialize, Deserialize)]
37pub struct Sth {
38    /// Version of the STH structure.
39    pub v: u8,
40    /// Log identifier (KID hex derived from SPKI SHA-256 prefix).
41    pub log_id: String,
42    /// Total number of leaves included in this tree snapshot.
43    pub tree_size: u64,
44    /// Root hash of the Merkle tree for `tree_size` leaves.
45    #[serde(with = "serde_bytes")]
46    pub root_hash: Vec<u8>,
47    /// Unix timestamp when this STH was produced.
48    pub timestamp: i64,
49    /// Signature algorithm identifier (e.g., "mldsa-87").
50    pub sig_alg: String,
51    /// Detached signature bytes over canonical STH transcript.
52    #[serde(with = "serde_bytes")]
53    pub sig: Vec<u8>,
54}
55
56/// Receipt containing inclusion proof for a given leaf.
57#[derive(Clone, Debug, Serialize, Deserialize)]
58pub struct AppendReceipt {
59    /// Leaf hash (sha256 of canonical QSig core view)
60    #[serde(with = "serde_bytes")]
61    pub leaf_hash: Vec<u8>,
62    /// 0-based leaf index within the tree.
63    pub index: u64,
64    /// Sibling nodes bottom-up from leaf to root.
65    pub path: Vec<Vec<u8>>,
66    /// Signed Tree Head snapshot after append.
67    pub sth: Sth,
68}
69
70/// Append a leaf to the transparency log and return a receipt.
71pub fn append(base_url: &str, leaf: [u8; 32]) -> Result<AppendReceipt, Error> {
72    #[derive(Serialize)]
73    struct Req<'a> {
74        #[serde(with = "serde_bytes")]
75        leaf_hash: &'a [u8],
76    }
77    let mut body = Vec::new();
78    let req = Req { leaf_hash: &leaf };
79    ser::into_writer(&req, &mut body).map_err(|e| Error::Cbor(e.to_string()))?;
80    let url = format!("{}/v1/append", base_url.trim_end_matches('/'));
81    let mut res = ureq::post(&url)
82        .content_type("application/cbor")
83        .send(body.as_slice())
84        .map_err(|e| Error::Http(e.to_string()))?;
85    let bytes = res
86        .body_mut()
87        .read_to_vec()
88        .map_err(|e| Error::Http(e.to_string()))?;
89    de::from_reader(bytes.as_slice()).map_err(|e| Error::Cbor(e.to_string()))
90}
91
92/// Verify an inclusion proof and STH signature against the provided log SPKI.
93pub fn verify_inclusion(
94    r: &AppendReceipt,
95    leaf: [u8; 32],
96    log_pubkey_spki: &[u8],
97) -> Result<(), Error> {
98    if r.leaf_hash.as_slice() != leaf {
99        return Err(Error::Verify("leaf mismatch".into()));
100    }
101    if r.sth.root_hash.len() != 32 {
102        return Err(Error::Verify("root hash wrong length".into()));
103    }
104
105    // Inclusion path
106    if !verify_inclusion_simple(r.index as usize, leaf, &r.path, r.sth.root_hash.as_slice()) {
107        return Err(Error::Verify("inclusion proof invalid".into()));
108    }
109
110    // STH signature
111    if r.sth.sig_alg != "mldsa-87" {
112        return Err(Error::Verify("unsupported sth sig alg".into()));
113    }
114    let kid = qs_crypto::kid_from_spki_der(log_pubkey_spki);
115    if kid != r.sth.log_id {
116        return Err(Error::Verify("log_id mismatch".into()));
117    }
118    let signed = encode_sth_sig_input(&r.sth).map_err(Error::Verify)?;
119    let digest = sha512(&signed);
120    qs_crypto::verify_mldsa87_spki(log_pubkey_spki, &digest, DigestAlg::Sha512, &r.sth.sig, None)
121        .map_err(|e| Error::Verify(format!("sth sig: {e}")))?
122        ;
123    Ok(())
124}
125
126/// Verify a consistency proof between two STHs.
127/// Verify CT-style consistency proof between two STHs using the log’s hash function (sha256(left||right)).
128pub fn verify_consistency(old: &Sth, new_: &Sth, proof: &[[u8; 32]]) -> Result<(), Error> {
129    if old.tree_size > new_.tree_size {
130        return Err(Error::Verify("tree sizes inverted".into()));
131    }
132    if old.tree_size == new_.tree_size {
133        if old.root_hash == new_.root_hash && proof.is_empty() {
134            return Ok(());
135        } else {
136            return Err(Error::Verify("equal sizes but roots/proof mismatch".into()));
137        }
138    }
139
140    // Algorithm adapted from RFC 6962 Section 2.1.2
141    let mut m = (old.tree_size - 1) as u64;
142    let mut _n = (new_.tree_size - 1) as u64;
143
144    // Skip common right edges
145    let mut i = 1usize;
146    while (m & 1) == 1 {
147        m >>= 1;
148        _n >>= 1;
149    }
150    if proof.is_empty() {
151        return Err(Error::Verify("empty consistency proof".into()));
152    }
153    let mut c_old = proof[0];
154    let mut c_new = proof[0];
155    while i < proof.len() {
156        let p = proof[i];
157        if (m & 1) == 1 {
158            // moving up from a right child; combine sibling with current
159            c_old = sha256_concat(&p, &c_old);
160            c_new = sha256_concat(&p, &c_new);
161            i += 1;
162            while (m & 1) == 0 {
163                m >>= 1;
164                _n >>= 1;
165            }
166        } else {
167            // moving up from a left child for the newer tree only
168            c_new = sha256_concat(&c_new, &p);
169            i += 1;
170        }
171        m >>= 1;
172        _n >>= 1;
173    }
174
175    if c_old.as_slice() != old.root_hash.as_slice() {
176        return Err(Error::Verify("old root mismatch".into()));
177    }
178    if c_new.as_slice() != new_.root_hash.as_slice() {
179        return Err(Error::Verify("new root mismatch".into()));
180    }
181    Ok(())
182}
183
184/// Encode STH fields into a canonical CBOR transcript used for signature verification.
185pub fn encode_sth_sig_input(sth: &Sth) -> Result<Vec<u8>, String> {
186    #[derive(Serialize)]
187    struct S<'a> {
188        v: u8,
189        log_id: &'a str,
190        tree_size: u64,
191        #[serde(with = "serde_bytes")]
192        root_hash: &'a [u8],
193        timestamp: i64,
194    }
195    let s = S {
196        v: sth.v,
197        log_id: &sth.log_id,
198        tree_size: sth.tree_size,
199        root_hash: &sth.root_hash,
200        timestamp: sth.timestamp,
201    };
202    let mut out = Vec::new();
203    ser::into_writer(&s, &mut out).map_err(|e| e.to_string())?;
204    Ok(out)
205}
206
207/// Compute SHA‑512 for STH transcript hashing.
208pub fn sha512(data: &[u8]) -> [u8; 64] {
209    use sha2::{Digest, Sha512};
210    let d = Sha512::digest(data);
211    let mut out = [0u8; 64];
212    out.copy_from_slice(&d);
213    out
214}
215
216fn sha256_concat(left: &[u8], right: &[u8]) -> [u8; 32] {
217    use sha2::{Digest, Sha256};
218    let mut h = Sha256::new();
219    h.update(left);
220    h.update(right);
221    let d = h.finalize();
222    let mut out = [0u8; 32];
223    out.copy_from_slice(&d);
224    out
225}
226
227fn verify_inclusion_simple(index: usize, mut leaf: [u8; 32], path: &[Vec<u8>], root: &[u8]) -> bool {
228    let mut idx = index;
229    for sib in path {
230        if sib.len() != 32 { return false; }
231        if idx % 2 == 0 {
232            leaf = sha256_concat(&leaf, sib);
233        } else {
234            leaf = sha256_concat(sib, &leaf);
235        }
236        idx >>= 1;
237    }
238    leaf.as_slice() == root
239}