qs-format 0.1.0

CBOR signature format for Quantum-Sign - The Digital Notary Stamp
Documentation
#![forbid(unsafe_code)]

//! Canonical `.qsig` detached signature container encoding.
//! Uses a fixed-position CBOR array to avoid map-ordering pitfalls.

use ciborium::{de, ser};
use serde::{Deserialize, Serialize};
use serde_bytes::ByteBuf;
use serde_tuple::{Deserialize_tuple, Serialize_tuple};
use std::io;

/// Errors returned by encoding or decoding a `.qsig` structure.
#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("CBOR encode error: {0}")]
    Encode(String),
    #[error("CBOR decode error: {0}")]
    Decode(String),
    #[error("I/O error: {0}")]
    Io(#[from] io::Error),
}

/// Additional metadata about the signer (key identifier + optional opaque claims blob).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Signer {
    pub kid: String,
    #[serde(with = "serde_bytes")]
    #[serde(default)]
    pub claims: Vec<u8>,
}

/// Transparency log proof bundle (RFC 6962-style).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Transparency {
    #[serde(with = "serde_bytes")]
    pub root: Vec<u8>,
    #[serde(with = "serde_bytes")]
    pub proof: Vec<u8>,
}

/// Canonical `.qsig` representation. All fields have a fixed position.
#[derive(Debug, Clone, PartialEq, Eq, Serialize_tuple, Deserialize_tuple)]
pub struct QSig {
    pub version: u8,
    pub alg: String,
    #[serde(with = "serde_bytes")]
    pub digest: Vec<u8>,
    #[serde(with = "serde_bytes")]
    pub sig: Vec<u8>,
    pub co_sig: Vec<ByteBuf>,
    pub tst: Option<ByteBuf>,
    pub tlog: Option<Transparency>,
    pub signer: Signer,
    pub time_unix: i64,
    pub domain_sep: String,
    #[serde(default)]
    pub meta: Option<ByteBuf>,
}

impl QSig {
    /// Serialize to canonical CBOR bytes.
    pub fn encode_to_vec(&self) -> Result<Vec<u8>, Error> {
        let mut buf = Vec::new();
        ser::into_writer(self, &mut buf).map_err(|e| Error::Encode(e.to_string()))?;
        Ok(buf)
    }

    /// Deserialize from CBOR bytes.
    pub fn decode(bytes: &[u8]) -> Result<Self, Error> {
        de::from_reader(bytes).map_err(|e| Error::Decode(e.to_string()))
    }

    /// Convenience constructor from raw field values.
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        version: u8,
        alg: String,
        digest: Vec<u8>,
        sig: Vec<u8>,
        co_sig: Vec<Vec<u8>>,
        tst: Option<Vec<u8>>,
        tlog: Option<Transparency>,
        signer: Signer,
        time_unix: i64,
        domain_sep: String,
        meta: Option<Vec<u8>>,
    ) -> Self {
        Self {
            version,
            alg,
            digest,
            sig,
            co_sig: co_sig.into_iter().map(ByteBuf::from).collect(),
            tst: tst.map(ByteBuf::from),
            tlog,
            signer,
            time_unix,
            domain_sep,
            meta: meta.map(ByteBuf::from),
        }
    }

    /// Access optional timestamp token as bytes.
    pub fn timestamp(&self) -> Option<&[u8]> {
        self.tst.as_ref().map(ByteBuf::as_ref)
    }

    /// Iterate over co-signature blobs as raw byte slices.
    pub fn co_signatures(&self) -> impl Iterator<Item = &[u8]> {
        self.co_sig.iter().map(ByteBuf::as_ref)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use sha2::{Digest, Sha256};

    fn sample_qsig() -> QSig {
        let signer = Signer {
            kid: "kid-example".into(),
            claims: vec![0x01, 0x02],
        };
        QSig {
            version: 1,
            alg: "mldsa-87".into(),
            digest: vec![0xAA; 32],
            sig: vec![0xBB; 24],
            co_sig: vec![ByteBuf::from(vec![0xCC; 10])],
            tst: Some(ByteBuf::from(vec![0xDD; 8])),
            tlog: Some(Transparency {
                root: vec![0xEE; 32],
                proof: vec![0xFF; 64],
            }),
            signer,
            time_unix: 1_700_000_000,
            domain_sep: "quantum-sign-v1".into(),
            meta: Some(ByteBuf::from(vec![0x11, 0x22])),
        }
    }

    #[test]
    fn round_trip() {
        let qsig = sample_qsig();
        let encoded = qsig.encode_to_vec().expect("encode");
        let decoded = QSig::decode(&encoded).expect("decode");
        assert_eq!(qsig, decoded);
        assert_eq!(decoded.timestamp().unwrap(), &[0xDD; 8]);
        assert_eq!(decoded.co_signatures().count(), 1);
    }

    #[test]
    fn golden_sha256_digest() {
        const GOLDEN_DIGEST: &str =
            "877519a8e00897c7214f9393bea0462f13eee190bdef1b952c432bc121560374";

        let qsig = sample_qsig();
        let encoded = qsig.encode_to_vec().expect("encode");
        let digest = Sha256::digest(&encoded);
        let digest_hex: String = digest.iter().map(|b| format!("{:02x}", b)).collect();
        assert_eq!(digest_hex, GOLDEN_DIGEST);
    }
}