Skip to main content

claim169_core/
encode.rs

1//! Encoder builder for creating Claim 169 QR codes.
2//!
3//! The [`Encoder`] provides a fluent builder API for encoding identity credentials
4//! into QR-ready Base45 strings.
5//!
6//! # Basic Usage
7//!
8//! ```rust,ignore
9//! use claim169_core::{Encoder, Claim169, CwtMeta};
10//!
11//! // Create an unsigned credential (requires explicit opt-in)
12//! let qr_data = Encoder::new(claim169, cwt_meta)
13//!     .allow_unsigned()
14//!     .encode()?;
15//!
16//! // Create a signed credential with Ed25519
17//! let qr_data = Encoder::new(claim169, cwt_meta)
18//!     .sign_with_ed25519(&private_key)?
19//!     .encode()?;
20//!
21//! // Create a signed and encrypted credential
22//! let qr_data = Encoder::new(claim169, cwt_meta)
23//!     .sign_with_ed25519(&private_key)?
24//!     .encrypt_with_aes256(&aes_key)?
25//!     .encode()?;
26//! ```
27//!
28//! # HSM Integration
29//!
30//! For hardware security modules, use the generic `sign_with()` method:
31//!
32//! ```rust,ignore
33//! use claim169_core::{Encoder, Signer};
34//!
35//! struct HsmSigner { /* ... */ }
36//! impl Signer for HsmSigner { /* ... */ }
37//!
38//! let qr_data = Encoder::new(claim169, cwt_meta)
39//!     .sign_with(hsm_signer, iana::Algorithm::EdDSA)
40//!     .encode()?;
41//! ```
42
43use coset::iana;
44
45use crate::crypto::traits::{Encryptor, Signer};
46use crate::error::{Claim169Error, Result};
47use crate::model::{Claim169, CwtMeta};
48use crate::pipeline::decompress::{Compression, DetectedCompression};
49use crate::pipeline::encode::{encode_signed, encode_signed_and_encrypted, EncodeConfig};
50use crate::{Warning, WarningCode};
51
52#[cfg(feature = "software-crypto")]
53use crate::crypto::software::AesGcmEncryptor;
54
55/// Configuration for encryption in the encoder
56struct EncryptConfig {
57    encryptor: Box<dyn Encryptor + Send + Sync>,
58    algorithm: iana::Algorithm,
59    nonce: Option<[u8; 12]>,
60}
61
62/// Result of encoding a Claim 169 credential.
63#[derive(Debug)]
64pub struct EncodeResult {
65    /// The Base45-encoded QR code string.
66    pub qr_data: String,
67    /// Which compression was actually applied.
68    pub compression_used: DetectedCompression,
69    /// Warnings generated during encoding.
70    pub warnings: Vec<Warning>,
71}
72
73/// Builder for encoding Claim 169 credentials into QR-ready strings.
74///
75/// The encoder follows a builder pattern where configuration methods return `Self`
76/// and the final `encode()` method consumes the builder to produce the result.
77///
78/// # Operation Order
79///
80/// When both signing and encryption are configured, the credential is always
81/// **signed first, then encrypted** (sign-then-encrypt), regardless of the order
82/// in which builder methods are called.
83///
84/// # Security
85///
86/// - Unsigned encoding requires explicit opt-in via [`allow_unsigned()`](Self::allow_unsigned)
87/// - Nonces are generated randomly by default for encryption
88/// - Use explicit nonce methods only for testing or deterministic scenarios
89///
90/// # Example
91///
92/// ```rust,ignore
93/// use claim169_core::{Encoder, Claim169, CwtMeta};
94///
95/// let claim169 = Claim169::minimal("ID-001", "Jane Doe");
96/// let cwt_meta = CwtMeta::new()
97///     .with_issuer("https://issuer.example.com")
98///     .with_expires_at(1800000000);
99///
100/// // Sign with Ed25519
101/// let qr_data = Encoder::new(claim169, cwt_meta)
102///     .sign_with_ed25519(&private_key)?
103///     .encode()?;
104/// ```
105pub struct Encoder {
106    claim169: Claim169,
107    cwt_meta: CwtMeta,
108    signer: Option<Box<dyn Signer + Send + Sync>>,
109    sign_algorithm: Option<iana::Algorithm>,
110    encrypt_config: Option<EncryptConfig>,
111    allow_unsigned: bool,
112    skip_biometrics: bool,
113    compression: Compression,
114}
115
116impl Encoder {
117    /// Create a new encoder with the given claim and CWT metadata.
118    ///
119    /// # Arguments
120    ///
121    /// * `claim169` - The identity claim data to encode
122    /// * `cwt_meta` - CWT metadata including issuer, expiration, etc.
123    ///
124    /// # Example
125    ///
126    /// ```rust,ignore
127    /// let encoder = Encoder::new(claim169, cwt_meta);
128    /// ```
129    pub fn new(claim169: Claim169, cwt_meta: CwtMeta) -> Self {
130        Self {
131            claim169,
132            cwt_meta,
133            signer: None,
134            sign_algorithm: None,
135            encrypt_config: None,
136            allow_unsigned: false,
137            skip_biometrics: false,
138            compression: Compression::default(),
139        }
140    }
141
142    /// Sign with a custom signer implementation.
143    ///
144    /// Use this method for HSM integration or custom cryptographic backends.
145    ///
146    /// # Arguments
147    ///
148    /// * `signer` - A type implementing the [`Signer`] trait
149    /// * `algorithm` - The COSE algorithm to use for signing
150    ///
151    /// # Example
152    ///
153    /// ```rust,ignore
154    /// let encoder = Encoder::new(claim169, cwt_meta)
155    ///     .sign_with(hsm_signer, iana::Algorithm::EdDSA);
156    /// ```
157    pub fn sign_with<S: Signer + 'static>(mut self, signer: S, algorithm: iana::Algorithm) -> Self {
158        self.signer = Some(Box::new(signer));
159        self.sign_algorithm = Some(algorithm);
160        self
161    }
162
163    /// Sign with an Ed25519 private key.
164    ///
165    /// The key is validated immediately and an error is returned if invalid.
166    ///
167    /// # Arguments
168    ///
169    /// * `private_key` - 32-byte Ed25519 private key
170    ///
171    /// # Errors
172    ///
173    /// Returns an error if the private key is not exactly 32 bytes.
174    ///
175    /// # Example
176    ///
177    /// ```rust,ignore
178    /// let encoder = Encoder::new(claim169, cwt_meta)
179    ///     .sign_with_ed25519(&private_key)?;
180    /// ```
181    #[cfg(feature = "software-crypto")]
182    pub fn sign_with_ed25519(self, private_key: &[u8]) -> Result<Self> {
183        use crate::crypto::software::Ed25519Signer;
184
185        let signer = Ed25519Signer::from_bytes(private_key)
186            .map_err(|e| Claim169Error::Crypto(e.to_string()))?;
187
188        Ok(self.sign_with(signer, iana::Algorithm::EdDSA))
189    }
190
191    /// Sign with an ECDSA P-256 private key.
192    ///
193    /// The key is validated immediately and an error is returned if invalid.
194    ///
195    /// # Arguments
196    ///
197    /// * `private_key` - 32-byte ECDSA P-256 private key (scalar)
198    ///
199    /// # Errors
200    ///
201    /// Returns an error if the private key format is invalid.
202    ///
203    /// # Example
204    ///
205    /// ```rust,ignore
206    /// let encoder = Encoder::new(claim169, cwt_meta)
207    ///     .sign_with_ecdsa_p256(&private_key)?;
208    /// ```
209    #[cfg(feature = "software-crypto")]
210    pub fn sign_with_ecdsa_p256(self, private_key: &[u8]) -> Result<Self> {
211        use crate::crypto::software::EcdsaP256Signer;
212
213        let signer = EcdsaP256Signer::from_bytes(private_key)
214            .map_err(|e| Claim169Error::Crypto(e.to_string()))?;
215
216        Ok(self.sign_with(signer, iana::Algorithm::ES256))
217    }
218
219    /// Encrypt with a custom encryptor implementation.
220    ///
221    /// Use this method for HSM integration or custom cryptographic backends.
222    /// A random 12-byte nonce is generated automatically.
223    ///
224    /// # Arguments
225    ///
226    /// * `encryptor` - A type implementing the [`Encryptor`] trait
227    /// * `algorithm` - The COSE algorithm to use for encryption
228    pub fn encrypt_with<E: Encryptor + 'static>(
229        mut self,
230        encryptor: E,
231        algorithm: iana::Algorithm,
232    ) -> Self {
233        self.encrypt_config = Some(EncryptConfig {
234            encryptor: Box::new(encryptor),
235            algorithm,
236            nonce: None, // Generate random nonce during encode
237        });
238        self
239    }
240
241    /// Encrypt with AES-256-GCM.
242    ///
243    /// A random 12-byte nonce is generated automatically during encoding.
244    ///
245    /// # Arguments
246    ///
247    /// * `key` - 32-byte AES-256 encryption key
248    ///
249    /// # Errors
250    ///
251    /// Returns an error if the key is not exactly 32 bytes.
252    ///
253    /// # Example
254    ///
255    /// ```rust,ignore
256    /// let encoder = Encoder::new(claim169, cwt_meta)
257    ///     .sign_with_ed25519(&sign_key)?
258    ///     .encrypt_with_aes256(&aes_key)?;
259    /// ```
260    #[cfg(feature = "software-crypto")]
261    pub fn encrypt_with_aes256(self, key: &[u8]) -> Result<Self> {
262        let encryptor =
263            AesGcmEncryptor::aes256(key).map_err(|e| Claim169Error::Crypto(e.to_string()))?;
264
265        Ok(self.encrypt_with(encryptor, iana::Algorithm::A256GCM))
266    }
267
268    /// Encrypt with AES-128-GCM.
269    ///
270    /// A random 12-byte nonce is generated automatically during encoding.
271    ///
272    /// # Arguments
273    ///
274    /// * `key` - 16-byte AES-128 encryption key
275    ///
276    /// # Errors
277    ///
278    /// Returns an error if the key is not exactly 16 bytes.
279    #[cfg(feature = "software-crypto")]
280    pub fn encrypt_with_aes128(self, key: &[u8]) -> Result<Self> {
281        let encryptor =
282            AesGcmEncryptor::aes128(key).map_err(|e| Claim169Error::Crypto(e.to_string()))?;
283
284        Ok(self.encrypt_with(encryptor, iana::Algorithm::A128GCM))
285    }
286
287    /// Encrypt with AES-256-GCM using an explicit nonce.
288    ///
289    /// **Warning**: Only use this for testing or deterministic scenarios.
290    /// Reusing nonces with the same key is a critical security vulnerability.
291    ///
292    /// # Arguments
293    ///
294    /// * `key` - 32-byte AES-256 encryption key
295    /// * `nonce` - 12-byte nonce/IV (must be unique per encryption)
296    #[cfg(feature = "software-crypto")]
297    pub fn encrypt_with_aes256_nonce(mut self, key: &[u8], nonce: &[u8; 12]) -> Result<Self> {
298        let encryptor =
299            AesGcmEncryptor::aes256(key).map_err(|e| Claim169Error::Crypto(e.to_string()))?;
300
301        self.encrypt_config = Some(EncryptConfig {
302            encryptor: Box::new(encryptor),
303            algorithm: iana::Algorithm::A256GCM,
304            nonce: Some(*nonce),
305        });
306        Ok(self)
307    }
308
309    /// Encrypt with AES-128-GCM using an explicit nonce.
310    ///
311    /// **Warning**: Only use this for testing or deterministic scenarios.
312    /// Reusing nonces with the same key is a critical security vulnerability.
313    ///
314    /// # Arguments
315    ///
316    /// * `key` - 16-byte AES-128 encryption key
317    /// * `nonce` - 12-byte nonce/IV (must be unique per encryption)
318    #[cfg(feature = "software-crypto")]
319    pub fn encrypt_with_aes128_nonce(mut self, key: &[u8], nonce: &[u8; 12]) -> Result<Self> {
320        let encryptor =
321            AesGcmEncryptor::aes128(key).map_err(|e| Claim169Error::Crypto(e.to_string()))?;
322
323        self.encrypt_config = Some(EncryptConfig {
324            encryptor: Box::new(encryptor),
325            algorithm: iana::Algorithm::A128GCM,
326            nonce: Some(*nonce),
327        });
328        Ok(self)
329    }
330
331    /// Allow encoding without a signature.
332    ///
333    /// **Security Warning**: Unsigned credentials cannot be verified for authenticity.
334    /// Only use this for testing or scenarios where signatures are not required.
335    ///
336    /// # Example
337    ///
338    /// ```rust,ignore
339    /// let encoder = Encoder::new(claim169, cwt_meta)
340    ///     .allow_unsigned()
341    ///     .encode()?;
342    /// ```
343    pub fn allow_unsigned(mut self) -> Self {
344        self.allow_unsigned = true;
345        self
346    }
347
348    /// Skip biometric fields during encoding.
349    ///
350    /// This reduces the QR code size by excluding fingerprint, iris, face,
351    /// palm, and voice biometric data.
352    ///
353    /// # Example
354    ///
355    /// ```rust,ignore
356    /// let encoder = Encoder::new(claim169, cwt_meta)
357    ///     .skip_biometrics()
358    ///     .sign_with_ed25519(&key)?
359    ///     .encode()?;
360    /// ```
361    pub fn skip_biometrics(mut self) -> Self {
362        self.skip_biometrics = true;
363        self
364    }
365
366    /// Set the compression mode for encoding.
367    ///
368    /// The default is `Compression::Zlib` (spec-compliant). Other modes
369    /// produce non-standard output and will generate a warning.
370    ///
371    /// # Arguments
372    ///
373    /// * `compression` - The compression mode to use
374    ///
375    /// # Example
376    ///
377    /// ```rust,ignore
378    /// use claim169_core::{Encoder, Compression};
379    ///
380    /// let encoder = Encoder::new(claim169, cwt_meta)
381    ///     .compression(Compression::Adaptive)
382    ///     .allow_unsigned();
383    /// ```
384    pub fn compression(mut self, compression: Compression) -> Self {
385        self.compression = compression;
386        self
387    }
388
389    /// Encode the credential to a Base45 QR string.
390    ///
391    /// This method consumes the encoder and produces the final QR-ready string.
392    ///
393    /// # Pipeline
394    ///
395    /// ```text
396    /// Claim169 → CBOR → CWT → COSE_Sign1 → [COSE_Encrypt0] → zlib → Base45
397    /// ```
398    ///
399    /// # Errors
400    ///
401    /// Returns an error if:
402    /// - Neither a signer nor `allow_unsigned()` was configured
403    /// - Signing fails
404    /// - Encryption fails
405    /// - CBOR encoding fails
406    ///
407    /// # Example
408    ///
409    /// ```rust,ignore
410    /// let qr_data = Encoder::new(claim169, cwt_meta)
411    ///     .sign_with_ed25519(&key)?
412    ///     .encode()?;
413    ///
414    /// println!("QR content: {}", qr_data);
415    /// ```
416    pub fn encode(self) -> Result<EncodeResult> {
417        // Validate configuration
418        if self.signer.is_none() && !self.allow_unsigned {
419            return Err(Claim169Error::EncodingConfig(
420                "either call sign_with_*() or allow_unsigned() before encode()".to_string(),
421            ));
422        }
423
424        let config = EncodeConfig {
425            skip_biometrics: self.skip_biometrics,
426            compression: self.compression,
427        };
428
429        // Convert the boxed signer to a trait object reference
430        // The cast is needed because Box<dyn Signer + Send + Sync> doesn't automatically
431        // coerce to &dyn Signer, even though Signer: Send + Sync
432        let signer_ref: Option<&dyn Signer> =
433            self.signer.as_ref().map(|s| s.as_ref() as &dyn Signer);
434
435        let pipeline_result = match self.encrypt_config {
436            Some(encrypt_config) => {
437                // Get nonce - auto-generate if software-crypto is enabled, otherwise require explicit
438                #[cfg(feature = "software-crypto")]
439                let nonce = encrypt_config.nonce.unwrap_or_else(generate_nonce);
440
441                #[cfg(not(feature = "software-crypto"))]
442                let nonce = encrypt_config.nonce.ok_or_else(|| {
443                    Claim169Error::EncodingConfig(
444                        "explicit nonce required when software-crypto feature is disabled"
445                            .to_string(),
446                    )
447                })?;
448
449                encode_signed_and_encrypted(
450                    &self.claim169,
451                    &self.cwt_meta,
452                    signer_ref,
453                    self.sign_algorithm,
454                    encrypt_config.encryptor.as_ref(),
455                    encrypt_config.algorithm,
456                    &nonce,
457                    &config,
458                )?
459            }
460            None => encode_signed(
461                &self.claim169,
462                &self.cwt_meta,
463                signer_ref,
464                self.sign_algorithm,
465                &config,
466            )?,
467        };
468
469        let mut warnings = Vec::new();
470        if pipeline_result.compression_used != DetectedCompression::Zlib {
471            warnings.push(Warning {
472                code: WarningCode::NonStandardCompression,
473                message: format!(
474                    "non-standard compression used: {}",
475                    pipeline_result.compression_used
476                ),
477            });
478        }
479
480        Ok(EncodeResult {
481            qr_data: pipeline_result.qr_data,
482            compression_used: pipeline_result.compression_used,
483            warnings,
484        })
485    }
486}
487
488/// Generate a random 12-byte nonce for AES-GCM encryption.
489#[cfg(feature = "software-crypto")]
490fn generate_nonce() -> [u8; 12] {
491    use rand::RngCore;
492    let mut nonce = [0u8; 12];
493    rand::thread_rng().fill_bytes(&mut nonce);
494    nonce
495}
496
497/// Generate a random nonce.
498///
499/// Returns a 12-byte random nonce suitable for AES-GCM encryption.
500///
501/// # Example
502///
503/// ```rust
504/// use claim169_core::generate_random_nonce;
505///
506/// let nonce = generate_random_nonce();
507/// assert_eq!(nonce.len(), 12);
508/// ```
509#[cfg(feature = "software-crypto")]
510pub fn generate_random_nonce() -> [u8; 12] {
511    generate_nonce()
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    #[test]
519    fn test_encoder_requires_signer_or_allow_unsigned() {
520        let claim169 = Claim169::minimal("test-id", "Test User");
521        let cwt_meta = CwtMeta::default();
522
523        let result = Encoder::new(claim169, cwt_meta).encode();
524
525        assert!(result.is_err());
526        match result.unwrap_err() {
527            Claim169Error::EncodingConfig(msg) => {
528                assert!(msg.contains("allow_unsigned"));
529            }
530            e => panic!("Expected EncodingConfig error, got: {:?}", e),
531        }
532    }
533
534    #[test]
535    fn test_encoder_unsigned() {
536        let claim169 = Claim169::minimal("test-id", "Test User");
537        let cwt_meta = CwtMeta::new().with_issuer("test-issuer");
538
539        let result = Encoder::new(claim169, cwt_meta).allow_unsigned().encode();
540
541        assert!(result.is_ok());
542        let encode_result = result.unwrap();
543        assert!(!encode_result.qr_data.is_empty());
544        assert_eq!(encode_result.compression_used, DetectedCompression::Zlib);
545        assert!(encode_result.warnings.is_empty());
546    }
547
548    #[cfg(feature = "software-crypto")]
549    #[test]
550    fn test_encoder_ed25519_signed() {
551        use crate::crypto::software::Ed25519Signer;
552
553        let claim169 = Claim169::minimal("signed-test", "Signed User");
554        let cwt_meta = CwtMeta::new()
555            .with_issuer("https://test.issuer")
556            .with_expires_at(1800000000);
557
558        let private_key = [0u8; 32]; // For test - generate real key in production
559        let test_signer = Ed25519Signer::from_bytes(&private_key).unwrap();
560
561        let result = Encoder::new(claim169, cwt_meta)
562            .sign_with(test_signer, iana::Algorithm::EdDSA)
563            .encode();
564
565        assert!(result.is_ok());
566    }
567
568    #[cfg(feature = "software-crypto")]
569    #[test]
570    fn test_encoder_ed25519_convenience() {
571        let claim169 = Claim169::minimal("signed-test", "Signed User");
572        let cwt_meta = CwtMeta::default();
573
574        // Use a fixed test key
575        let private_key = [1u8; 32];
576
577        let result = Encoder::new(claim169, cwt_meta)
578            .sign_with_ed25519(&private_key)
579            .and_then(|e| e.encode());
580
581        assert!(result.is_ok());
582    }
583
584    #[cfg(feature = "software-crypto")]
585    #[test]
586    fn test_encoder_with_encryption() {
587        let claim169 = Claim169::minimal("encrypted-test", "Encrypted User");
588        let cwt_meta = CwtMeta::new().with_issuer("test");
589
590        let sign_key = [2u8; 32];
591        let encrypt_key = [3u8; 32];
592        let nonce = [4u8; 12];
593
594        let result = Encoder::new(claim169, cwt_meta)
595            .sign_with_ed25519(&sign_key)
596            .and_then(|e| e.encrypt_with_aes256_nonce(&encrypt_key, &nonce))
597            .and_then(|e| e.encode());
598
599        assert!(result.is_ok());
600    }
601
602    #[cfg(feature = "software-crypto")]
603    #[test]
604    fn test_encoder_skip_biometrics() {
605        use crate::model::Biometric;
606
607        let mut claim169 = Claim169::minimal("bio-test", "Bio User");
608        claim169.face = Some(vec![Biometric::new(vec![1, 2, 3, 4, 5])]);
609        assert!(claim169.has_biometrics());
610
611        let cwt_meta = CwtMeta::default();
612        let sign_key = [5u8; 32];
613
614        // Encode with biometrics
615        let result_with_bio = Encoder::new(claim169.clone(), cwt_meta.clone())
616            .sign_with_ed25519(&sign_key)
617            .and_then(|e| e.encode())
618            .unwrap();
619
620        // Encode without biometrics
621        let result_without_bio = Encoder::new(claim169, cwt_meta)
622            .skip_biometrics()
623            .sign_with_ed25519(&sign_key)
624            .and_then(|e| e.encode())
625            .unwrap();
626
627        // Without biometrics should be smaller
628        assert!(result_without_bio.qr_data.len() < result_with_bio.qr_data.len());
629    }
630
631    #[cfg(feature = "software-crypto")]
632    #[test]
633    fn test_encoder_roundtrip() {
634        use crate::crypto::software::{AesGcmDecryptor, Ed25519Signer};
635        use crate::model::VerificationStatus;
636        use crate::pipeline::claim169::transform;
637        use crate::pipeline::{base45_decode, cose_parse, cwt_parse, decompress};
638
639        let original_claim = Claim169 {
640            id: Some("roundtrip-builder".to_string()),
641            full_name: Some("Builder Roundtrip".to_string()),
642            email: Some("builder@test.com".to_string()),
643            ..Default::default()
644        };
645
646        let cwt_meta = CwtMeta::new()
647            .with_issuer("https://builder.test")
648            .with_expires_at(1800000000);
649
650        // Generate keys
651        let signer = Ed25519Signer::generate();
652        let verifier = signer.verifying_key();
653
654        let encrypt_key = [10u8; 32];
655        let nonce = [11u8; 12];
656
657        // Encode using builder
658        let encode_result = Encoder::new(original_claim.clone(), cwt_meta.clone())
659            .sign_with(signer, iana::Algorithm::EdDSA)
660            .encrypt_with_aes256_nonce(&encrypt_key, &nonce)
661            .unwrap()
662            .encode()
663            .unwrap();
664
665        // Decode and verify
666        let compressed = base45_decode(&encode_result.qr_data).unwrap();
667        let (cose_bytes, _detected) = decompress(&compressed, 65536).unwrap();
668        let decryptor = AesGcmDecryptor::aes256(&encrypt_key).unwrap();
669        let cose_result = cose_parse(&cose_bytes, Some(&verifier), Some(&decryptor)).unwrap();
670
671        assert_eq!(
672            cose_result.verification_status,
673            VerificationStatus::Verified
674        );
675
676        let cwt_result = cwt_parse(&cose_result.payload).unwrap();
677        let decoded_claim = transform(cwt_result.claim_169, false).unwrap();
678
679        assert_eq!(decoded_claim.id, original_claim.id);
680        assert_eq!(decoded_claim.full_name, original_claim.full_name);
681        assert_eq!(decoded_claim.email, original_claim.email);
682    }
683
684    #[test]
685    fn test_encoder_compression_none_produces_warning() {
686        let claim169 = Claim169::minimal("test", "Test");
687        let cwt_meta = CwtMeta::default();
688
689        let result = Encoder::new(claim169, cwt_meta)
690            .allow_unsigned()
691            .compression(Compression::None)
692            .encode()
693            .unwrap();
694
695        assert_eq!(result.compression_used, DetectedCompression::None);
696        assert!(result
697            .warnings
698            .iter()
699            .any(|w| w.code == WarningCode::NonStandardCompression));
700    }
701
702    #[test]
703    fn test_encoder_default_compression_is_zlib() {
704        let claim169 = Claim169::minimal("test", "Test");
705        let cwt_meta = CwtMeta::default();
706
707        let result = Encoder::new(claim169, cwt_meta)
708            .allow_unsigned()
709            .encode()
710            .unwrap();
711
712        assert_eq!(result.compression_used, DetectedCompression::Zlib);
713        assert!(!result
714            .warnings
715            .iter()
716            .any(|w| w.code == WarningCode::NonStandardCompression));
717    }
718
719    #[test]
720    fn test_encoder_compression_none_roundtrips_through_decoder() {
721        use crate::Decoder;
722
723        let claim169 = Claim169::minimal("no-compress", "Test User");
724        let cwt_meta = CwtMeta::new().with_expires_at(i64::MAX);
725
726        let encode_result = Encoder::new(claim169, cwt_meta)
727            .allow_unsigned()
728            .compression(Compression::None)
729            .encode()
730            .unwrap();
731
732        let decode_result = Decoder::new(&encode_result.qr_data)
733            .allow_unverified()
734            .decode()
735            .unwrap();
736
737        assert_eq!(decode_result.claim169.id.as_deref(), Some("no-compress"));
738        assert_eq!(
739            decode_result.detected_compression,
740            DetectedCompression::None
741        );
742    }
743
744    #[test]
745    fn test_generate_random_nonce() {
746        let nonce1 = generate_random_nonce();
747        let nonce2 = generate_random_nonce();
748
749        assert_eq!(nonce1.len(), 12);
750        assert_eq!(nonce2.len(), 12);
751        assert_ne!(nonce1, nonce2);
752    }
753}