qs_format/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Canonical `.qsig` detached signature container encoding.
4//! Uses a fixed-position CBOR array to avoid map-ordering pitfalls.
5
6use ciborium::{de, ser};
7use serde::{Deserialize, Serialize};
8use serde_bytes::ByteBuf;
9use serde_tuple::{Deserialize_tuple, Serialize_tuple};
10use std::io;
11
12/// Errors returned by encoding or decoding a `.qsig` structure.
13#[derive(Debug, thiserror::Error)]
14pub enum Error {
15    #[error("CBOR encode error: {0}")]
16    Encode(String),
17    #[error("CBOR decode error: {0}")]
18    Decode(String),
19    #[error("I/O error: {0}")]
20    Io(#[from] io::Error),
21}
22
23/// Additional metadata about the signer (key identifier + optional opaque claims blob).
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct Signer {
26    pub kid: String,
27    #[serde(with = "serde_bytes")]
28    #[serde(default)]
29    pub claims: Vec<u8>,
30}
31
32/// Transparency log proof bundle (RFC 6962-style).
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct Transparency {
35    #[serde(with = "serde_bytes")]
36    pub root: Vec<u8>,
37    #[serde(with = "serde_bytes")]
38    pub proof: Vec<u8>,
39}
40
41/// Canonical `.qsig` representation. All fields have a fixed position.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize_tuple, Deserialize_tuple)]
43pub struct QSig {
44    pub version: u8,
45    pub alg: String,
46    #[serde(with = "serde_bytes")]
47    pub digest: Vec<u8>,
48    #[serde(with = "serde_bytes")]
49    pub sig: Vec<u8>,
50    pub co_sig: Vec<ByteBuf>,
51    pub tst: Option<ByteBuf>,
52    pub tlog: Option<Transparency>,
53    pub signer: Signer,
54    pub time_unix: i64,
55    pub domain_sep: String,
56    #[serde(default)]
57    pub meta: Option<ByteBuf>,
58}
59
60impl QSig {
61    /// Serialize to canonical CBOR bytes.
62    pub fn encode_to_vec(&self) -> Result<Vec<u8>, Error> {
63        let mut buf = Vec::new();
64        ser::into_writer(self, &mut buf).map_err(|e| Error::Encode(e.to_string()))?;
65        Ok(buf)
66    }
67
68    /// Deserialize from CBOR bytes.
69    pub fn decode(bytes: &[u8]) -> Result<Self, Error> {
70        de::from_reader(bytes).map_err(|e| Error::Decode(e.to_string()))
71    }
72
73    /// Convenience constructor from raw field values.
74    #[allow(clippy::too_many_arguments)]
75    pub fn new(
76        version: u8,
77        alg: String,
78        digest: Vec<u8>,
79        sig: Vec<u8>,
80        co_sig: Vec<Vec<u8>>,
81        tst: Option<Vec<u8>>,
82        tlog: Option<Transparency>,
83        signer: Signer,
84        time_unix: i64,
85        domain_sep: String,
86        meta: Option<Vec<u8>>,
87    ) -> Self {
88        Self {
89            version,
90            alg,
91            digest,
92            sig,
93            co_sig: co_sig.into_iter().map(ByteBuf::from).collect(),
94            tst: tst.map(ByteBuf::from),
95            tlog,
96            signer,
97            time_unix,
98            domain_sep,
99            meta: meta.map(ByteBuf::from),
100        }
101    }
102
103    /// Access optional timestamp token as bytes.
104    pub fn timestamp(&self) -> Option<&[u8]> {
105        self.tst.as_ref().map(ByteBuf::as_ref)
106    }
107
108    /// Iterate over co-signature blobs as raw byte slices.
109    pub fn co_signatures(&self) -> impl Iterator<Item = &[u8]> {
110        self.co_sig.iter().map(ByteBuf::as_ref)
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use sha2::{Digest, Sha256};
118
119    fn sample_qsig() -> QSig {
120        let signer = Signer {
121            kid: "kid-example".into(),
122            claims: vec![0x01, 0x02],
123        };
124        QSig {
125            version: 1,
126            alg: "mldsa-87".into(),
127            digest: vec![0xAA; 32],
128            sig: vec![0xBB; 24],
129            co_sig: vec![ByteBuf::from(vec![0xCC; 10])],
130            tst: Some(ByteBuf::from(vec![0xDD; 8])),
131            tlog: Some(Transparency {
132                root: vec![0xEE; 32],
133                proof: vec![0xFF; 64],
134            }),
135            signer,
136            time_unix: 1_700_000_000,
137            domain_sep: "quantum-sign-v1".into(),
138            meta: Some(ByteBuf::from(vec![0x11, 0x22])),
139        }
140    }
141
142    #[test]
143    fn round_trip() {
144        let qsig = sample_qsig();
145        let encoded = qsig.encode_to_vec().expect("encode");
146        let decoded = QSig::decode(&encoded).expect("decode");
147        assert_eq!(qsig, decoded);
148        assert_eq!(decoded.timestamp().unwrap(), &[0xDD; 8]);
149        assert_eq!(decoded.co_signatures().count(), 1);
150    }
151
152    #[test]
153    fn golden_sha256_digest() {
154        const GOLDEN_DIGEST: &str =
155            "877519a8e00897c7214f9393bea0462f13eee190bdef1b952c432bc121560374";
156
157        let qsig = sample_qsig();
158        let encoded = qsig.encode_to_vec().expect("encode");
159        let digest = Sha256::digest(&encoded);
160        let digest_hex: String = digest.iter().map(|b| format!("{:02x}", b)).collect();
161        assert_eq!(digest_hex, GOLDEN_DIGEST);
162    }
163}