Skip to main content

pqc_sig/
types.rs

1//! Wire types for PQC signature public keys, secret keys, and signatures.
2//!
3//! These types are designed for serialization across WASM boundaries and for
4//! use in DID Documents (W3C Decentralized Identifiers).
5//!
6//! All byte arrays are encoded as base64url (no padding) strings in JSON.
7
8extern crate alloc;
9use alloc::{format, string::{String, ToString}, vec::Vec};
10
11use base64ct::{Base64Url, Encoding};
12use serde::{Deserialize, Serialize};
13use zeroize::{Zeroize, ZeroizeOnDrop};
14
15use crate::error::{SigError, SigResult};
16
17// ── Algorithm Identifiers ─────────────────────────────────────────────────────
18
19/// Identifies the post-quantum signature algorithm used.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum SigAlgorithm {
23    // FIPS 204 — ML-DSA (Module Lattice Digital Signature Algorithm)
24    /// ML-DSA-44 (NIST FIPS 204, Security Level 2)
25    MlDsa44,
26    /// ML-DSA-65 (NIST FIPS 204, Security Level 3) — recommended default
27    MlDsa65,
28    /// ML-DSA-87 (NIST FIPS 204, Security Level 5)
29    MlDsa87,
30
31    // FIPS 205 — SLH-DSA (Stateless Hash-Based Digital Signature Algorithm)
32    /// SLH-DSA-SHA2-128s (NIST FIPS 205, Security Level 1, small signatures)
33    SlhDsaSha2_128s,
34    /// SLH-DSA-SHA2-128f (NIST FIPS 205, Security Level 1, fast signing)
35    SlhDsaSha2_128f,
36    /// SLH-DSA-SHA2-192s (NIST FIPS 205, Security Level 3, small signatures)
37    SlhDsaSha2_192s,
38    /// SLH-DSA-SHA2-192f (NIST FIPS 205, Security Level 3, fast signing)
39    SlhDsaSha2_192f,
40    /// SLH-DSA-SHA2-256s (NIST FIPS 205, Security Level 5, small signatures)
41    SlhDsaSha2_256s,
42    /// SLH-DSA-SHA2-256f (NIST FIPS 205, Security Level 5, fast signing)
43    SlhDsaSha2_256f,
44    /// SLH-DSA-SHAKE-128s (NIST FIPS 205, Security Level 1, small signatures)
45    SlhDsaShake128s,
46    /// SLH-DSA-SHAKE-128f (NIST FIPS 205, Security Level 1, fast signing)
47    SlhDsaShake128f,
48    /// SLH-DSA-SHAKE-192s (NIST FIPS 205, Security Level 3, small signatures)
49    SlhDsaShake192s,
50    /// SLH-DSA-SHAKE-192f (NIST FIPS 205, Security Level 3, fast signing)
51    SlhDsaShake192f,
52    /// SLH-DSA-SHAKE-256s (NIST FIPS 205, Security Level 5, small signatures)
53    SlhDsaShake256s,
54    /// SLH-DSA-SHAKE-256f (NIST FIPS 205, Security Level 5, fast signing)
55    SlhDsaShake256f,
56
57    // FIPS 206 — FN-DSA (Falcon)
58    /// FN-DSA-512 (NIST FIPS 206, Security Level 1) — requires `fndsa` feature
59    FnDsa512,
60    /// FN-DSA-1024 (NIST FIPS 206, Security Level 5) — requires `fndsa` feature
61    FnDsa1024,
62}
63
64impl SigAlgorithm {
65    /// Returns the canonical string identifier for this algorithm.
66    pub fn as_str(&self) -> &'static str {
67        match self {
68            SigAlgorithm::MlDsa44           => "ML-DSA-44",
69            SigAlgorithm::MlDsa65           => "ML-DSA-65",
70            SigAlgorithm::MlDsa87           => "ML-DSA-87",
71            SigAlgorithm::SlhDsaSha2_128s   => "SLH-DSA-SHA2-128s",
72            SigAlgorithm::SlhDsaSha2_128f   => "SLH-DSA-SHA2-128f",
73            SigAlgorithm::SlhDsaSha2_192s   => "SLH-DSA-SHA2-192s",
74            SigAlgorithm::SlhDsaSha2_192f   => "SLH-DSA-SHA2-192f",
75            SigAlgorithm::SlhDsaSha2_256s   => "SLH-DSA-SHA2-256s",
76            SigAlgorithm::SlhDsaSha2_256f   => "SLH-DSA-SHA2-256f",
77            SigAlgorithm::SlhDsaShake128s   => "SLH-DSA-SHAKE-128s",
78            SigAlgorithm::SlhDsaShake128f   => "SLH-DSA-SHAKE-128f",
79            SigAlgorithm::SlhDsaShake192s   => "SLH-DSA-SHAKE-192s",
80            SigAlgorithm::SlhDsaShake192f   => "SLH-DSA-SHAKE-192f",
81            SigAlgorithm::SlhDsaShake256s   => "SLH-DSA-SHAKE-256s",
82            SigAlgorithm::SlhDsaShake256f   => "SLH-DSA-SHAKE-256f",
83            SigAlgorithm::FnDsa512          => "FN-DSA-512",
84            SigAlgorithm::FnDsa1024         => "FN-DSA-1024",
85        }
86    }
87
88    /// Returns the public key size in bytes for this algorithm.
89    pub fn public_key_size(&self) -> usize {
90        match self {
91            SigAlgorithm::MlDsa44           => 1312,
92            SigAlgorithm::MlDsa65           => 1952,
93            SigAlgorithm::MlDsa87           => 2592,
94            SigAlgorithm::SlhDsaSha2_128s   => 32,
95            SigAlgorithm::SlhDsaSha2_128f   => 32,
96            SigAlgorithm::SlhDsaSha2_192s   => 48,
97            SigAlgorithm::SlhDsaSha2_192f   => 48,
98            SigAlgorithm::SlhDsaSha2_256s   => 64,
99            SigAlgorithm::SlhDsaSha2_256f   => 64,
100            SigAlgorithm::SlhDsaShake128s   => 32,
101            SigAlgorithm::SlhDsaShake128f   => 32,
102            SigAlgorithm::SlhDsaShake192s   => 48,
103            SigAlgorithm::SlhDsaShake192f   => 48,
104            SigAlgorithm::SlhDsaShake256s   => 64,
105            SigAlgorithm::SlhDsaShake256f   => 64,
106            SigAlgorithm::FnDsa512          => 897,
107            SigAlgorithm::FnDsa1024         => 1793,
108        }
109    }
110
111    /// Returns the secret key size in bytes for this algorithm.
112    ///
113    /// For ML-DSA variants, this returns 32 (the seed size), which is the preferred
114    /// serialization per the `ml-dsa` crate. The full expanded key form (2560/4032/4896 bytes)
115    /// is deprecated in favor of the compact 32-byte seed.
116    ///
117    /// For FN-DSA variants, this is the `fn-dsa` crate's own encoded signing-key format
118    /// (1345/2369 bytes), not the raw 1281/2305-byte NIST secret-key size — implementation-defined,
119    /// like the ML-DSA seed above.
120    pub fn secret_key_size(&self) -> usize {
121        match self {
122            SigAlgorithm::MlDsa44           => 32,
123            SigAlgorithm::MlDsa65           => 32,
124            SigAlgorithm::MlDsa87           => 32,
125            SigAlgorithm::SlhDsaSha2_128s   => 64,
126            SigAlgorithm::SlhDsaSha2_128f   => 64,
127            SigAlgorithm::SlhDsaSha2_192s   => 96,
128            SigAlgorithm::SlhDsaSha2_192f   => 96,
129            SigAlgorithm::SlhDsaSha2_256s   => 128,
130            SigAlgorithm::SlhDsaSha2_256f   => 128,
131            SigAlgorithm::SlhDsaShake128s   => 64,
132            SigAlgorithm::SlhDsaShake128f   => 64,
133            SigAlgorithm::SlhDsaShake192s   => 96,
134            SigAlgorithm::SlhDsaShake192f   => 96,
135            SigAlgorithm::SlhDsaShake256s   => 128,
136            SigAlgorithm::SlhDsaShake256f   => 128,
137            SigAlgorithm::FnDsa512          => 1345,
138            SigAlgorithm::FnDsa1024         => 2369,
139        }
140    }
141
142    /// Returns the maximum signature size in bytes for this algorithm.
143    pub fn signature_size(&self) -> usize {
144        match self {
145            SigAlgorithm::MlDsa44           => 2420,
146            SigAlgorithm::MlDsa65           => 3309,
147            SigAlgorithm::MlDsa87           => 4627,
148            SigAlgorithm::SlhDsaSha2_128s   => 7856,
149            SigAlgorithm::SlhDsaSha2_128f   => 17088,
150            SigAlgorithm::SlhDsaSha2_192s   => 16224,
151            SigAlgorithm::SlhDsaSha2_192f   => 35664,
152            SigAlgorithm::SlhDsaSha2_256s   => 29792,
153            SigAlgorithm::SlhDsaSha2_256f   => 49856,
154            SigAlgorithm::SlhDsaShake128s   => 7856,
155            SigAlgorithm::SlhDsaShake128f   => 17088,
156            SigAlgorithm::SlhDsaShake192s   => 16224,
157            SigAlgorithm::SlhDsaShake192f   => 35664,
158            SigAlgorithm::SlhDsaShake256s   => 29792,
159            SigAlgorithm::SlhDsaShake256f   => 49856,
160            SigAlgorithm::FnDsa512          => 666,
161            SigAlgorithm::FnDsa1024         => 1280,
162        }
163    }
164
165    /// Returns the FIPS standard number for this algorithm.
166    pub fn fips_standard(&self) -> &'static str {
167        match self {
168            SigAlgorithm::MlDsa44 | SigAlgorithm::MlDsa65 | SigAlgorithm::MlDsa87 => "FIPS-204",
169            SigAlgorithm::SlhDsaSha2_128s
170            | SigAlgorithm::SlhDsaSha2_128f
171            | SigAlgorithm::SlhDsaSha2_192s
172            | SigAlgorithm::SlhDsaSha2_192f
173            | SigAlgorithm::SlhDsaSha2_256s
174            | SigAlgorithm::SlhDsaSha2_256f
175            | SigAlgorithm::SlhDsaShake128s
176            | SigAlgorithm::SlhDsaShake128f
177            | SigAlgorithm::SlhDsaShake192s
178            | SigAlgorithm::SlhDsaShake192f
179            | SigAlgorithm::SlhDsaShake256s
180            | SigAlgorithm::SlhDsaShake256f => "FIPS-205",
181            SigAlgorithm::FnDsa512 | SigAlgorithm::FnDsa1024 => "FIPS-206",
182        }
183    }
184
185    /// Returns the [multicodec](https://github.com/multiformats/multicodec) code for this
186    /// algorithm's public key.
187    ///
188    /// ML-DSA and SLH-DSA codes (`0x1210`–`0x122b`) are registered (draft status) in the
189    /// upstream multicodec table. FN-DSA (FIPS 206 / Falcon) has **no upstream-registered**
190    /// multicodec code as of this writing — see [`FN_DSA_PRIVATE_USE_BASE`] for the
191    /// provisional, 0x307-namespaced private-use codes this crate assigns instead, and its
192    /// doc comment for the full rationale, scope, and revisit trigger.
193    pub fn multicodec_code(&self) -> SigResult<u32> {
194        match self {
195            SigAlgorithm::MlDsa44         => Ok(0x1210),
196            SigAlgorithm::MlDsa65         => Ok(0x1211),
197            SigAlgorithm::MlDsa87         => Ok(0x1212),
198            SigAlgorithm::SlhDsaSha2_128s => Ok(0x1220),
199            SigAlgorithm::SlhDsaShake128s => Ok(0x1221),
200            SigAlgorithm::SlhDsaSha2_128f => Ok(0x1222),
201            SigAlgorithm::SlhDsaShake128f => Ok(0x1223),
202            SigAlgorithm::SlhDsaSha2_192s => Ok(0x1224),
203            SigAlgorithm::SlhDsaShake192s => Ok(0x1225),
204            SigAlgorithm::SlhDsaSha2_192f => Ok(0x1226),
205            SigAlgorithm::SlhDsaShake192f => Ok(0x1227),
206            SigAlgorithm::SlhDsaSha2_256s => Ok(0x1228),
207            SigAlgorithm::SlhDsaShake256s => Ok(0x1229),
208            SigAlgorithm::SlhDsaSha2_256f => Ok(0x122a),
209            SigAlgorithm::SlhDsaShake256f => Ok(0x122b),
210            // Provisional, 0x307-reserved private-use codes -- see
211            // `FN_DSA_PRIVATE_USE_BASE` doc comment below for full rationale.
212            SigAlgorithm::FnDsa512  => Ok(FN_DSA_PRIVATE_USE_BASE),
213            SigAlgorithm::FnDsa1024 => Ok(FN_DSA_PRIVATE_USE_BASE + 1),
214        }
215    }
216
217    /// Returns `true` if this algorithm's [`multicodec_code`](Self::multicodec_code) is one of
218    /// this crate's own provisional private-use reservations (currently just FN-DSA), rather
219    /// than an upstream-registered multiformats/multicodec table entry.
220    ///
221    /// Useful for callers who want to warn, log, or otherwise flag when a DID Document /
222    /// Multikey they're producing embeds a non-standard code that other multicodec
223    /// implementations will not recognize.
224    pub fn is_private_use_multicodec(&self) -> bool {
225        matches!(self, SigAlgorithm::FnDsa512 | SigAlgorithm::FnDsa1024)
226    }
227}
228
229/// Base of the **provisional, `0x307`-reserved private-use multicodec range** used for
230/// FN-DSA (FIPS 206 / Falcon), because [multiformats/multicodec] has no registered code for
231/// FN-DSA/Falcon as of this writing (checked against the live table during the P2/alpha-001
232/// implementation pass — no PR, issue, or merged entry exists for FN-DSA/Falcon at either
233/// `0x1213` (the next sequential slot after ML-DSA's `0x1210`-`0x1212` block) or elsewhere).
234///
235/// [multiformats/multicodec]: https://github.com/multiformats/multicodec
236///
237/// # Decision (per project owner: "reserve and don't block upstream")
238///
239/// Rather than leaving `to_multibase()`/`from_multibase()` permanently erroring for FN-DSA
240/// until an upstream PR lands (outside 0x307's control, no committed timeline), this crate
241/// provisionally reserves its own private-use block:
242///
243/// - **`0x307000`** — `FN-DSA-512` (`SigAlgorithm::FnDsa512`)
244/// - **`0x307001`** — `FN-DSA-1024` (`SigAlgorithm::FnDsa1024`)
245///
246/// `0x307000` is deliberately namespaced after this organization (`0x307`) rather than picked
247/// arbitrarily, so a code walking a DID Document that hits an unrecognized `0x307xxx`
248/// multicodec prefix has an immediate, greppable clue about its provenance. The
249/// `0x307000`-`0x3070ff` block (256 codes) is reserved for this purpose, of which only the
250/// first two are in use today — headroom for any future 0x307-authored algorithm that hits
251/// the same "real, no upstream multicodec yet" situation FN-DSA is in now.
252///
253/// # This is intentionally *not* a claim about the officially blessed "Private Use Area"
254///
255/// The multicodec table's own header comments describe a reserved application-specific /
256/// private-use block in the `0x300000`-`0x3fffff` range, and `0x307000` happens to fall
257/// inside it — which is a reasonable coincidence given `0x307`'s own numeric name, not proof
258/// this crate re-verified that exact boundary byte-for-byte against the live `table.csv`.
259/// **Before this crate's FN-DSA Multikey output is ever consumed by a system this
260/// organization does not control, re-confirm `0x307000`-`0x3070ff` against the current
261/// upstream table** (or complete the real registration PR, which supersedes this reservation
262/// entirely — see the revisit trigger below).
263///
264/// # Consequences and caveats
265///
266/// - **Interop scope: 0x307-controlled systems only.** Any multibase-encoded FN-DSA public
267///   key produced by [`SigPublicKey::to_multibase`] carries this provisional code. A
268///   generic/independent multicodec-table-driven decoder (one that doesn't know about
269///   `0x307000`/`0x307001` specifically) will not recognize it as FN-DSA — it isn't in the
270///   canonical table. This is fine for internal SAGP DID documents (the actual, current use
271///   case — see `docs/pqc/migration.md`'s FN-DSA-as-compact-signature design), but it is
272///   **not** a substitute for real interop with third-party multicodec/DID tooling.
273/// - **This is a breaking change, shipped as one.** `multicodec_code()` previously returned
274///   `Err(..)` for FN-DSA; `STABILITY.md` §2 lists "changing documented behavior... including
275///   error-vs-success outcomes" as breaking, explicitly and without a doc-comment-worded-it-as-
276///   provisional exception. It ships in a minor version bump alongside another, independently
277///   breaking change in this same release — see `CHANGELOG.md`'s migration note.
278/// - **Revisit trigger:** if/when multiformats/multicodec registers an official code for
279///   FN-DSA/Falcon, migrate `multicodec_code()` to that value in a follow-up **minor**
280///   release (changing an already-provisional, explicitly-flagged-non-final code is not the
281///   same kind of break as changing an already-stable one — but it does still change the
282///   wire bytes of anything encoded with `0x307000`/`0x307001` in the interim, so it must
283///   still carry a `CHANGELOG.md` migration note per `STABILITY.md`, and any 0x307-internal
284///   system that persisted FN-DSA Multikeys before that point needs a re-encode pass).
285pub const FN_DSA_PRIVATE_USE_BASE: u32 = 0x307000;
286
287/// Encode a multicodec code as an unsigned varint (LEB128, per the
288/// [multiformats unsigned-varint spec](https://github.com/multiformats/unsigned-varint)).
289fn encode_varint(mut code: u32) -> Vec<u8> {
290    let mut out = Vec::with_capacity(2);
291    loop {
292        let mut byte = (code & 0x7f) as u8;
293        code >>= 7;
294        if code != 0 {
295            byte |= 0x80;
296        }
297        out.push(byte);
298        if code == 0 {
299            break;
300        }
301    }
302    out
303}
304
305/// Decode an unsigned varint from the start of `bytes`, returning the decoded value and the
306/// number of bytes consumed.
307fn decode_varint(bytes: &[u8]) -> SigResult<(u32, usize)> {
308    let mut value: u32 = 0;
309    for (i, &byte) in bytes.iter().enumerate() {
310        if i >= 5 {
311            return Err(SigError::InvalidPublicKey("multicodec varint prefix too long".into()));
312        }
313        value |= ((byte & 0x7f) as u32) << (7 * i);
314        if byte & 0x80 == 0 {
315            return Ok((value, i + 1));
316        }
317    }
318    Err(SigError::InvalidPublicKey("truncated multicodec varint prefix".into()))
319}
320
321impl core::fmt::Display for SigAlgorithm {
322    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
323        write!(f, "{}", self.as_str())
324    }
325}
326
327// ── Public Key ────────────────────────────────────────────────────────────────
328
329/// A post-quantum public key with its algorithm tag.
330///
331/// Public keys are safe to distribute and do not require zeroization.
332#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
333pub struct SigPublicKey {
334    /// The algorithm this key belongs to.
335    pub algorithm: SigAlgorithm,
336    /// Raw public key bytes.
337    #[serde(
338        serialize_with   = "serialize_bytes_base64url",
339        deserialize_with = "deserialize_bytes_base64url"
340    )]
341    pub bytes: Vec<u8>,
342}
343
344impl SigPublicKey {
345    /// Create a new public key from raw bytes.
346    pub fn new(algorithm: SigAlgorithm, bytes: Vec<u8>) -> Self {
347        Self { algorithm, bytes }
348    }
349
350    /// Returns the raw bytes of the public key.
351    pub fn as_bytes(&self) -> &[u8] {
352        &self.bytes
353    }
354
355    /// Encode the public key bytes as base64url (no padding).
356    pub fn to_base64url(&self) -> String {
357        Base64Url::encode_string(&self.bytes)
358    }
359
360    /// Decode a public key from base64url bytes.
361    pub fn from_base64url(algorithm: SigAlgorithm, encoded: &str) -> SigResult<Self> {
362        let bytes = Base64Url::decode_vec(encoded)
363            .map_err(|e| SigError::Base64Decode(e.to_string()))?;
364        Ok(Self { algorithm, bytes })
365    }
366
367    /// Encode as a [W3C Multikey](https://www.w3.org/TR/controller-document/#multikey):
368    /// a multicodec-prefixed key, multibase-encoded as base58btc with a 'z' prefix.
369    ///
370    /// Works for all 17 algorithms this crate supports, including FN-DSA — which uses a
371    /// provisional, `0x307`-reserved private-use multicodec code rather than an
372    /// upstream-registered one; see [`FN_DSA_PRIVATE_USE_BASE`] for the full rationale and
373    /// interop scope. In practice this only returns `Err` for malformed/mismatched input, not
374    /// for any currently-defined [`SigAlgorithm`] variant.
375    pub fn to_multibase(&self) -> SigResult<String> {
376        let code = self.algorithm.multicodec_code()?;
377        let mut prefixed = encode_varint(code);
378        prefixed.extend_from_slice(&self.bytes);
379
380        let mut out = String::with_capacity(prefixed.len() * 2);
381        out.push('z');
382        out.push_str(&bs58::encode(&prefixed).into_string());
383        Ok(out)
384    }
385
386    /// Decode from a [W3C Multikey](https://www.w3.org/TR/controller-document/#multikey)
387    /// (base58btc with 'z' prefix). Verifies the embedded multicodec code matches `algorithm`.
388    pub fn from_multibase(algorithm: SigAlgorithm, multibase: &str) -> SigResult<Self> {
389        let expected_code = algorithm.multicodec_code()?;
390
391        let stripped = multibase.strip_prefix('z').ok_or_else(|| {
392            SigError::InvalidPublicKey("multibase must start with 'z' (base58btc)".into())
393        })?;
394        let prefixed = bs58::decode(stripped)
395            .into_vec()
396            .map_err(|e| SigError::InvalidPublicKey(format!("base58 decode: {}", e)))?;
397
398        let (code, prefix_len) = decode_varint(&prefixed)?;
399        if code != expected_code {
400            return Err(SigError::InvalidPublicKey(format!(
401                "multicodec code 0x{:x} does not match expected code 0x{:x} for {}",
402                code, expected_code, algorithm.as_str()
403            )));
404        }
405
406        let bytes = prefixed[prefix_len..].to_vec();
407        Ok(Self { algorithm, bytes })
408    }
409
410    /// Serialize to JSON string.
411    pub fn to_json(&self) -> SigResult<String> {
412        serde_json::to_string(self)
413            .map_err(|e| SigError::Serialization(e.to_string()))
414    }
415
416    /// Deserialize from JSON string.
417    pub fn from_json(json: &str) -> SigResult<Self> {
418        serde_json::from_str(json)
419            .map_err(|e| SigError::Serialization(e.to_string()))
420    }
421}
422
423// ── Secret Key ────────────────────────────────────────────────────────────────
424
425/// A post-quantum secret key with its algorithm tag. Zeroized on drop.
426#[derive(Clone, Zeroize, ZeroizeOnDrop)]
427pub struct SigSecretKey {
428    /// The algorithm this key belongs to.
429    /// Skipped during zeroization — `SigAlgorithm` is a plain enum with no secret data.
430    #[zeroize(skip)]
431    pub algorithm: SigAlgorithm,
432    /// Raw secret key bytes (zeroized on drop).
433    pub bytes: Vec<u8>,
434}
435
436impl core::fmt::Debug for SigSecretKey {
437    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
438        write!(f, "SigSecretKey {{ algorithm: {:?}, bytes: [REDACTED {} bytes] }}", self.algorithm, self.bytes.len())
439    }
440}
441
442impl SigSecretKey {
443    /// Create a new secret key from raw bytes.
444    pub fn new(algorithm: SigAlgorithm, bytes: Vec<u8>) -> Self {
445        Self { algorithm, bytes }
446    }
447
448    /// Returns the raw bytes of the secret key.
449    pub fn as_bytes(&self) -> &[u8] {
450        &self.bytes
451    }
452
453    /// Encode the secret key bytes as base64url (no padding).
454    pub fn to_base64url(&self) -> String {
455        Base64Url::encode_string(&self.bytes)
456    }
457
458    /// Decode a secret key from base64url bytes.
459    pub fn from_base64url(algorithm: SigAlgorithm, encoded: &str) -> SigResult<Self> {
460        let bytes = Base64Url::decode_vec(encoded)
461            .map_err(|e| SigError::Base64Decode(e.to_string()))?;
462        Ok(Self { algorithm, bytes })
463    }
464}
465
466// ── Signature ─────────────────────────────────────────────────────────────────
467
468/// A post-quantum signature with its algorithm tag.
469#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
470pub struct Signature {
471    /// The algorithm used to produce this signature.
472    pub algorithm: SigAlgorithm,
473    /// Raw signature bytes.
474    #[serde(
475        serialize_with   = "serialize_bytes_base64url",
476        deserialize_with = "deserialize_bytes_base64url"
477    )]
478    pub bytes: Vec<u8>,
479}
480
481impl Signature {
482    /// Create a new signature from raw bytes.
483    pub fn new(algorithm: SigAlgorithm, bytes: Vec<u8>) -> Self {
484        Self { algorithm, bytes }
485    }
486
487    /// Returns the raw bytes of the signature.
488    pub fn as_bytes(&self) -> &[u8] {
489        &self.bytes
490    }
491
492    /// Encode the signature bytes as base64url (no padding).
493    pub fn to_base64url(&self) -> String {
494        Base64Url::encode_string(&self.bytes)
495    }
496
497    /// Decode a signature from base64url bytes.
498    pub fn from_base64url(algorithm: SigAlgorithm, encoded: &str) -> SigResult<Self> {
499        let bytes = Base64Url::decode_vec(encoded)
500            .map_err(|e| SigError::Base64Decode(e.to_string()))?;
501        Ok(Self { algorithm, bytes })
502    }
503
504    /// Serialize to JSON string (for WASM boundary crossing).
505    pub fn to_json(&self) -> SigResult<String> {
506        serde_json::to_string(self)
507            .map_err(|e| SigError::Serialization(e.to_string()))
508    }
509
510    /// Deserialize from JSON string.
511    pub fn from_json(json: &str) -> SigResult<Self> {
512        serde_json::from_str(json)
513            .map_err(|e| SigError::Serialization(e.to_string()))
514    }
515}
516
517// ── Signed Message Envelope ───────────────────────────────────────────────────
518
519/// A signed message envelope — bundles the message, signature, and public key together.
520///
521/// Useful for self-contained attestation payloads that can be verified without
522/// any external context.
523#[derive(Debug, Clone, Serialize, Deserialize)]
524pub struct SignedMessage {
525    /// The original message bytes (base64url encoded in JSON).
526    #[serde(
527        serialize_with   = "serialize_bytes_base64url",
528        deserialize_with = "deserialize_bytes_base64url"
529    )]
530    pub message: Vec<u8>,
531    /// The signature over the message.
532    pub signature: Signature,
533    /// The public key that produced the signature.
534    pub public_key: SigPublicKey,
535    /// Algorithm identifier string (e.g. "ML-DSA-65").
536    pub algorithm: String,
537}
538
539impl SignedMessage {
540    /// Create a new signed message envelope.
541    pub fn new(message: Vec<u8>, signature: Signature, public_key: SigPublicKey) -> Self {
542        let algorithm = signature.algorithm.as_str().to_string();
543        Self { message, signature, public_key, algorithm }
544    }
545
546    /// Serialize to JSON string.
547    pub fn to_json(&self) -> SigResult<String> {
548        serde_json::to_string(self)
549            .map_err(|e| SigError::Serialization(e.to_string()))
550    }
551
552    /// Deserialize from JSON string.
553    pub fn from_json(json: &str) -> SigResult<Self> {
554        serde_json::from_str(json)
555            .map_err(|e| SigError::Serialization(e.to_string()))
556    }
557}
558
559// ── Serde Helpers ─────────────────────────────────────────────────────────────
560
561pub(crate) fn serialize_bytes_base64url<S>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error>
562where
563    S: serde::Serializer,
564{
565    serializer.serialize_str(&Base64Url::encode_string(bytes))
566}
567
568pub(crate) fn deserialize_bytes_base64url<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
569where
570    D: serde::Deserializer<'de>,
571{
572    let s = String::deserialize(deserializer)?;
573    Base64Url::decode_vec(&s).map_err(serde::de::Error::custom)
574}