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}