Tahinli/forge
public0 watching
★ 0
| 1 | //! Instance key handling and XChaCha20-Poly1305 sealing of secrets at rest. |
| 2 | |
| 3 | use std::path::Path; |
| 4 | |
| 5 | use base64::prelude::{BASE64_STANDARD, Engine as _}; |
| 6 | use chacha20poly1305::aead::Aead; |
| 7 | use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce}; |
| 8 | |
| 9 | use crate::error::{Error, Result}; |
| 10 | |
| 11 | const PREFIX: &str = "v1:"; |
| 12 | const NONCE_LEN: usize = 24; |
| 13 | |
| 14 | /// Loads the 32-byte instance key at `path`, creating it (mode 0600) if missing. |
| 15 | pub fn load_or_create_key(path: &Path) -> Result<[u8; 32]> { |
| 16 | if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { |
| 17 | std::fs::create_dir_all(parent)?; |
| 18 | } |
| 19 | match create_key(path) { |
| 20 | Ok(key) => Ok(key), |
| 21 | Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => read_key(path), |
| 22 | Err(e) => Err(e.into()), |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | fn create_key(path: &Path) -> std::io::Result<[u8; 32]> { |
| 27 | use std::io::Write; |
| 28 | |
| 29 | use rand::TryRng; |
| 30 | |
| 31 | let mut key = [0u8; 32]; |
| 32 | rand::rngs::SysRng |
| 33 | .try_fill_bytes(&mut key) |
| 34 | .map_err(std::io::Error::other)?; |
| 35 | |
| 36 | let mut options = std::fs::OpenOptions::new(); |
| 37 | options.write(true).create_new(true); |
| 38 | #[cfg(unix)] |
| 39 | { |
| 40 | use std::os::unix::fs::OpenOptionsExt; |
| 41 | options.mode(0o600); |
| 42 | } |
| 43 | let mut file = options.open(path)?; |
| 44 | let written = file.write_all(&key).and_then(|()| file.sync_all()); |
| 45 | if let Err(e) = written { |
| 46 | // Do not leave a truncated key behind; the next start would reject it. |
| 47 | drop(file); |
| 48 | let _ = std::fs::remove_file(path); |
| 49 | return Err(e); |
| 50 | } |
| 51 | Ok(key) |
| 52 | } |
| 53 | |
| 54 | fn read_key(path: &Path) -> Result<[u8; 32]> { |
| 55 | #[cfg(unix)] |
| 56 | { |
| 57 | use std::os::unix::fs::PermissionsExt; |
| 58 | let mode = std::fs::metadata(path)?.permissions().mode(); |
| 59 | if mode & 0o077 != 0 { |
| 60 | return Err(Error::Crypto(format!( |
| 61 | "instance key {} is group/world accessible (mode {:o}); expected 0600", |
| 62 | path.display(), |
| 63 | mode & 0o777 |
| 64 | ))); |
| 65 | } |
| 66 | } |
| 67 | let bytes = std::fs::read(path)?; |
| 68 | <[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| { |
| 69 | Error::Crypto(format!( |
| 70 | "instance key {} has length {}, expected 32", |
| 71 | path.display(), |
| 72 | bytes.len() |
| 73 | )) |
| 74 | }) |
| 75 | } |
| 76 | |
| 77 | /// Seals `plaintext`. Output format: `"v1:" + base64(nonce || ciphertext)`. |
| 78 | /// |
| 79 | /// Errors if the cipher rejects the input; the caller must not store plaintext instead. |
| 80 | pub fn seal(key: &[u8; 32], plaintext: &[u8]) -> Result<String> { |
| 81 | let mut nonce = [0u8; NONCE_LEN]; |
| 82 | rand::fill(&mut nonce); |
| 83 | let cipher = XChaCha20Poly1305::new(key.into()); |
| 84 | let ciphertext = cipher |
| 85 | .encrypt(&XNonce::from(nonce), plaintext) |
| 86 | .map_err(|_| Error::Crypto("sealing failed".into()))?; |
| 87 | let mut blob = Vec::with_capacity(NONCE_LEN + ciphertext.len()); |
| 88 | blob.extend_from_slice(&nonce); |
| 89 | blob.extend_from_slice(&ciphertext); |
| 90 | Ok(format!("{PREFIX}{}", BASE64_STANDARD.encode(blob))) |
| 91 | } |
| 92 | |
| 93 | /// Opens a value produced by [`seal`]. |
| 94 | pub fn open(key: &[u8; 32], sealed: &str) -> Result<Vec<u8>> { |
| 95 | let encoded = sealed |
| 96 | .strip_prefix(PREFIX) |
| 97 | .ok_or_else(|| Error::Crypto("sealed value has no v1 prefix".into()))?; |
| 98 | let blob = BASE64_STANDARD |
| 99 | .decode(encoded) |
| 100 | .map_err(|_| Error::Crypto("sealed value is not valid base64".into()))?; |
| 101 | if blob.len() < NONCE_LEN { |
| 102 | return Err(Error::Crypto("sealed value is too short".into())); |
| 103 | } |
| 104 | let (nonce, ciphertext) = blob.split_at(NONCE_LEN); |
| 105 | let nonce = <[u8; NONCE_LEN]>::try_from(nonce) |
| 106 | .map_err(|_| Error::Crypto("sealed value has a bad nonce".into()))?; |
| 107 | let cipher = XChaCha20Poly1305::new(key.into()); |
| 108 | cipher |
| 109 | .decrypt(&XNonce::from(nonce), ciphertext) |
| 110 | .map_err(|_| Error::Crypto("sealed value failed authentication".into())) |
| 111 | } |
| 112 | |
| 113 | #[cfg(test)] |
| 114 | mod tests { |
| 115 | use super::*; |
| 116 | |
| 117 | #[test] |
| 118 | fn seal_open_round_trip_and_format() { |
| 119 | let key = [7u8; 32]; |
| 120 | let sealed = seal(&key, b"hello secret").unwrap(); |
| 121 | assert!(sealed.starts_with("v1:")); |
| 122 | assert_eq!(open(&key, &sealed).unwrap(), b"hello secret"); |
| 123 | // Fresh nonce each time. |
| 124 | assert_ne!(sealed, seal(&key, b"hello secret").unwrap()); |
| 125 | } |
| 126 | |
| 127 | #[test] |
| 128 | fn open_rejects_wrong_key_tamper_and_garbage() { |
| 129 | let key = [7u8; 32]; |
| 130 | let other = [8u8; 32]; |
| 131 | let sealed = seal(&key, b"data").unwrap(); |
| 132 | assert!(open(&other, &sealed).is_err()); |
| 133 | let mut tampered = sealed.clone(); |
| 134 | let last = tampered.pop().unwrap(); |
| 135 | tampered.push(if last == 'A' { 'B' } else { 'A' }); |
| 136 | assert!(open(&key, &tampered).is_err()); |
| 137 | assert!(open(&key, "data").is_err()); |
| 138 | assert!(open(&key, "v1:!!!").is_err()); |
| 139 | assert!(open(&key, "v1:AAAA").is_err()); |
| 140 | assert!(open(&key, "").is_err()); |
| 141 | } |
| 142 | |
| 143 | #[test] |
| 144 | fn key_is_created_with_32_bytes_and_reloaded() { |
| 145 | let dir = tempfile::tempdir().unwrap(); |
| 146 | let path = dir.path().join("sub").join("instance.key"); |
| 147 | let created = load_or_create_key(&path).unwrap(); |
| 148 | assert_eq!(std::fs::read(&path).unwrap().len(), 32); |
| 149 | assert_eq!(load_or_create_key(&path).unwrap(), created); |
| 150 | } |
| 151 | |
| 152 | #[cfg(unix)] |
| 153 | #[test] |
| 154 | fn key_file_mode_is_0600_and_loose_modes_are_rejected() { |
| 155 | use std::os::unix::fs::PermissionsExt; |
| 156 | let dir = tempfile::tempdir().unwrap(); |
| 157 | let path = dir.path().join("instance.key"); |
| 158 | load_or_create_key(&path).unwrap(); |
| 159 | let mode = std::fs::metadata(&path).unwrap().permissions().mode(); |
| 160 | assert_eq!(mode & 0o777, 0o600); |
| 161 | |
| 162 | std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap(); |
| 163 | assert!(load_or_create_key(&path).is_err()); |
| 164 | } |
| 165 | |
| 166 | #[test] |
| 167 | fn wrong_length_key_is_rejected() { |
| 168 | let dir = tempfile::tempdir().unwrap(); |
| 169 | let path = dir.path().join("instance.key"); |
| 170 | std::fs::write(&path, [1u8; 31]).unwrap(); |
| 171 | #[cfg(unix)] |
| 172 | { |
| 173 | use std::os::unix::fs::PermissionsExt; |
| 174 | std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); |
| 175 | } |
| 176 | assert!(matches!(load_or_create_key(&path), Err(Error::Crypto(_)))); |
| 177 | } |
| 178 | } |