#![allow(unexpected_cfgs)]
use anyhow::{Context, Result, bail};
use base64::prelude::*;
use chrono::Utc;
use rand::Rng;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use tracing::info;
use zeroize::Zeroize;
const RECOVERY_SECRET_BYTES: usize = 32;
#[derive(Clone)]
pub struct RecoverySecret {
bytes: Vec<u8>,
encoded: String,
}
impl std::fmt::Debug for RecoverySecret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RecoverySecret")
.field("entropy_bits", &self.entropy_bits())
.field("encoded", &"[REDACTED]")
.finish()
}
}
impl RecoverySecret {
pub fn generate() -> Self {
let mut bytes = vec![0u8; RECOVERY_SECRET_BYTES];
let mut rng = rand::rng();
rng.fill_bytes(&mut bytes);
let encoded = BASE64_URL_SAFE_NO_PAD.encode(&bytes);
Self { bytes, encoded }
}
pub fn from_bytes(bytes: Vec<u8>) -> Option<Self> {
if bytes.len() < 24 {
return None;
}
let encoded = BASE64_URL_SAFE_NO_PAD.encode(&bytes);
Some(Self { bytes, encoded })
}
pub fn from_encoded(encoded: &str) -> Result<Self> {
let bytes = BASE64_URL_SAFE_NO_PAD
.decode(encoded)
.context("Invalid base64url encoding")?;
if bytes.len() < 24 {
bail!("Recovery secret too short (minimum 192 bits for long-term security)");
}
Ok(Self {
bytes,
encoded: encoded.to_string(),
})
}
pub fn as_bytes(&self) -> &[u8] {
&self.bytes
}
pub fn encoded(&self) -> &str {
&self.encoded
}
pub fn entropy_bits(&self) -> usize {
self.bytes.len() * 8
}
}
impl Drop for RecoverySecret {
fn drop(&mut self) {
self.bytes.zeroize();
let mut encoded_bytes = std::mem::take(&mut self.encoded).into_bytes();
encoded_bytes.zeroize();
}
}
pub struct RecoveryArtifacts {
pub secret: RecoverySecret,
pub secret_text: String,
pub qr_png: Vec<u8>,
pub qr_svg: String,
}
impl Drop for RecoveryArtifacts {
fn drop(&mut self) {
let mut text_bytes = std::mem::take(&mut self.secret_text).into_bytes();
text_bytes.zeroize();
self.qr_png.zeroize();
let mut svg_bytes = std::mem::take(&mut self.qr_svg).into_bytes();
svg_bytes.zeroize();
}
}
impl RecoveryArtifacts {
pub fn generate(archive_name: &str) -> Result<Self> {
let secret = RecoverySecret::generate();
let timestamp = Utc::now().to_rfc3339();
let secret_text = format!(
r#"CASS RECOVERY SECRET
====================
Archive: {archive_name}
Created: {timestamp}
Secret: {secret}
IMPORTANT:
- This secret unlocks your archive if you forget your password
- Store securely (password manager, encrypted USB, safe)
- NEVER deploy this file with the public site
- The QR code encodes the same secret
[QR code path: qr-code.png]
"#,
archive_name = archive_name,
timestamp = timestamp,
secret = secret.encoded(),
);
let qr_png = generate_qr_png(secret.encoded())?;
let qr_svg = generate_qr_svg(secret.encoded())?;
info!(
entropy_bits = secret.entropy_bits(),
encoded_len = secret.encoded().len(),
"Generated recovery secret"
);
Ok(Self {
secret,
secret_text,
qr_png,
qr_svg,
})
}
pub fn write_to_dir(&self, dir: &Path) -> Result<()> {
ensure_recovery_artifact_dir(dir)?;
let secret_path = dir.join("recovery-secret.txt");
write_recovery_artifact(&secret_path, self.secret_text.as_bytes())
.context("Failed to write recovery-secret.txt")?;
let png_path = dir.join("qr-code.png");
write_recovery_artifact(&png_path, &self.qr_png).context("Failed to write qr-code.png")?;
let svg_path = dir.join("qr-code.svg");
write_recovery_artifact(&svg_path, self.qr_svg.as_bytes())
.context("Failed to write qr-code.svg")?;
info!(
dir = %dir.display(),
"Wrote recovery artifacts: recovery-secret.txt, qr-code.png, qr-code.svg"
);
Ok(())
}
}
fn ensure_recovery_artifact_dir(dir: &Path) -> Result<()> {
match std::fs::symlink_metadata(dir) {
Ok(metadata) => {
let file_type = metadata.file_type();
if file_type.is_symlink() {
bail!(
"Recovery artifact directory must not be a symlink: {}",
dir.display()
);
}
if !file_type.is_dir() {
bail!(
"Recovery artifact path must be a directory: {}",
dir.display()
);
}
Ok(())
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
std::fs::create_dir_all(dir).context("Failed to create private directory")?;
ensure_recovery_artifact_dir(dir)
}
Err(err) => Err(err)
.with_context(|| format!("Failed to inspect recovery artifact dir {}", dir.display())),
}
}
fn reject_recovery_artifact_symlink(path: &Path) -> Result<()> {
match std::fs::symlink_metadata(path) {
Ok(metadata) => {
let file_type = metadata.file_type();
if file_type.is_symlink() {
bail!(
"Recovery artifact file must not be a symlink: {}",
path.display()
);
}
if file_type.is_dir() {
bail!(
"Recovery artifact path must be a regular file, not a directory: {}",
path.display()
);
}
Ok(())
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err)
.with_context(|| format!("Failed to inspect recovery artifact {}", path.display())),
}
}
fn recovery_artifact_temp_path(path: &Path, attempt: usize) -> PathBuf {
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("artifact");
path.with_file_name(format!(
".{file_name}.tmp.{}.{}",
std::process::id(),
attempt
))
}
fn write_recovery_artifact(path: &Path, contents: &[u8]) -> Result<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
ensure_recovery_artifact_dir(parent)?;
reject_recovery_artifact_symlink(path)?;
let mut temp_path = None;
let mut file = None;
for attempt in 0..100 {
let candidate = recovery_artifact_temp_path(path, attempt);
match OpenOptions::new()
.write(true)
.create_new(true)
.open(&candidate)
{
Ok(opened) => {
temp_path = Some(candidate);
file = Some(opened);
break;
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(err) => {
return Err(err).with_context(|| {
format!(
"Failed to create temporary recovery artifact {}",
candidate.display()
)
});
}
}
}
let temp_path = temp_path.ok_or_else(|| {
anyhow::anyhow!(
"Failed to allocate a temporary recovery artifact path for {}",
path.display()
)
})?;
let mut file = file.expect("temp_path is set only with an open file");
let write_result = (|| -> Result<()> {
file.write_all(contents).with_context(|| {
format!(
"Failed to write temporary recovery artifact {}",
temp_path.display()
)
})?;
file.sync_all().with_context(|| {
format!(
"Failed to sync temporary recovery artifact {}",
temp_path.display()
)
})?;
Ok(())
})();
if let Err(err) = write_result {
let _ = std::fs::remove_file(&temp_path);
return Err(err);
}
drop(file);
if let Err(err) = std::fs::rename(&temp_path, path) {
let _ = std::fs::remove_file(&temp_path);
return Err(err)
.with_context(|| format!("Failed to install recovery artifact {}", path.display()));
}
Ok(())
}
pub fn generate_qr_png(data: &str) -> Result<Vec<u8>> {
#[cfg(feature = "qr")]
{
use image::Luma;
use qrcode::QrCode;
let code = QrCode::new(data.as_bytes()).context("Failed to create QR code")?;
let image = code.render::<Luma<u8>>().build();
let mut png_bytes = Vec::new();
image::DynamicImage::ImageLuma8(image)
.write_to(
&mut std::io::Cursor::new(&mut png_bytes),
image::ImageFormat::Png,
)
.context("Failed to encode PNG")?;
Ok(png_bytes)
}
#[cfg(not(feature = "qr"))]
{
let _ = data;
bail!("QR code generation requires the 'qr' feature to be enabled")
}
}
pub fn generate_qr_svg(data: &str) -> Result<String> {
#[cfg(feature = "qr")]
{
use qrcode::QrCode;
use qrcode::render::svg;
let code = QrCode::new(data.as_bytes()).context("Failed to create QR code")?;
let svg = code
.render()
.min_dimensions(200, 200)
.dark_color(svg::Color("#000000"))
.light_color(svg::Color("#ffffff"))
.build();
Ok(svg)
}
#[cfg(not(feature = "qr"))]
{
let _ = data;
bail!("QR code generation requires the 'qr' feature to be enabled")
}
}
pub struct QrGenerator;
impl Default for QrGenerator {
fn default() -> Self {
Self::new()
}
}
impl QrGenerator {
pub fn new() -> Self {
Self
}
pub fn generate(&self, data: &str, output_path: &Path) -> Result<()> {
let png_data = generate_qr_png(data)?;
write_recovery_artifact(output_path, &png_data)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_recovery_secret_generation() {
let secret = RecoverySecret::generate();
assert_eq!(secret.entropy_bits(), 256);
assert_eq!(secret.as_bytes().len(), 32);
assert!(!secret.encoded().is_empty());
assert!(!secret.encoded().contains('+')); assert!(!secret.encoded().contains('/')); }
#[test]
fn test_recovery_secret_round_trip() {
let secret1 = RecoverySecret::generate();
let encoded = secret1.encoded().to_string();
let secret2 = RecoverySecret::from_encoded(&encoded).expect("decode should work");
assert_eq!(secret1.as_bytes(), secret2.as_bytes());
}
#[test]
fn test_recovery_secret_minimum_entropy() {
let short_bytes = vec![0u8; 23]; assert!(RecoverySecret::from_bytes(short_bytes).is_none());
let min_bytes = vec![0u8; 24]; assert!(RecoverySecret::from_bytes(min_bytes).is_some());
}
#[test]
fn test_recovery_secret_deterministic_encoding() {
let bytes = vec![1u8; 32];
let secret1 = RecoverySecret::from_bytes(bytes.clone()).unwrap();
let secret2 = RecoverySecret::from_bytes(bytes).unwrap();
assert_eq!(secret1.encoded(), secret2.encoded());
}
#[test]
#[cfg(unix)]
fn test_recovery_artifacts_write_to_dir_rejects_symlinked_secret_file() {
use std::os::unix::fs::symlink;
let tmp = TempDir::new().expect("create temp dir");
let private_dir = tmp.path().join("private");
let outside = tmp.path().join("outside");
std::fs::create_dir_all(&private_dir).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let protected = outside.join("protected-secret.txt");
std::fs::write(&protected, "do not overwrite").unwrap();
symlink(&protected, private_dir.join("recovery-secret.txt")).unwrap();
let secret = RecoverySecret::from_bytes(vec![1u8; 32]).unwrap();
let artifacts = RecoveryArtifacts {
secret,
secret_text: "safe secret text".to_string(),
qr_png: b"png".to_vec(),
qr_svg: "<svg></svg>".to_string(),
};
let err = artifacts.write_to_dir(&private_dir).unwrap_err();
let rendered = format!("{err:#}");
assert!(
rendered.contains("must not be a symlink"),
"unexpected error: {err:#}"
);
assert_eq!(
std::fs::read_to_string(&protected).unwrap(),
"do not overwrite"
);
assert!(
std::fs::symlink_metadata(private_dir.join("recovery-secret.txt"))
.unwrap()
.file_type()
.is_symlink(),
"rejected recovery secret symlink should be left intact"
);
}
#[test]
#[cfg(feature = "qr")]
fn test_qr_png_generation() {
let data = "test-secret-data-12345";
let png = generate_qr_png(data).expect("PNG generation should work");
assert!(png.len() > 100);
assert_eq!(&png[0..8], b"\x89PNG\r\n\x1a\n");
}
#[test]
#[cfg(feature = "qr")]
fn test_qr_svg_generation() {
let data = "test-secret-data-12345";
let svg = generate_qr_svg(data).expect("SVG generation should work");
assert!(svg.contains("<svg"));
assert!(svg.contains("</svg>"));
}
#[test]
#[cfg(feature = "qr")]
fn test_recovery_artifacts_generation() {
let artifacts =
RecoveryArtifacts::generate("test-archive").expect("Artifacts generation should work");
assert_eq!(artifacts.secret.entropy_bits(), 256);
assert!(artifacts.secret_text.contains(artifacts.secret.encoded()));
assert!(artifacts.secret_text.contains("test-archive"));
assert!(artifacts.secret_text.contains("CASS RECOVERY SECRET"));
assert!(artifacts.qr_png.len() > 100);
assert_eq!(&artifacts.qr_png[0..8], b"\x89PNG\r\n\x1a\n");
assert!(artifacts.qr_svg.contains("<svg"));
}
#[test]
#[cfg(feature = "qr")]
fn test_recovery_artifacts_write_to_dir() {
let tmp = TempDir::new().expect("create temp dir");
let private_dir = tmp.path().join("private");
let artifacts =
RecoveryArtifacts::generate("test-archive").expect("Artifacts generation should work");
artifacts
.write_to_dir(&private_dir)
.expect("Writing should work");
assert!(private_dir.join("recovery-secret.txt").exists());
assert!(private_dir.join("qr-code.png").exists());
assert!(private_dir.join("qr-code.svg").exists());
let secret_content =
std::fs::read_to_string(private_dir.join("recovery-secret.txt")).unwrap();
assert!(secret_content.contains(artifacts.secret.encoded()));
}
#[test]
#[cfg(feature = "qr")]
fn test_qr_code_encodes_exact_secret() {
let artifacts =
RecoveryArtifacts::generate("test-archive").expect("Artifacts generation should work");
let png1 = generate_qr_png(artifacts.secret.encoded()).unwrap();
let png2 = generate_qr_png(artifacts.secret.encoded()).unwrap();
assert_eq!(png1, png2, "Same input should produce same output");
}
}