Skip to main content

lfsx_server/storage/
crypt.rs

1use chacha20poly1305::aead::{Aead, KeyInit, Payload};
2use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
3
4use crate::error::Error;
5
6// What encryption at rest is for, said plainly so nobody reads more into it than
7// is there: it protects the bytes on a disk somebody else can read. A stolen
8// drive, a leaked backup, a decommissioned volume, a bucket whose provider is
9// not you. It does not protect against anyone who has the running server,
10// because that process holds the key by construction.
11
12pub const KEY: usize = 32;
13pub const SALT: usize = 16;
14pub const ID: usize = 4;
15pub const TAG: u64 = 16;
16
17// A key is identified by a hash of itself rather than by a number an operator
18// assigns. Two things follow, and both are the point: an id can never name a
19// different key than the one it was written with, and rotating is appending a
20// line rather than remembering which number is next.
21pub type KeyId = [u8; ID];
22
23pub struct Keyring {
24    // The first key is the one writes use. Every key is accepted for reads,
25    // which is what makes rotation something other than re-encrypting the store
26    // in one go.
27    keys: Vec<([u8; KEY], KeyId)>,
28}
29
30impl Keyring {
31    pub fn from_source(source: &crate::config::KeySource) -> Result<Self, Error> {
32        match source {
33            crate::config::KeySource::File(path) => Self::load(path),
34            crate::config::KeySource::Command(hook) => Self::exec(hook),
35        }
36    }
37
38    pub fn load(path: &std::path::Path) -> Result<Self, Error> {
39        let contents = std::fs::read_to_string(path).map_err(|error| {
40            Error::Storage(std::io::Error::other(format!(
41                "the encryption key file at {} could not be read: {error}",
42                path.display()
43            )))
44        })?;
45
46        Self::parse(&contents)
47    }
48
49    // The hook runs through the platform shell, because "the command a KMS
50    // documents" always carries arguments, and its stdout is read exactly like
51    // the key file: hex keys one per line, first line writes. The keys never
52    // rest on disk, the audit trail is the source's own, and rotation stays
53    // "the source returns a new first line". A failure is spelled out with the
54    // command's stderr, because the operator debugging this sees nothing else.
55    fn exec(hook: &str) -> Result<Self, Error> {
56        let output = shell(hook).output().map_err(|error| {
57            Error::Storage(std::io::Error::other(format!(
58                "the encryption key command could not be run: {error}"
59            )))
60        })?;
61
62        if !output.status.success() {
63            return Err(Error::Storage(std::io::Error::other(format!(
64                "the encryption key command failed ({}): {}",
65                output.status,
66                String::from_utf8_lossy(&output.stderr).trim()
67            ))));
68        }
69
70        Self::parse(&String::from_utf8_lossy(&output.stdout))
71    }
72
73    pub(super) fn parse(contents: &str) -> Result<Self, Error> {
74        let mut keys: Vec<([u8; KEY], KeyId)> = Vec::new();
75
76        for line in contents.lines() {
77            let line = line.trim();
78            if line.is_empty() || line.starts_with('#') {
79                continue;
80            }
81
82            let raw = hex::decode(line)
83                .ok()
84                .filter(|raw| raw.len() == KEY)
85                .ok_or(Error::Misconfigured(
86                    "an encryption key must be 32 bytes as 64 hex characters, one key per line",
87                ))?;
88
89            let mut key = [0u8; KEY];
90            key.copy_from_slice(&raw);
91            let id = identify(&key);
92
93            // Two keys answering to the same id would make a stored object
94            // ambiguous, and the object cannot say which one it meant. Four
95            // bytes of a hash make this vanishingly unlikely and free to check,
96            // and a duplicated line is the case that actually happens.
97            if keys.iter().any(|(_, known)| *known == id) {
98                return Err(Error::Misconfigured(
99                    "two encryption keys hash to the same id: the same key is probably listed twice",
100                ));
101            }
102
103            keys.push((key, id));
104        }
105
106        if keys.is_empty() {
107            return Err(Error::Misconfigured(
108                "the encryption key file holds no keys",
109            ));
110        }
111
112        Ok(Self { keys })
113    }
114
115    pub fn writing(&self) -> ObjectKey {
116        let (key, id) = &self.keys[0];
117
118        ObjectKey::derive(key, *id, random_salt())
119    }
120
121    pub fn reading(&self, id: KeyId, salt: [u8; SALT]) -> Result<ObjectKey, Error> {
122        self.keys
123            .iter()
124            .find(|(_, known)| *known == id)
125            .map(|(key, id)| ObjectKey::derive(key, *id, salt))
126            .ok_or(Error::UnknownKey)
127    }
128}
129
130fn identify(key: &[u8; KEY]) -> KeyId {
131    let mut id = [0u8; ID];
132    id.copy_from_slice(&blake3::hash(key).as_bytes()[..ID]);
133    id
134}
135
136fn random_salt() -> [u8; SALT] {
137    let mut salt = [0u8; SALT];
138    getrandom::fill(&mut salt).expect("the operating system has a random number generator");
139    salt
140}
141
142// One key per object, derived from the master key and a salt stored with the
143// object. It costs a hash per open and buys the thing that matters: a nonce is
144// only ever a frame counter, so two objects cannot collide on one however many
145// of them a store holds. Deriving per object is what makes that true by
146// construction rather than by a birthday bound on a random nonce prefix.
147pub struct ObjectKey {
148    cipher: ChaCha20Poly1305,
149    id: KeyId,
150    salt: [u8; SALT],
151}
152
153const CONTEXT: &str = "LFSX 2026-08-16 object encryption key";
154
155impl ObjectKey {
156    fn derive(master: &[u8; KEY], id: KeyId, salt: [u8; SALT]) -> Self {
157        let mut hasher = blake3::Hasher::new_derive_key(CONTEXT);
158        hasher.update(master);
159        hasher.update(&salt);
160        let derived = hasher.finalize();
161
162        Self {
163            cipher: ChaCha20Poly1305::new(&Key::from(*derived.as_bytes())),
164            id,
165            salt,
166        }
167    }
168
169    pub fn id(&self) -> KeyId {
170        self.id
171    }
172
173    pub fn salt(&self) -> [u8; SALT] {
174        self.salt
175    }
176
177    pub fn seal(&self, frame: u32, last: bool, oid: &str, plain: &[u8]) -> Result<Vec<u8>, Error> {
178        self.cipher
179            .encrypt(
180                &nonce(frame),
181                Payload {
182                    msg: plain,
183                    aad: &associated(frame, last, oid),
184                },
185            )
186            .map_err(|_| Error::Storage(std::io::Error::other("a frame could not be encrypted")))
187    }
188
189    pub fn open(&self, frame: u32, last: bool, oid: &str, sealed: &[u8]) -> Result<Vec<u8>, Error> {
190        self.cipher
191            .decrypt(
192                &nonce(frame),
193                Payload {
194                    msg: sealed,
195                    aad: &associated(frame, last, oid),
196                },
197            )
198            .map_err(|_| Error::Tampered)
199    }
200}
201
202fn nonce(frame: u32) -> Nonce {
203    let mut bytes = [0u8; 12];
204    bytes[8..].copy_from_slice(&frame.to_be_bytes());
205
206    Nonce::from(bytes)
207}
208
209// What each frame is bound to, so that a frame is only ever valid where it was
210// written. The index stops two frames of one object being swapped; the last-frame
211// flag stops an object being truncated to a shorter one that still verifies; the
212// object id stops a whole file being moved on top of another, which matters more
213// here than usual because the shared content store means one file answers for
214// every repository that pushed those bytes.
215fn associated(frame: u32, last: bool, oid: &str) -> Vec<u8> {
216    let mut aad = Vec::with_capacity(oid.len() + 5);
217    aad.extend_from_slice(oid.as_bytes());
218    aad.extend_from_slice(&frame.to_le_bytes());
219    aad.push(u8::from(last));
220    aad
221}
222
223#[cfg(test)]
224mod tests;
225
226#[cfg(unix)]
227fn shell(hook: &str) -> std::process::Command {
228    let mut command = std::process::Command::new("sh");
229    command.arg("-c").arg(hook);
230    command
231}
232
233#[cfg(windows)]
234fn shell(hook: &str) -> std::process::Command {
235    let mut command = std::process::Command::new("cmd");
236    command.arg("/C").arg(hook);
237    command
238}