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 load(path: &std::path::Path) -> Result<Self, Error> {
32        let contents = std::fs::read_to_string(path).map_err(|error| {
33            Error::Storage(std::io::Error::other(format!(
34                "the encryption key file at {} could not be read: {error}",
35                path.display()
36            )))
37        })?;
38
39        Self::parse(&contents)
40    }
41
42    pub(super) fn parse(contents: &str) -> Result<Self, Error> {
43        let mut keys: Vec<([u8; KEY], KeyId)> = Vec::new();
44
45        for line in contents.lines() {
46            let line = line.trim();
47            if line.is_empty() || line.starts_with('#') {
48                continue;
49            }
50
51            let raw = hex::decode(line)
52                .ok()
53                .filter(|raw| raw.len() == KEY)
54                .ok_or(Error::Misconfigured(
55                    "an encryption key must be 32 bytes as 64 hex characters, one key per line",
56                ))?;
57
58            let mut key = [0u8; KEY];
59            key.copy_from_slice(&raw);
60            let id = identify(&key);
61
62            // Two keys answering to the same id would make a stored object
63            // ambiguous, and the object cannot say which one it meant. Four
64            // bytes of a hash make this vanishingly unlikely and free to check,
65            // and a duplicated line is the case that actually happens.
66            if keys.iter().any(|(_, known)| *known == id) {
67                return Err(Error::Misconfigured(
68                    "two encryption keys hash to the same id — the same key is probably listed twice",
69                ));
70            }
71
72            keys.push((key, id));
73        }
74
75        if keys.is_empty() {
76            return Err(Error::Misconfigured(
77                "the encryption key file holds no keys",
78            ));
79        }
80
81        Ok(Self { keys })
82    }
83
84    pub fn writing(&self) -> ObjectKey {
85        let (key, id) = &self.keys[0];
86
87        ObjectKey::derive(key, *id, random_salt())
88    }
89
90    pub fn reading(&self, id: KeyId, salt: [u8; SALT]) -> Result<ObjectKey, Error> {
91        self.keys
92            .iter()
93            .find(|(_, known)| *known == id)
94            .map(|(key, id)| ObjectKey::derive(key, *id, salt))
95            .ok_or(Error::UnknownKey)
96    }
97}
98
99fn identify(key: &[u8; KEY]) -> KeyId {
100    let mut id = [0u8; ID];
101    id.copy_from_slice(&blake3::hash(key).as_bytes()[..ID]);
102    id
103}
104
105fn random_salt() -> [u8; SALT] {
106    let mut salt = [0u8; SALT];
107    getrandom::fill(&mut salt).expect("the operating system has a random number generator");
108    salt
109}
110
111// One key per object, derived from the master key and a salt stored with the
112// object. It costs a hash per open and buys the thing that matters: a nonce is
113// only ever a frame counter, so two objects cannot collide on one however many
114// of them a store holds. Deriving per object is what makes that true by
115// construction rather than by a birthday bound on a random nonce prefix.
116pub struct ObjectKey {
117    cipher: ChaCha20Poly1305,
118    id: KeyId,
119    salt: [u8; SALT],
120}
121
122const CONTEXT: &str = "LFSX 2026-08-16 object encryption key";
123
124impl ObjectKey {
125    fn derive(master: &[u8; KEY], id: KeyId, salt: [u8; SALT]) -> Self {
126        let mut hasher = blake3::Hasher::new_derive_key(CONTEXT);
127        hasher.update(master);
128        hasher.update(&salt);
129        let derived = hasher.finalize();
130
131        Self {
132            cipher: ChaCha20Poly1305::new(&Key::from(*derived.as_bytes())),
133            id,
134            salt,
135        }
136    }
137
138    pub fn id(&self) -> KeyId {
139        self.id
140    }
141
142    pub fn salt(&self) -> [u8; SALT] {
143        self.salt
144    }
145
146    pub fn seal(&self, frame: u32, last: bool, oid: &str, plain: &[u8]) -> Result<Vec<u8>, Error> {
147        self.cipher
148            .encrypt(
149                &nonce(frame),
150                Payload {
151                    msg: plain,
152                    aad: &associated(frame, last, oid),
153                },
154            )
155            .map_err(|_| Error::Storage(std::io::Error::other("a frame could not be encrypted")))
156    }
157
158    pub fn open(&self, frame: u32, last: bool, oid: &str, sealed: &[u8]) -> Result<Vec<u8>, Error> {
159        self.cipher
160            .decrypt(
161                &nonce(frame),
162                Payload {
163                    msg: sealed,
164                    aad: &associated(frame, last, oid),
165                },
166            )
167            .map_err(|_| Error::Tampered)
168    }
169}
170
171fn nonce(frame: u32) -> Nonce {
172    let mut bytes = [0u8; 12];
173    bytes[8..].copy_from_slice(&frame.to_be_bytes());
174
175    Nonce::from(bytes)
176}
177
178// What each frame is bound to, so that a frame is only ever valid where it was
179// written. The index stops two frames of one object being swapped; the last-frame
180// flag stops an object being truncated to a shorter one that still verifies; the
181// object id stops a whole file being moved on top of another, which matters more
182// here than usual because the shared content store means one file answers for
183// every repository that pushed those bytes.
184fn associated(frame: u32, last: bool, oid: &str) -> Vec<u8> {
185    let mut aad = Vec::with_capacity(oid.len() + 5);
186    aad.extend_from_slice(oid.as_bytes());
187    aad.extend_from_slice(&frame.to_le_bytes());
188    aad.push(u8::from(last));
189    aad
190}
191
192#[cfg(test)]
193mod tests;