quantum-sign 0.1.7

Quantum-Sign: post-quantum signatures, format, policy, and CLI in one crate
Documentation
//! CT-style Merkle transparency log (client + verification).
#![forbid(unsafe_code)]
#![deny(missing_docs)]

use ciborium::{de, ser};
// Local Merkle verification helpers (binary tree, parent = sha256(left||right))
use serde::{Deserialize, Serialize};
use std::fmt;

use crate::crypto::{self as qs_crypto, DigestAlg};

/// Errors returned by transparency operations.
#[derive(Debug)]
pub enum Error {
    /// HTTP error (non-200 or network failure)
    Http(String),
    /// CBOR encoding/decoding error
    Cbor(String),
    /// Verification error (proofs, signatures)
    Verify(String),
}

impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Error::Http(e) => write!(f, "http: {e}"),
            Error::Cbor(e) => write!(f, "cbor: {e}"),
            Error::Verify(e) => write!(f, "verify: {e}"),
        }
    }
}

impl std::error::Error for Error {}

/// Signed Tree Head (STH) metadata signed by the log.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Sth {
    /// Version of the STH structure.
    pub v: u8,
    /// Log identifier (KID hex derived from SPKI SHA-256 prefix).
    pub log_id: String,
    /// Total number of leaves included in this tree snapshot.
    pub tree_size: u64,
    /// Root hash of the Merkle tree for `tree_size` leaves.
    #[serde(with = "serde_bytes")]
    pub root_hash: Vec<u8>,
    /// Unix timestamp when this STH was produced.
    pub timestamp: i64,
    /// Signature algorithm identifier (e.g., "mldsa-87").
    pub sig_alg: String,
    /// Detached signature bytes over canonical STH transcript.
    #[serde(with = "serde_bytes")]
    pub sig: Vec<u8>,
}

/// Receipt containing inclusion proof for a given leaf.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AppendReceipt {
    /// Leaf hash (sha256 of canonical QSig core view)
    #[serde(with = "serde_bytes")]
    pub leaf_hash: Vec<u8>,
    /// 0-based leaf index within the tree.
    pub index: u64,
    /// Sibling nodes bottom-up from leaf to root.
    pub path: Vec<Vec<u8>>,
    /// Signed Tree Head snapshot after append.
    pub sth: Sth,
}

/// Append a leaf to the transparency log and return a receipt.
pub fn append(base_url: &str, leaf: [u8; 32]) -> Result<AppendReceipt, Error> {
    #[derive(Serialize)]
    struct Req<'a> {
        #[serde(with = "serde_bytes")]
        leaf_hash: &'a [u8],
    }
    let mut body = Vec::new();
    let req = Req { leaf_hash: &leaf };
    ser::into_writer(&req, &mut body).map_err(|e| Error::Cbor(e.to_string()))?;
    let url = format!("{}/v1/append", base_url.trim_end_matches('/'));
    let mut res = ureq::post(&url)
        .content_type("application/cbor")
        .send(body.as_slice())
        .map_err(|e| Error::Http(e.to_string()))?;
    let bytes = res
        .body_mut()
        .read_to_vec()
        .map_err(|e| Error::Http(e.to_string()))?;
    de::from_reader(bytes.as_slice()).map_err(|e| Error::Cbor(e.to_string()))
}

/// Verify an inclusion proof and STH signature against the provided log SPKI.
pub fn verify_inclusion(
    r: &AppendReceipt,
    leaf: [u8; 32],
    log_pubkey_spki: &[u8],
) -> Result<(), Error> {
    if r.leaf_hash.as_slice() != leaf {
        return Err(Error::Verify("leaf mismatch".into()));
    }
    if r.sth.root_hash.len() != 32 {
        return Err(Error::Verify("root hash wrong length".into()));
    }

    // Inclusion path
    if !verify_inclusion_simple(r.index as usize, leaf, &r.path, r.sth.root_hash.as_slice()) {
        return Err(Error::Verify("inclusion proof invalid".into()));
    }

    // STH signature
    if r.sth.sig_alg != "mldsa-87" {
        return Err(Error::Verify("unsupported sth sig alg".into()));
    }
    let kid = qs_crypto::kid_from_spki_der(log_pubkey_spki);
    if kid != r.sth.log_id {
        return Err(Error::Verify("log_id mismatch".into()));
    }
    let signed = encode_sth_sig_input(&r.sth).map_err(Error::Verify)?;
    let digest = sha512(&signed);
    qs_crypto::verify_mldsa87_spki(log_pubkey_spki, &digest, DigestAlg::Sha512, &r.sth.sig, None)
        .map_err(|e| Error::Verify(format!("sth sig: {e}")))?
        ;
    Ok(())
}

/// Verify a consistency proof between two STHs.
/// Verify CT-style consistency proof between two STHs using the log’s hash function (sha256(left||right)).
pub fn verify_consistency(old: &Sth, new_: &Sth, proof: &[[u8; 32]]) -> Result<(), Error> {
    if old.tree_size > new_.tree_size {
        return Err(Error::Verify("tree sizes inverted".into()));
    }
    if old.tree_size == new_.tree_size {
        if old.root_hash == new_.root_hash && proof.is_empty() {
            return Ok(());
        } else {
            return Err(Error::Verify("equal sizes but roots/proof mismatch".into()));
        }
    }

    // Algorithm adapted from RFC 6962 Section 2.1.2
    let mut m = (old.tree_size - 1) as u64;
    let mut _n = (new_.tree_size - 1) as u64;

    // Skip common right edges
    let mut i = 1usize;
    while (m & 1) == 1 {
        m >>= 1;
        _n >>= 1;
    }
    if proof.is_empty() {
        return Err(Error::Verify("empty consistency proof".into()));
    }
    let mut c_old = proof[0];
    let mut c_new = proof[0];
    while i < proof.len() {
        let p = proof[i];
        if (m & 1) == 1 {
            // moving up from a right child; combine sibling with current
            c_old = sha256_concat(&p, &c_old);
            c_new = sha256_concat(&p, &c_new);
            i += 1;
            while (m & 1) == 0 {
                m >>= 1;
                _n >>= 1;
            }
        } else {
            // moving up from a left child for the newer tree only
            c_new = sha256_concat(&c_new, &p);
            i += 1;
        }
        m >>= 1;
        _n >>= 1;
    }

    if c_old.as_slice() != old.root_hash.as_slice() {
        return Err(Error::Verify("old root mismatch".into()));
    }
    if c_new.as_slice() != new_.root_hash.as_slice() {
        return Err(Error::Verify("new root mismatch".into()));
    }
    Ok(())
}

/// Encode STH fields into a canonical CBOR transcript used for signature verification.
pub fn encode_sth_sig_input(sth: &Sth) -> Result<Vec<u8>, String> {
    #[derive(Serialize)]
    struct S<'a> {
        v: u8,
        log_id: &'a str,
        tree_size: u64,
        #[serde(with = "serde_bytes")]
        root_hash: &'a [u8],
        timestamp: i64,
    }
    let s = S {
        v: sth.v,
        log_id: &sth.log_id,
        tree_size: sth.tree_size,
        root_hash: &sth.root_hash,
        timestamp: sth.timestamp,
    };
    let mut out = Vec::new();
    ser::into_writer(&s, &mut out).map_err(|e| e.to_string())?;
    Ok(out)
}

/// Compute SHA‑512 for STH transcript hashing.
pub fn sha512(data: &[u8]) -> [u8; 64] {
    use sha2::{Digest, Sha512};
    let d = Sha512::digest(data);
    let mut out = [0u8; 64];
    out.copy_from_slice(&d);
    out
}

fn sha256_concat(left: &[u8], right: &[u8]) -> [u8; 32] {
    use sha2::{Digest, Sha256};
    let mut h = Sha256::new();
    h.update(left);
    h.update(right);
    let d = h.finalize();
    let mut out = [0u8; 32];
    out.copy_from_slice(&d);
    out
}

fn verify_inclusion_simple(index: usize, mut leaf: [u8; 32], path: &[Vec<u8>], root: &[u8]) -> bool {
    let mut idx = index;
    for sib in path {
        if sib.len() != 32 { return false; }
        if idx % 2 == 0 {
            leaf = sha256_concat(&leaf, sib);
        } else {
            leaf = sha256_concat(sib, &leaf);
        }
        idx >>= 1;
    }
    leaf.as_slice() == root
}