Skip to main content

pqc_kem/
types.rs

1//! Wire types for KEM public keys, ciphertexts, and shared secrets.
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::{KemError, KemResult};
16
17// ── Algorithm Identifiers ─────────────────────────────────────────────────────
18
19/// Identifies the KEM algorithm used.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum KemAlgorithm {
23    /// ML-KEM-512 (NIST FIPS 203, Security Level 1)
24    MlKem512,
25    /// ML-KEM-768 (NIST FIPS 203, Security Level 3) — recommended default
26    MlKem768,
27    /// ML-KEM-1024 (NIST FIPS 203, Security Level 5)
28    MlKem1024,
29    /// Hybrid: X25519 + ML-KEM-768 — primary construction (profile v1,
30    /// `HYBRID_PROFILE_V1`; see `docs/hybrid-profiles.md`)
31    HybridX25519MlKem768,
32    /// X-Wing: X25519 + ML-KEM-768, per `draft-connolly-cfrg-xwing-kem`
33    /// (profile v2, `HYBRID_PROFILE_V2`) — same component KEMs as
34    /// [`KemAlgorithm::HybridX25519MlKem768`], different combiner and byte
35    /// layout (ML-KEM first). Recommended for new deployments; see
36    /// `docs/hybrid-profiles.md`. Added 0.3.0.
37    XWing,
38    /// HQC-128 (NIST 2025, Security Level 1) — requires `hqc` feature
39    Hqc128,
40    /// HQC-192 (NIST 2025, Security Level 3) — requires `hqc` feature
41    Hqc192,
42    /// HQC-256 (NIST 2025, Security Level 5) — requires `hqc` feature
43    Hqc256,
44    /// BIKE (NIST Round 4 alternate) — **never implemented in this crate**.
45    ///
46    /// The `bike` feature and `src/bike` module (a permanent
47    /// `compile_error!` stub) were removed entirely in 0.3.0. This variant
48    /// is retained only so previously-serialized values (e.g.
49    /// `"bike"` in JSON) continue to deserialize without error; it carries
50    /// no working implementation in any release and never did. Scheduled
51    /// for removal in 0.4.0 per `STABILITY.md` §3's one-minor-version
52    /// deprecation floor.
53    #[deprecated(
54        since = "0.3.0",
55        note = "BIKE was never implemented; the `bike` feature/module were removed in 0.3.0. This variant is kept only for wire compatibility and will be removed in 0.4.0."
56    )]
57    Bike,
58    /// Classic McEliece (NIST Round 4 alternate) — **never implemented in this crate**.
59    ///
60    /// The `mceliece` feature and `src/mceliece` module (a permanent
61    /// `compile_error!` stub) were removed entirely in 0.3.0. This variant
62    /// is retained only so previously-serialized values (e.g.
63    /// `"classic_mceliece"` in JSON) continue to deserialize without error;
64    /// it carries no working implementation in any release and never did.
65    /// Scheduled for removal in 0.4.0 per `STABILITY.md` §3's
66    /// one-minor-version deprecation floor.
67    #[deprecated(
68        since = "0.3.0",
69        note = "Classic McEliece was never implemented; the `mceliece` feature/module were removed in 0.3.0. This variant is kept only for wire compatibility and will be removed in 0.4.0."
70    )]
71    ClassicMceliece,
72}
73
74impl KemAlgorithm {
75    /// Returns the algorithm identifier string (for DID Documents, JWK, etc.)
76    #[allow(deprecated)] // Bike/ClassicMceliece arms — see variant docs (removed in 0.4.0)
77    pub fn as_str(&self) -> &'static str {
78        match self {
79            KemAlgorithm::MlKem512              => "ML-KEM-512",
80            KemAlgorithm::MlKem768              => "ML-KEM-768",
81            KemAlgorithm::MlKem1024             => "ML-KEM-1024",
82            KemAlgorithm::HybridX25519MlKem768  => "X25519+ML-KEM-768",
83            KemAlgorithm::XWing                 => "X-Wing",
84            KemAlgorithm::Hqc128                => "HQC-128",
85            KemAlgorithm::Hqc192                => "HQC-192",
86            KemAlgorithm::Hqc256                => "HQC-256",
87            KemAlgorithm::Bike                  => "BIKE",
88            KemAlgorithm::ClassicMceliece       => "Classic-McEliece",
89        }
90    }
91
92    /// Returns the expected public key size in bytes (0 = variable).
93    #[allow(deprecated)] // Bike/ClassicMceliece arms — see variant docs (removed in 0.4.0)
94    pub fn public_key_size(&self) -> usize {
95        match self {
96            KemAlgorithm::MlKem512              => 800,
97            KemAlgorithm::MlKem768              => 1184,
98            KemAlgorithm::MlKem1024             => 1568,
99            KemAlgorithm::HybridX25519MlKem768  => 32 + 1184, // 1216
100            KemAlgorithm::XWing                 => 1216, // pk_M(1184) ‖ pk_X(32)
101            KemAlgorithm::Hqc128                => 2249,
102            KemAlgorithm::Hqc192                => 4522,
103            KemAlgorithm::Hqc256                => 7245,
104            KemAlgorithm::Bike                  => 0, // variable by level
105            KemAlgorithm::ClassicMceliece       => 0, // variable by parameter set
106        }
107    }
108
109    /// Returns the expected ciphertext size in bytes (0 = variable).
110    ///
111    /// HQC-128/256 sizes were corrected from 4481/14469 to 4433/14421 bytes
112    /// during the P2/alpha-001 HQC implementation pass — verified live
113    /// against both `pqcrypto-hqc` 0.2.2 and `liboqs` 0.13.0 (two
114    /// independent implementations of the same NIST submission, which
115    /// agree). HQC-192's 8978-byte ciphertext was already correct. This
116    /// reflects the HQC round-4/2023 parameter revision to the
117    /// error-correcting code, which changed ciphertext size at the 128- and
118    /// 256-bit levels but not the 192-bit level.
119    #[allow(deprecated)] // Bike/ClassicMceliece arms — see variant docs (removed in 0.4.0)
120    pub fn ciphertext_size(&self) -> usize {
121        match self {
122            KemAlgorithm::MlKem512              => 768,
123            KemAlgorithm::MlKem768              => 1088,
124            KemAlgorithm::MlKem1024             => 1568,
125            KemAlgorithm::HybridX25519MlKem768  => 32 + 1088, // 1120
126            KemAlgorithm::XWing                 => 1120, // ct_M(1088) ‖ ct_X(32)
127            KemAlgorithm::Hqc128                => 4433,
128            KemAlgorithm::Hqc192                => 8978,
129            KemAlgorithm::Hqc256                => 14421,
130            KemAlgorithm::Bike                  => 0,
131            KemAlgorithm::ClassicMceliece       => 0,
132        }
133    }
134
135    /// Returns the shared secret size in bytes (always 32 for all supported algorithms).
136    pub fn shared_secret_size(&self) -> usize {
137        32
138    }
139}
140
141// ── Raw Byte Wrappers ─────────────────────────────────────────────────────────
142
143/// A KEM public key (raw bytes).
144///
145/// Public keys are safe to distribute and do not require zeroization.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147pub struct KemPublicKey {
148    /// The algorithm this key belongs to.
149    pub algorithm: KemAlgorithm,
150    /// Raw public key bytes.
151    #[serde(
152        serialize_with   = "serialize_bytes_base64url",
153        deserialize_with = "deserialize_bytes_base64url"
154    )]
155    pub bytes: Vec<u8>,
156}
157
158impl KemPublicKey {
159    /// Create a new public key from raw bytes.
160    pub fn new(algorithm: KemAlgorithm, bytes: Vec<u8>) -> Self {
161        Self { algorithm, bytes }
162    }
163
164    /// Encode the public key as base64url (no padding).
165    pub fn to_base64url(&self) -> String {
166        Base64Url::encode_string(&self.bytes)
167    }
168
169    /// Encode the public key as multibase (base58btc, prefix 'z') for DID Documents.
170    pub fn to_multibase(&self) -> String {
171        let mut out = String::with_capacity(self.bytes.len() * 2);
172        out.push('z');
173        out.push_str(&bs58::encode(&self.bytes).into_string());
174        out
175    }
176
177    /// Decode a public key from base64url.
178    pub fn from_base64url(algorithm: KemAlgorithm, s: &str) -> KemResult<Self> {
179        let bytes = Base64Url::decode_vec(s)
180            .map_err(|e| KemError::Base64Decode(e.to_string()))?;
181        Ok(Self { algorithm, bytes })
182    }
183
184    /// Decode a public key from multibase (base58btc, prefix 'z').
185    pub fn from_multibase(algorithm: KemAlgorithm, s: &str) -> KemResult<Self> {
186        let s = s.strip_prefix('z')
187            .ok_or_else(|| KemError::InvalidKey("multibase prefix must be 'z' (base58btc)".into()))?;
188        let bytes = bs58::decode(s)
189            .into_vec()
190            .map_err(|e| KemError::InvalidKey(e.to_string()))?;
191        Ok(Self { algorithm, bytes })
192    }
193}
194
195/// A KEM secret key (raw bytes). Zeroized on drop.
196///
197/// # Secret handling
198/// `bytes` is zeroized on drop (`#[derive(Zeroize, ZeroizeOnDrop)]`).
199/// `algorithm` is `#[zeroize(skip)]`d — it is a plain enum tag with no
200/// secret data. Cloning this type duplicates the secret bytes into a new,
201/// independently-zeroizing allocation; the clone is zeroized on its own
202/// drop just like the original.
203#[derive(Debug, Clone, Zeroize, ZeroizeOnDrop)]
204pub struct KemSecretKey {
205    /// The algorithm this key belongs to.
206    /// Skipped during zeroization — `KemAlgorithm` is a plain enum with no secret data.
207    #[zeroize(skip)]
208    pub algorithm: KemAlgorithm,
209    /// Raw secret key bytes (zeroized on drop).
210    pub bytes: Vec<u8>,
211}
212
213impl KemSecretKey {
214    /// Create a new secret key from raw bytes.
215    pub fn new(algorithm: KemAlgorithm, bytes: Vec<u8>) -> Self {
216        Self { algorithm, bytes }
217    }
218}
219
220/// A KEM ciphertext (raw bytes).
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222pub struct KemCiphertext {
223    /// The algorithm used to produce this ciphertext.
224    pub algorithm: KemAlgorithm,
225    /// Raw ciphertext bytes.
226    #[serde(
227        serialize_with   = "serialize_bytes_base64url",
228        deserialize_with = "deserialize_bytes_base64url"
229    )]
230    pub bytes: Vec<u8>,
231}
232
233impl KemCiphertext {
234    /// Create a new ciphertext from raw bytes.
235    pub fn new(algorithm: KemAlgorithm, bytes: Vec<u8>) -> Self {
236        Self { algorithm, bytes }
237    }
238
239    /// Encode the ciphertext as base64url (no padding).
240    pub fn to_base64url(&self) -> String {
241        Base64Url::encode_string(&self.bytes)
242    }
243
244    /// Decode a ciphertext from base64url.
245    pub fn from_base64url(algorithm: KemAlgorithm, s: &str) -> KemResult<Self> {
246        let bytes = Base64Url::decode_vec(s)
247            .map_err(|e| KemError::Base64Decode(e.to_string()))?;
248        Ok(Self { algorithm, bytes })
249    }
250}
251
252/// A shared secret produced by KEM encapsulation/decapsulation. Zeroized on drop.
253///
254/// # Secret handling
255/// `bytes` is zeroized on drop (`#[derive(Zeroize, ZeroizeOnDrop)]`).
256/// Cloning this type duplicates the secret bytes into a new,
257/// independently-zeroizing allocation.
258#[derive(Debug, Clone, Zeroize, ZeroizeOnDrop)]
259pub struct SharedSecret {
260    /// Raw shared secret bytes (always 32 bytes for all supported algorithms).
261    pub bytes: Vec<u8>,
262}
263
264impl SharedSecret {
265    /// Create a new shared secret from raw bytes.
266    pub fn new(bytes: Vec<u8>) -> Self {
267        Self { bytes }
268    }
269
270    /// Returns the shared secret as a fixed 32-byte array, if it is exactly 32 bytes.
271    pub fn as_32_bytes(&self) -> KemResult<[u8; 32]> {
272        self.bytes.as_slice().try_into()
273            .map_err(|_| KemError::Internal(
274                format!("shared secret is {} bytes, expected 32", self.bytes.len())
275            ))
276    }
277}
278
279// ── Hybrid KEM Wire Types ─────────────────────────────────────────────────────
280
281/// Wire format for a Hybrid X25519 + ML-KEM-768 ciphertext.
282///
283/// This is this crate's primary hybrid KEM construction.
284/// Breaking it requires breaking both X25519 AND ML-KEM-768 simultaneously.
285///
286/// # Construction
287/// ```text
288/// x25519_ss  = X25519(ephemeral_private, recipient_x25519_public)
289/// mlkem_ss   = ML-KEM-768.Decaps(ciphertext, recipient_mlkem_secret)
290/// combined   = x25519_ss ‖ mlkem_ss          (64 bytes)
291/// shared_key = HKDF-SHA256(combined, info="pqc-kem-hybrid-v1", len=32)
292/// ```
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
294pub struct HybridKemCiphertext {
295    /// X25519 ephemeral public key (32 bytes, base64url-encoded).
296    pub classical_ct: String,
297    /// ML-KEM-768 ciphertext (1088 bytes, base64url-encoded).
298    pub pqc_ct: String,
299    /// Algorithm identifier: "X25519+ML-KEM-768"
300    pub algorithm: String,
301}
302
303impl HybridKemCiphertext {
304    /// Create a new hybrid ciphertext from raw byte components.
305    pub fn new(x25519_ephemeral_pub: &[u8], mlkem_ciphertext: &[u8]) -> Self {
306        Self {
307            classical_ct: Base64Url::encode_string(x25519_ephemeral_pub),
308            pqc_ct:       Base64Url::encode_string(mlkem_ciphertext),
309            algorithm:    "X25519+ML-KEM-768".into(),
310        }
311    }
312
313    /// Decode the X25519 ephemeral public key bytes.
314    pub fn x25519_bytes(&self) -> KemResult<Vec<u8>> {
315        Base64Url::decode_vec(&self.classical_ct)
316            .map_err(|e| KemError::Base64Decode(e.to_string()))
317    }
318
319    /// Decode the ML-KEM-768 ciphertext bytes.
320    pub fn mlkem_bytes(&self) -> KemResult<Vec<u8>> {
321        Base64Url::decode_vec(&self.pqc_ct)
322            .map_err(|e| KemError::Base64Decode(e.to_string()))
323    }
324
325    /// Serialize to JSON string.
326    pub fn to_json(&self) -> KemResult<String> {
327        serde_json::to_string(self)
328            .map_err(|e| KemError::Serialization(e.to_string()))
329    }
330
331    /// Deserialize from JSON string.
332    pub fn from_json(s: &str) -> KemResult<Self> {
333        serde_json::from_str(s)
334            .map_err(|e| KemError::Serialization(e.to_string()))
335    }
336
337    /// Canonical **profile v1** (`HYBRID_PROFILE_V1`) byte encoding:
338    /// `x25519_eph_pk(32) ‖ mlkem_ct(1088)` = 1120 bytes. See
339    /// `docs/hybrid-profiles.md`. This is additive — the JSON wire format
340    /// above (`classical_ct`/`pqc_ct`/`algorithm`) is unchanged and remains
341    /// the compatibility-critical shape existing deployments consume.
342    pub const BYTES: usize = 1120;
343
344    /// Encode this ciphertext as the canonical 1120-byte v1 profile layout.
345    ///
346    /// Fails with [`KemError::InvalidCiphertext`] if the decoded component
347    /// lengths don't match the profile (32 / 1088 bytes) — never panics.
348    pub fn to_bytes(&self) -> KemResult<Vec<u8>> {
349        let x25519 = self.x25519_bytes()?;
350        let mlkem = self.mlkem_bytes()?;
351        if x25519.len() != 32 {
352            return Err(KemError::InvalidCiphertext(
353                format!("hybrid v1 profile: X25519 component must be 32 bytes, got {}", x25519.len())
354            ));
355        }
356        if mlkem.len() != 1088 {
357            return Err(KemError::InvalidCiphertext(
358                format!("hybrid v1 profile: ML-KEM-768 component must be 1088 bytes, got {}", mlkem.len())
359            ));
360        }
361        let mut out = Vec::with_capacity(Self::BYTES);
362        out.extend_from_slice(&x25519);
363        out.extend_from_slice(&mlkem);
364        Ok(out)
365    }
366
367    /// Decode the canonical 1120-byte v1 profile layout produced by
368    /// [`Self::to_bytes`]. Length-checked; returns [`KemError::InvalidCiphertext`]
369    /// (never panics) on a mismatched length. Rebuilds the same JSON-facing
370    /// fields [`Self::new`] would.
371    pub fn from_bytes(bytes: &[u8]) -> KemResult<Self> {
372        if bytes.len() != Self::BYTES {
373            return Err(KemError::InvalidCiphertext(
374                format!("hybrid v1 profile ciphertext must be {} bytes, got {}", Self::BYTES, bytes.len())
375            ));
376        }
377        Ok(Self::new(&bytes[..32], &bytes[32..]))
378    }
379}
380
381/// Wire format for a Hybrid X25519 + ML-KEM-768 public key.
382///
383/// Used in DID Documents under the `keyAgreement` verification method.
384#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
385pub struct HybridPublicKey {
386    /// X25519 public key (32 bytes, base64url-encoded).
387    pub x25519_key: String,
388    /// ML-KEM-768 public key (1184 bytes, base64url-encoded).
389    pub mlkem_key: String,
390    /// Multibase-encoded X25519 key for DID keyAgreement (base58btc, prefix 'z').
391    pub x25519_multibase: String,
392    /// Multibase-encoded ML-KEM-768 key for DID keyAgreement (base58btc, prefix 'z').
393    pub mlkem_multibase: String,
394}
395
396impl HybridPublicKey {
397    /// Create a new hybrid public key from raw byte components.
398    pub fn new(x25519_pub: &[u8], mlkem_pub: &[u8]) -> Self {
399        let x25519_multibase = {
400            let mut s = String::with_capacity(x25519_pub.len() * 2);
401            s.push('z');
402            s.push_str(&bs58::encode(x25519_pub).into_string());
403            s
404        };
405        let mlkem_multibase = {
406            let mut s = String::with_capacity(mlkem_pub.len() * 2);
407            s.push('z');
408            s.push_str(&bs58::encode(mlkem_pub).into_string());
409            s
410        };
411        Self {
412            x25519_key:      Base64Url::encode_string(x25519_pub),
413            mlkem_key:       Base64Url::encode_string(mlkem_pub),
414            x25519_multibase,
415            mlkem_multibase,
416        }
417    }
418
419    /// Decode the X25519 public key bytes.
420    pub fn x25519_bytes(&self) -> KemResult<Vec<u8>> {
421        Base64Url::decode_vec(&self.x25519_key)
422            .map_err(|e| KemError::Base64Decode(e.to_string()))
423    }
424
425    /// Decode the ML-KEM-768 public key bytes.
426    pub fn mlkem_bytes(&self) -> KemResult<Vec<u8>> {
427        Base64Url::decode_vec(&self.mlkem_key)
428            .map_err(|e| KemError::Base64Decode(e.to_string()))
429    }
430
431    /// Serialize to JSON string.
432    pub fn to_json(&self) -> KemResult<String> {
433        serde_json::to_string(self)
434            .map_err(|e| KemError::Serialization(e.to_string()))
435    }
436
437    /// Deserialize from JSON string.
438    pub fn from_json(s: &str) -> KemResult<Self> {
439        serde_json::from_str(s)
440            .map_err(|e| KemError::Serialization(e.to_string()))
441    }
442
443    /// Canonical **profile v1** (`HYBRID_PROFILE_V1`) byte encoding:
444    /// `x25519_pk(32) ‖ mlkem_ek(1184)` = 1216 bytes. See
445    /// `docs/hybrid-profiles.md`. Additive — the JSON wire format above
446    /// (`x25519_key`/`mlkem_key`/`*_multibase`) is unchanged.
447    pub const BYTES: usize = 1216;
448
449    /// Encode this public key as the canonical 1216-byte v1 profile layout.
450    ///
451    /// Fails with [`KemError::InvalidKey`] if the decoded component lengths
452    /// don't match the profile (32 / 1184 bytes) — never panics.
453    pub fn to_bytes(&self) -> KemResult<Vec<u8>> {
454        let x25519 = self.x25519_bytes()?;
455        let mlkem = self.mlkem_bytes()?;
456        if x25519.len() != 32 {
457            return Err(KemError::InvalidKey(
458                format!("hybrid v1 profile: X25519 component must be 32 bytes, got {}", x25519.len())
459            ));
460        }
461        if mlkem.len() != 1184 {
462            return Err(KemError::InvalidKey(
463                format!("hybrid v1 profile: ML-KEM-768 component must be 1184 bytes, got {}", mlkem.len())
464            ));
465        }
466        let mut out = Vec::with_capacity(Self::BYTES);
467        out.extend_from_slice(&x25519);
468        out.extend_from_slice(&mlkem);
469        Ok(out)
470    }
471
472    /// Decode the canonical 1216-byte v1 profile layout produced by
473    /// [`Self::to_bytes`]. Length-checked; returns [`KemError::InvalidKey`]
474    /// (never panics) on a mismatched length. Rebuilds the same
475    /// JSON-facing fields [`Self::new`] would (including the multibase
476    /// fields).
477    pub fn from_bytes(bytes: &[u8]) -> KemResult<Self> {
478        if bytes.len() != Self::BYTES {
479            return Err(KemError::InvalidKey(
480                format!("hybrid v1 profile public key must be {} bytes, got {}", Self::BYTES, bytes.len())
481            ));
482        }
483        Ok(Self::new(&bytes[..32], &bytes[32..]))
484    }
485}
486
487// ── Named Hybrid KEM Profiles (K-5) ───────────────────────────────────────────
488
489/// Identifies a named hybrid KEM profile (K-5, `docs/hybrid-profiles.md`).
490///
491/// Both profiles combine X25519 (RFC 7748) and ML-KEM-768 (FIPS 203) — they
492/// differ only in combiner and canonical byte layout:
493///
494/// - [`HybridProfile::V1`] — this crate's original combiner
495///   (`HKDF-SHA256(x25519_ss ‖ mlkem_ss, info="pqc-kem-hybrid-v1")`,
496///   `HybridKemKeypair`), wire-compatible with every prior 0.x release.
497///   Does **not** bind the ciphertext/public key into the KDF.
498/// - [`HybridProfile::V2XWing`] — X-Wing (`draft-connolly-cfrg-xwing-kem`,
499///   `XWingKeypair`), `SHA3-256`-based combiner that binds `ct_X`/`pk_X`.
500///   **Recommended for new deployments.**
501///
502/// `PRIMARY_ALGORITHM`/`HYBRID_PROFILE_ID` still point at v1 for wire
503/// compatibility with existing deployments; this may change in a
504/// future major/minor version once v2 has had time to see adoption.
505#[derive(Debug, Clone, Copy, PartialEq, Eq)]
506pub enum HybridProfile {
507    /// `HybridKem-X25519-MLKEM768-v1` — this crate's original combiner.
508    V1,
509    /// `HybridKem-X25519-MLKEM768-v2` — X-Wing.
510    V2XWing,
511}
512
513impl HybridProfile {
514    /// The profile's string identifier (`HYBRID_PROFILE_V1`/`HYBRID_PROFILE_V2`).
515    pub fn id(&self) -> &'static str {
516        match self {
517            HybridProfile::V1 => crate::HYBRID_PROFILE_V1,
518            HybridProfile::V2XWing => crate::HYBRID_PROFILE_V2,
519        }
520    }
521
522    /// Public key size in bytes. Identical (1216) for both profiles — they
523    /// differ in component *order*, not size (see `docs/hybrid-profiles.md`).
524    pub fn pk_len(&self) -> usize {
525        1216
526    }
527
528    /// Ciphertext size in bytes. Identical (1120) for both profiles.
529    pub fn ct_len(&self) -> usize {
530        1120
531    }
532
533    /// Secret-key storage size in bytes. v1: `x25519_sk(32) ‖ d(32) ‖ z(32)`
534    /// = 96. X-Wing: a single 32-byte seed.
535    pub fn sk_len(&self) -> usize {
536        match self {
537            HybridProfile::V1 => 96,
538            HybridProfile::V2XWing => 32,
539        }
540    }
541}
542
543// ── Serde Helpers ─────────────────────────────────────────────────────────────
544
545pub(crate) fn serialize_bytes_base64url<S>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error>
546where
547    S: serde::Serializer,
548{
549    serializer.serialize_str(&Base64Url::encode_string(bytes))
550}
551
552pub(crate) fn deserialize_bytes_base64url<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
553where
554    D: serde::Deserializer<'de>,
555{
556    let s = String::deserialize(deserializer)?;
557    Base64Url::decode_vec(&s).map_err(serde::de::Error::custom)
558}