Skip to main content

coding_agent_search/pages/
qr.rs

1//! QR code generation for recovery secrets.
2//!
3//! Generates high-entropy recovery secrets and encodes them as QR codes
4//! for out-of-band archive unlock. The recovery secret provides an alternative
5//! to password-based decryption using HKDF-SHA256 (fast for high-entropy inputs).
6//!
7//! # Output Files (private/)
8//!
9//! ```text
10//! private/
11//! ├── recovery-secret.txt   # Human-readable secret with instructions
12//! ├── qr-code.png           # QR code image for mobile scanning
13//! └── qr-code.svg           # Vector QR code for print
14//! ```
15//!
16//! # Security
17//!
18//! - Recovery secret is 256-bit (32 bytes) for maximum security
19//! - Encoded as URL-safe base64 without padding
20//! - Creates a recovery key slot using HKDF-SHA256
21//! - NEVER deploy private/ directory with public site
22
23#![allow(unexpected_cfgs)]
24
25use anyhow::{Context, Result, bail};
26use base64::prelude::*;
27use chrono::Utc;
28use rand::Rng;
29use std::fs::OpenOptions;
30use std::io::Write;
31use std::path::{Path, PathBuf};
32use tracing::info;
33use zeroize::Zeroize;
34
35/// Recovery secret entropy (256 bits = 32 bytes)
36const RECOVERY_SECRET_BYTES: usize = 32;
37
38/// Recovery secret for archive unlock.
39///
40/// Contains high-entropy random bytes that can be used to derive
41/// a key encryption key (KEK) via HKDF-SHA256.
42#[derive(Clone)]
43pub struct RecoverySecret {
44    /// Raw secret bytes (zeroized on drop)
45    bytes: Vec<u8>,
46    /// Base64url-encoded secret (for QR code and text file)
47    encoded: String,
48}
49
50impl std::fmt::Debug for RecoverySecret {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        // Redact sensitive data to prevent accidental logging
53        f.debug_struct("RecoverySecret")
54            .field("entropy_bits", &self.entropy_bits())
55            .field("encoded", &"[REDACTED]")
56            .finish()
57    }
58}
59
60impl RecoverySecret {
61    /// Generate a new random recovery secret.
62    ///
63    /// Uses the system's cryptographically secure random number generator.
64    pub fn generate() -> Self {
65        let mut bytes = vec![0u8; RECOVERY_SECRET_BYTES];
66        let mut rng = rand::rng();
67        rng.fill_bytes(&mut bytes);
68        let encoded = BASE64_URL_SAFE_NO_PAD.encode(&bytes);
69        Self { bytes, encoded }
70    }
71
72    /// Create a recovery secret from existing bytes.
73    ///
74    /// Returns None if the bytes are too short (< 24 bytes / 192 bits).
75    /// NIST recommends 192+ bits for long-term cryptographic material.
76    pub fn from_bytes(bytes: Vec<u8>) -> Option<Self> {
77        if bytes.len() < 24 {
78            return None;
79        }
80        let encoded = BASE64_URL_SAFE_NO_PAD.encode(&bytes);
81        Some(Self { bytes, encoded })
82    }
83
84    /// Create a recovery secret from a base64url-encoded string.
85    pub fn from_encoded(encoded: &str) -> Result<Self> {
86        let bytes = BASE64_URL_SAFE_NO_PAD
87            .decode(encoded)
88            .context("Invalid base64url encoding")?;
89        if bytes.len() < 24 {
90            bail!("Recovery secret too short (minimum 192 bits for long-term security)");
91        }
92        Ok(Self {
93            bytes,
94            encoded: encoded.to_string(),
95        })
96    }
97
98    /// Get the raw secret bytes for key derivation.
99    pub fn as_bytes(&self) -> &[u8] {
100        &self.bytes
101    }
102
103    /// Get the base64url-encoded secret (for QR code).
104    pub fn encoded(&self) -> &str {
105        &self.encoded
106    }
107
108    /// Get the entropy in bits.
109    pub fn entropy_bits(&self) -> usize {
110        self.bytes.len() * 8
111    }
112}
113
114impl Drop for RecoverySecret {
115    fn drop(&mut self) {
116        // Use zeroize crate for secure erasure (prevents compiler optimization)
117        self.bytes.zeroize();
118        // Move encoded bytes out, zeroize, then drop without unsafe string mutation.
119        let mut encoded_bytes = std::mem::take(&mut self.encoded).into_bytes();
120        encoded_bytes.zeroize();
121    }
122}
123
124/// Generated recovery artifacts ready for writing to disk.
125pub struct RecoveryArtifacts {
126    /// The recovery secret
127    pub secret: RecoverySecret,
128    /// Content for recovery-secret.txt (contains secret, zeroized on drop)
129    pub secret_text: String,
130    /// PNG image bytes for qr-code.png
131    pub qr_png: Vec<u8>,
132    /// SVG markup for qr-code.svg
133    pub qr_svg: String,
134}
135
136impl Drop for RecoveryArtifacts {
137    fn drop(&mut self) {
138        // Zeroize all secret-bearing payloads before drop.
139        let mut text_bytes = std::mem::take(&mut self.secret_text).into_bytes();
140        text_bytes.zeroize();
141        self.qr_png.zeroize();
142        let mut svg_bytes = std::mem::take(&mut self.qr_svg).into_bytes();
143        svg_bytes.zeroize();
144        // Note: secret field has its own Drop impl that zeroizes it
145    }
146}
147
148impl RecoveryArtifacts {
149    /// Generate all recovery artifacts for an archive.
150    ///
151    /// # Arguments
152    /// * `archive_name` - Name of the archive (for the text file header)
153    pub fn generate(archive_name: &str) -> Result<Self> {
154        let secret = RecoverySecret::generate();
155        let timestamp = Utc::now().to_rfc3339();
156
157        // Generate recovery-secret.txt content
158        let secret_text = format!(
159            r#"CASS RECOVERY SECRET
160====================
161
162Archive: {archive_name}
163Created: {timestamp}
164
165Secret: {secret}
166
167IMPORTANT:
168- This secret unlocks your archive if you forget your password
169- Store securely (password manager, encrypted USB, safe)
170- NEVER deploy this file with the public site
171- The QR code encodes the same secret
172
173[QR code path: qr-code.png]
174"#,
175            archive_name = archive_name,
176            timestamp = timestamp,
177            secret = secret.encoded(),
178        );
179
180        // Generate QR codes
181        let qr_png = generate_qr_png(secret.encoded())?;
182        let qr_svg = generate_qr_svg(secret.encoded())?;
183
184        info!(
185            entropy_bits = secret.entropy_bits(),
186            encoded_len = secret.encoded().len(),
187            "Generated recovery secret"
188        );
189
190        Ok(Self {
191            secret,
192            secret_text,
193            qr_png,
194            qr_svg,
195        })
196    }
197
198    /// Write all artifacts to the specified directory.
199    ///
200    /// Creates the directory if it doesn't exist.
201    pub fn write_to_dir(&self, dir: &Path) -> Result<()> {
202        ensure_recovery_artifact_dir(dir)?;
203
204        // Write recovery-secret.txt
205        let secret_path = dir.join("recovery-secret.txt");
206        write_recovery_artifact(&secret_path, self.secret_text.as_bytes())
207            .context("Failed to write recovery-secret.txt")?;
208
209        // Write qr-code.png
210        let png_path = dir.join("qr-code.png");
211        write_recovery_artifact(&png_path, &self.qr_png).context("Failed to write qr-code.png")?;
212
213        // Write qr-code.svg
214        let svg_path = dir.join("qr-code.svg");
215        write_recovery_artifact(&svg_path, self.qr_svg.as_bytes())
216            .context("Failed to write qr-code.svg")?;
217
218        info!(
219            dir = %dir.display(),
220            "Wrote recovery artifacts: recovery-secret.txt, qr-code.png, qr-code.svg"
221        );
222
223        Ok(())
224    }
225}
226
227fn ensure_recovery_artifact_dir(dir: &Path) -> Result<()> {
228    match std::fs::symlink_metadata(dir) {
229        Ok(metadata) => {
230            let file_type = metadata.file_type();
231            if file_type.is_symlink() {
232                bail!(
233                    "Recovery artifact directory must not be a symlink: {}",
234                    dir.display()
235                );
236            }
237            if !file_type.is_dir() {
238                bail!(
239                    "Recovery artifact path must be a directory: {}",
240                    dir.display()
241                );
242            }
243            Ok(())
244        }
245        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
246            std::fs::create_dir_all(dir).context("Failed to create private directory")?;
247            ensure_recovery_artifact_dir(dir)
248        }
249        Err(err) => Err(err)
250            .with_context(|| format!("Failed to inspect recovery artifact dir {}", dir.display())),
251    }
252}
253
254fn reject_recovery_artifact_symlink(path: &Path) -> Result<()> {
255    match std::fs::symlink_metadata(path) {
256        Ok(metadata) => {
257            let file_type = metadata.file_type();
258            if file_type.is_symlink() {
259                bail!(
260                    "Recovery artifact file must not be a symlink: {}",
261                    path.display()
262                );
263            }
264            if file_type.is_dir() {
265                bail!(
266                    "Recovery artifact path must be a regular file, not a directory: {}",
267                    path.display()
268                );
269            }
270            Ok(())
271        }
272        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
273        Err(err) => Err(err)
274            .with_context(|| format!("Failed to inspect recovery artifact {}", path.display())),
275    }
276}
277
278fn recovery_artifact_temp_path(path: &Path, attempt: usize) -> PathBuf {
279    let file_name = path
280        .file_name()
281        .and_then(|name| name.to_str())
282        .unwrap_or("artifact");
283    path.with_file_name(format!(
284        ".{file_name}.tmp.{}.{}",
285        std::process::id(),
286        attempt
287    ))
288}
289
290fn write_recovery_artifact(path: &Path, contents: &[u8]) -> Result<()> {
291    let parent = path.parent().unwrap_or_else(|| Path::new("."));
292    ensure_recovery_artifact_dir(parent)?;
293    reject_recovery_artifact_symlink(path)?;
294
295    let mut temp_path = None;
296    let mut file = None;
297    for attempt in 0..100 {
298        let candidate = recovery_artifact_temp_path(path, attempt);
299        match OpenOptions::new()
300            .write(true)
301            .create_new(true)
302            .open(&candidate)
303        {
304            Ok(opened) => {
305                temp_path = Some(candidate);
306                file = Some(opened);
307                break;
308            }
309            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
310            Err(err) => {
311                return Err(err).with_context(|| {
312                    format!(
313                        "Failed to create temporary recovery artifact {}",
314                        candidate.display()
315                    )
316                });
317            }
318        }
319    }
320
321    let temp_path = temp_path.ok_or_else(|| {
322        anyhow::anyhow!(
323            "Failed to allocate a temporary recovery artifact path for {}",
324            path.display()
325        )
326    })?;
327    let mut file = file.expect("temp_path is set only with an open file");
328    let write_result = (|| -> Result<()> {
329        file.write_all(contents).with_context(|| {
330            format!(
331                "Failed to write temporary recovery artifact {}",
332                temp_path.display()
333            )
334        })?;
335        file.sync_all().with_context(|| {
336            format!(
337                "Failed to sync temporary recovery artifact {}",
338                temp_path.display()
339            )
340        })?;
341        Ok(())
342    })();
343
344    if let Err(err) = write_result {
345        let _ = std::fs::remove_file(&temp_path);
346        return Err(err);
347    }
348    drop(file);
349
350    if let Err(err) = std::fs::rename(&temp_path, path) {
351        let _ = std::fs::remove_file(&temp_path);
352        return Err(err)
353            .with_context(|| format!("Failed to install recovery artifact {}", path.display()));
354    }
355    Ok(())
356}
357
358/// Generate a QR code as PNG bytes.
359///
360/// Returns PNG image data that can be written to a file.
361pub fn generate_qr_png(data: &str) -> Result<Vec<u8>> {
362    #[cfg(feature = "qr")]
363    {
364        use image::Luma;
365        use qrcode::QrCode;
366
367        let code = QrCode::new(data.as_bytes()).context("Failed to create QR code")?;
368        let image = code.render::<Luma<u8>>().build();
369
370        let mut png_bytes = Vec::new();
371        image::DynamicImage::ImageLuma8(image)
372            .write_to(
373                &mut std::io::Cursor::new(&mut png_bytes),
374                image::ImageFormat::Png,
375            )
376            .context("Failed to encode PNG")?;
377
378        Ok(png_bytes)
379    }
380
381    #[cfg(not(feature = "qr"))]
382    {
383        let _ = data;
384        bail!("QR code generation requires the 'qr' feature to be enabled")
385    }
386}
387
388/// Generate a QR code as SVG string.
389///
390/// Returns SVG markup that can be written to a file.
391pub fn generate_qr_svg(data: &str) -> Result<String> {
392    #[cfg(feature = "qr")]
393    {
394        use qrcode::QrCode;
395        use qrcode::render::svg;
396
397        let code = QrCode::new(data.as_bytes()).context("Failed to create QR code")?;
398        let svg = code
399            .render()
400            .min_dimensions(200, 200)
401            .dark_color(svg::Color("#000000"))
402            .light_color(svg::Color("#ffffff"))
403            .build();
404
405        Ok(svg)
406    }
407
408    #[cfg(not(feature = "qr"))]
409    {
410        let _ = data;
411        bail!("QR code generation requires the 'qr' feature to be enabled")
412    }
413}
414
415/// QR code generator (legacy struct interface for backward compatibility)
416pub struct QrGenerator;
417
418impl Default for QrGenerator {
419    fn default() -> Self {
420        Self::new()
421    }
422}
423
424impl QrGenerator {
425    pub fn new() -> Self {
426        Self
427    }
428
429    pub fn generate(&self, data: &str, output_path: &Path) -> Result<()> {
430        let png_data = generate_qr_png(data)?;
431        write_recovery_artifact(output_path, &png_data)?;
432        Ok(())
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use tempfile::TempDir;
440
441    #[test]
442    fn test_recovery_secret_generation() {
443        let secret = RecoverySecret::generate();
444
445        // Should have 256 bits of entropy
446        assert_eq!(secret.entropy_bits(), 256);
447        assert_eq!(secret.as_bytes().len(), 32);
448
449        // Encoded string should be valid base64url
450        assert!(!secret.encoded().is_empty());
451        assert!(!secret.encoded().contains('+')); // base64url, not base64
452        assert!(!secret.encoded().contains('/')); // base64url, not base64
453    }
454
455    #[test]
456    fn test_recovery_secret_round_trip() {
457        let secret1 = RecoverySecret::generate();
458        let encoded = secret1.encoded().to_string();
459
460        let secret2 = RecoverySecret::from_encoded(&encoded).expect("decode should work");
461        assert_eq!(secret1.as_bytes(), secret2.as_bytes());
462    }
463
464    #[test]
465    fn test_recovery_secret_minimum_entropy() {
466        // Should reject secrets with < 192 bits (NIST recommendation for long-term security)
467        let short_bytes = vec![0u8; 23]; // Only 184 bits (below 192-bit threshold)
468        assert!(RecoverySecret::from_bytes(short_bytes).is_none());
469
470        // Should accept secrets with >= 192 bits
471        let min_bytes = vec![0u8; 24]; // 192 bits (minimum acceptable)
472        assert!(RecoverySecret::from_bytes(min_bytes).is_some());
473    }
474
475    #[test]
476    fn test_recovery_secret_deterministic_encoding() {
477        // Same bytes should produce same encoding
478        let bytes = vec![1u8; 32];
479        let secret1 = RecoverySecret::from_bytes(bytes.clone()).unwrap();
480        let secret2 = RecoverySecret::from_bytes(bytes).unwrap();
481        assert_eq!(secret1.encoded(), secret2.encoded());
482    }
483
484    #[test]
485    #[cfg(unix)]
486    fn test_recovery_artifacts_write_to_dir_rejects_symlinked_secret_file() {
487        use std::os::unix::fs::symlink;
488
489        let tmp = TempDir::new().expect("create temp dir");
490        let private_dir = tmp.path().join("private");
491        let outside = tmp.path().join("outside");
492        std::fs::create_dir_all(&private_dir).unwrap();
493        std::fs::create_dir_all(&outside).unwrap();
494        let protected = outside.join("protected-secret.txt");
495        std::fs::write(&protected, "do not overwrite").unwrap();
496        symlink(&protected, private_dir.join("recovery-secret.txt")).unwrap();
497
498        let secret = RecoverySecret::from_bytes(vec![1u8; 32]).unwrap();
499        let artifacts = RecoveryArtifacts {
500            secret,
501            secret_text: "safe secret text".to_string(),
502            qr_png: b"png".to_vec(),
503            qr_svg: "<svg></svg>".to_string(),
504        };
505
506        let err = artifacts.write_to_dir(&private_dir).unwrap_err();
507        let rendered = format!("{err:#}");
508
509        assert!(
510            rendered.contains("must not be a symlink"),
511            "unexpected error: {err:#}"
512        );
513        assert_eq!(
514            std::fs::read_to_string(&protected).unwrap(),
515            "do not overwrite"
516        );
517        assert!(
518            std::fs::symlink_metadata(private_dir.join("recovery-secret.txt"))
519                .unwrap()
520                .file_type()
521                .is_symlink(),
522            "rejected recovery secret symlink should be left intact"
523        );
524    }
525
526    #[test]
527    #[cfg(feature = "qr")]
528    fn test_qr_png_generation() {
529        let data = "test-secret-data-12345";
530        let png = generate_qr_png(data).expect("PNG generation should work");
531
532        // Should produce valid PNG (starts with PNG magic bytes)
533        assert!(png.len() > 100);
534        assert_eq!(&png[0..8], b"\x89PNG\r\n\x1a\n");
535    }
536
537    #[test]
538    #[cfg(feature = "qr")]
539    fn test_qr_svg_generation() {
540        let data = "test-secret-data-12345";
541        let svg = generate_qr_svg(data).expect("SVG generation should work");
542
543        // Should produce valid SVG
544        assert!(svg.contains("<svg"));
545        assert!(svg.contains("</svg>"));
546    }
547
548    #[test]
549    #[cfg(feature = "qr")]
550    fn test_recovery_artifacts_generation() {
551        let artifacts =
552            RecoveryArtifacts::generate("test-archive").expect("Artifacts generation should work");
553
554        // Secret should be 256 bits
555        assert_eq!(artifacts.secret.entropy_bits(), 256);
556
557        // Text file should contain the secret
558        assert!(artifacts.secret_text.contains(artifacts.secret.encoded()));
559        assert!(artifacts.secret_text.contains("test-archive"));
560        assert!(artifacts.secret_text.contains("CASS RECOVERY SECRET"));
561
562        // PNG should be valid
563        assert!(artifacts.qr_png.len() > 100);
564        assert_eq!(&artifacts.qr_png[0..8], b"\x89PNG\r\n\x1a\n");
565
566        // SVG should be valid
567        assert!(artifacts.qr_svg.contains("<svg"));
568    }
569
570    #[test]
571    #[cfg(feature = "qr")]
572    fn test_recovery_artifacts_write_to_dir() {
573        let tmp = TempDir::new().expect("create temp dir");
574        let private_dir = tmp.path().join("private");
575
576        let artifacts =
577            RecoveryArtifacts::generate("test-archive").expect("Artifacts generation should work");
578
579        artifacts
580            .write_to_dir(&private_dir)
581            .expect("Writing should work");
582
583        // All files should exist
584        assert!(private_dir.join("recovery-secret.txt").exists());
585        assert!(private_dir.join("qr-code.png").exists());
586        assert!(private_dir.join("qr-code.svg").exists());
587
588        // Verify secret file content
589        let secret_content =
590            std::fs::read_to_string(private_dir.join("recovery-secret.txt")).unwrap();
591        assert!(secret_content.contains(artifacts.secret.encoded()));
592    }
593
594    #[test]
595    #[cfg(feature = "qr")]
596    fn test_qr_code_encodes_exact_secret() {
597        // Generate artifacts
598        let artifacts =
599            RecoveryArtifacts::generate("test-archive").expect("Artifacts generation should work");
600
601        // The QR codes should encode the exact secret
602        // (We can't easily decode without an external library, but we verify
603        // the same data goes into both PNG and SVG generation)
604        let png1 = generate_qr_png(artifacts.secret.encoded()).unwrap();
605        let png2 = generate_qr_png(artifacts.secret.encoded()).unwrap();
606        assert_eq!(png1, png2, "Same input should produce same output");
607    }
608}