1use 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
55struct EncryptConfig {
57 encryptor: Box<dyn Encryptor + Send + Sync>,
58 algorithm: iana::Algorithm,
59 nonce: Option<[u8; 12]>,
60}
61
62#[derive(Debug)]
64pub struct EncodeResult {
65 pub qr_data: String,
67 pub compression_used: DetectedCompression,
69 pub warnings: Vec<Warning>,
71}
72
73pub 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 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 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 #[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 #[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 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, });
238 self
239 }
240
241 #[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 #[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 #[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 #[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 pub fn allow_unsigned(mut self) -> Self {
344 self.allow_unsigned = true;
345 self
346 }
347
348 pub fn skip_biometrics(mut self) -> Self {
362 self.skip_biometrics = true;
363 self
364 }
365
366 pub fn compression(mut self, compression: Compression) -> Self {
385 self.compression = compression;
386 self
387 }
388
389 pub fn encode(self) -> Result<EncodeResult> {
417 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 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 #[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#[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#[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]; 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 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 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 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 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 let signer = Ed25519Signer::generate();
652 let verifier = signer.verifying_key();
653
654 let encrypt_key = [10u8; 32];
655 let nonce = [11u8; 12];
656
657 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 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}