forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
6.1 KiB
1//! Instance key handling and XChaCha20-Poly1305 sealing of secrets at rest.
2
3use std::path::Path;
4
5use base64::prelude::{BASE64_STANDARD, Engine as _};
6use chacha20poly1305::aead::Aead;
7use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce};
8
9use crate::error::{Error, Result};
10
11const PREFIX: &str = "v1:";
12const NONCE_LEN: usize = 24;
13
14/// Loads the 32-byte instance key at `path`, creating it (mode 0600) if missing.
15pub 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
26fn 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
54fn 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.
80pub 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`].
94pub 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)]
114mod 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}