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