//! Instance key handling and XChaCha20-Poly1305 sealing of secrets at rest. use std::path::Path; use base64::prelude::{BASE64_STANDARD, Engine as _}; use chacha20poly1305::aead::Aead; use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce}; use crate::error::{Error, Result}; const PREFIX: &str = "v1:"; const NONCE_LEN: usize = 24; /// Loads the 32-byte instance key at `path`, creating it (mode 0600) if missing. pub fn load_or_create_key(path: &Path) -> Result<[u8; 32]> { if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { std::fs::create_dir_all(parent)?; } match create_key(path) { Ok(key) => Ok(key), Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => read_key(path), Err(e) => Err(e.into()), } } fn create_key(path: &Path) -> std::io::Result<[u8; 32]> { use std::io::Write; use rand::TryRng; let mut key = [0u8; 32]; rand::rngs::SysRng .try_fill_bytes(&mut key) .map_err(std::io::Error::other)?; let mut options = std::fs::OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } let mut file = options.open(path)?; let written = file.write_all(&key).and_then(|()| file.sync_all()); if let Err(e) = written { // Do not leave a truncated key behind; the next start would reject it. drop(file); let _ = std::fs::remove_file(path); return Err(e); } Ok(key) } fn read_key(path: &Path) -> Result<[u8; 32]> { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mode = std::fs::metadata(path)?.permissions().mode(); if mode & 0o077 != 0 { return Err(Error::Crypto(format!( "instance key {} is group/world accessible (mode {:o}); expected 0600", path.display(), mode & 0o777 ))); } } let bytes = std::fs::read(path)?; <[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| { Error::Crypto(format!( "instance key {} has length {}, expected 32", path.display(), bytes.len() )) }) } /// Seals `plaintext`. Output format: `"v1:" + base64(nonce || ciphertext)`. /// /// Errors if the cipher rejects the input; the caller must not store plaintext instead. pub fn seal(key: &[u8; 32], plaintext: &[u8]) -> Result { let mut nonce = [0u8; NONCE_LEN]; rand::fill(&mut nonce); let cipher = XChaCha20Poly1305::new(key.into()); let ciphertext = cipher .encrypt(&XNonce::from(nonce), plaintext) .map_err(|_| Error::Crypto("sealing failed".into()))?; let mut blob = Vec::with_capacity(NONCE_LEN + ciphertext.len()); blob.extend_from_slice(&nonce); blob.extend_from_slice(&ciphertext); Ok(format!("{PREFIX}{}", BASE64_STANDARD.encode(blob))) } /// Opens a value produced by [`seal`]. pub fn open(key: &[u8; 32], sealed: &str) -> Result> { let encoded = sealed .strip_prefix(PREFIX) .ok_or_else(|| Error::Crypto("sealed value has no v1 prefix".into()))?; let blob = BASE64_STANDARD .decode(encoded) .map_err(|_| Error::Crypto("sealed value is not valid base64".into()))?; if blob.len() < NONCE_LEN { return Err(Error::Crypto("sealed value is too short".into())); } let (nonce, ciphertext) = blob.split_at(NONCE_LEN); let nonce = <[u8; NONCE_LEN]>::try_from(nonce) .map_err(|_| Error::Crypto("sealed value has a bad nonce".into()))?; let cipher = XChaCha20Poly1305::new(key.into()); cipher .decrypt(&XNonce::from(nonce), ciphertext) .map_err(|_| Error::Crypto("sealed value failed authentication".into())) } #[cfg(test)] mod tests { use super::*; #[test] fn seal_open_round_trip_and_format() { let key = [7u8; 32]; let sealed = seal(&key, b"hello secret").unwrap(); assert!(sealed.starts_with("v1:")); assert_eq!(open(&key, &sealed).unwrap(), b"hello secret"); // Fresh nonce each time. assert_ne!(sealed, seal(&key, b"hello secret").unwrap()); } #[test] fn open_rejects_wrong_key_tamper_and_garbage() { let key = [7u8; 32]; let other = [8u8; 32]; let sealed = seal(&key, b"data").unwrap(); assert!(open(&other, &sealed).is_err()); let mut tampered = sealed.clone(); let last = tampered.pop().unwrap(); tampered.push(if last == 'A' { 'B' } else { 'A' }); assert!(open(&key, &tampered).is_err()); assert!(open(&key, "data").is_err()); assert!(open(&key, "v1:!!!").is_err()); assert!(open(&key, "v1:AAAA").is_err()); assert!(open(&key, "").is_err()); } #[test] fn key_is_created_with_32_bytes_and_reloaded() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("sub").join("instance.key"); let created = load_or_create_key(&path).unwrap(); assert_eq!(std::fs::read(&path).unwrap().len(), 32); assert_eq!(load_or_create_key(&path).unwrap(), created); } #[cfg(unix)] #[test] fn key_file_mode_is_0600_and_loose_modes_are_rejected() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("instance.key"); load_or_create_key(&path).unwrap(); let mode = std::fs::metadata(&path).unwrap().permissions().mode(); assert_eq!(mode & 0o777, 0o600); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap(); assert!(load_or_create_key(&path).is_err()); } #[test] fn wrong_length_key_is_rejected() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("instance.key"); std::fs::write(&path, [1u8; 31]).unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); } assert!(matches!(load_or_create_key(&path), Err(Error::Crypto(_)))); } }