Skip to main content

pb_mapper_auth/persistence/
admin_key.rs

1//! Administrator key files, instance id, and recovery-key identity checks.
2use super::super::*;
3use super::{
4    atomic_write, auth_snapshot_path, auth_wal_path, create_new_write, encrypted_auth_state_exists,
5    open_blob, truncate_auth_wal,
6};
7
8pub(crate) fn load_or_create_instance_id(
9    path: &Path,
10) -> Result<[u8; INSTANCE_ID_LEN], AuthFailure> {
11    let instance_path = path.join("server-instance-id");
12    if let Some(instance_id) = read_instance_id_file(&instance_path)? {
13        return Ok(instance_id);
14    }
15    let instance_id = random_instance_id();
16    atomic_write(&instance_path, &instance_id, 0o600)?;
17    Ok(instance_id)
18}
19
20pub(crate) fn read_instance_id_file(
21    path: &Path,
22) -> Result<Option<[u8; INSTANCE_ID_LEN]>, AuthFailure> {
23    if !path.exists() {
24        return Ok(None);
25    }
26    let bytes = std::fs::read(path).map_err(|error| {
27        AuthFailure::new(
28            "auth_state_unavailable",
29            format!("failed to read `{}`: {error}", path.display()),
30            false,
31        )
32    })?;
33    bytes.try_into().map(Some).map_err(|_| {
34        AuthFailure::new(
35            "auth_state_unavailable",
36            "server instance id must be exactly 16 bytes",
37            false,
38        )
39    })
40}
41
42/// Promote `server-instance-id.next` when the snapshot already belongs to it.
43///
44/// Reset writes that staged file, then the empty snapshot, then the live
45/// instance-id file. A crash after the snapshot lands would otherwise fail
46/// closed on the next start because the live file still has the old id.
47pub(crate) fn recover_instance_id_after_reset(
48    state_dir: &Path,
49    admin_key: &AesKeyType,
50    current: [u8; INSTANCE_ID_LEN],
51) -> Result<[u8; INSTANCE_ID_LEN], AuthFailure> {
52    let next_path = state_dir.join("server-instance-id.next");
53    let Some(next) = read_instance_id_file(&next_path)? else {
54        return Ok(current);
55    };
56    let snapshot_path = auth_snapshot_path(state_dir);
57    if !snapshot_path.exists() {
58        let _ = std::fs::remove_file(&next_path);
59        return Ok(current);
60    }
61    let bytes = std::fs::read(&snapshot_path).map_err(|error| {
62        AuthFailure::new(
63            "temporary_key_store_unavailable",
64            format!("failed to read `{}`: {error}", snapshot_path.display()),
65            false,
66        )
67    })?;
68    let Ok(plain) = open_blob(admin_key, &bytes) else {
69        return Ok(current);
70    };
71    let Ok(snapshot) = serde_json::from_slice::<PersistedSnapshot>(&plain) else {
72        return Ok(current);
73    };
74    if snapshot.instance_id == current {
75        let _ = std::fs::remove_file(&next_path);
76        return Ok(current);
77    }
78    if snapshot.instance_id != next {
79        return Ok(current);
80    }
81    // The reset snapshot is complete. Any leftover WAL still belongs to the
82    // previous instance and must not be replayed onto the new derivation id.
83    truncate_auth_wal(state_dir)?;
84    atomic_write(&state_dir.join("server-instance-id"), &next, 0o600)?;
85    let _ = std::fs::remove_file(&next_path);
86    Ok(next)
87}
88
89pub(crate) fn random_instance_id() -> [u8; INSTANCE_ID_LEN] {
90    let mut instance_id = [0_u8; INSTANCE_ID_LEN];
91    let mut rng = rand::rng();
92    for byte in &mut instance_id {
93        *byte = rng.random();
94    }
95    instance_id
96}
97
98pub(crate) fn write_admin_key(state_dir: &Path, key: &str) -> Result<(), AuthFailure> {
99    atomic_write(
100        &state_dir.join("admin.key"),
101        format!("{key}\n").as_bytes(),
102        0o600,
103    )
104}
105
106pub fn generate_admin_key() -> String {
107    const CHARSET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
108    let mut rng = rand::rng();
109    (0..32)
110        .map(|_| CHARSET[rng.random_range(0..CHARSET.len())] as char)
111        .collect()
112}
113
114pub fn initialize_admin_key(path: &Path, force: bool) -> Result<String, AuthFailure> {
115    if path.exists() && !force {
116        return Err(AuthFailure::new(
117            "administrator_key_exists",
118            format!("administrator key file `{}` already exists", path.display()),
119            false,
120        ));
121    }
122    refuse_write_if_encrypted_state(path, force)?;
123    let key = generate_admin_key();
124    atomic_write(path, format!("{key}\n").as_bytes(), 0o600)?;
125    Ok(key)
126}
127
128/// Reject anything that is not a 32-byte administrator key before it reaches disk.
129fn validate_admin_key(key: &str) -> Result<(), AuthFailure> {
130    let Credential::Admin(_) = parse_credential(key)
131        .map_err(|error| AuthFailure::new("administrator_key_invalid", error, false))?
132    else {
133        return Err(AuthFailure::new(
134            "administrator_key_invalid",
135            "administrator key file requires a 32-byte administrator key",
136            false,
137        ));
138    };
139    Ok(())
140}
141
142pub fn write_admin_key_file(path: &Path, key: &str, force: bool) -> Result<(), AuthFailure> {
143    validate_admin_key(key)?;
144    if path.exists() && !force {
145        return Err(AuthFailure::new(
146            "administrator_key_exists",
147            format!(
148                "administrator key file `{}` already exists; pass --force to replace it",
149                path.display()
150            ),
151            false,
152        ));
153    }
154    if path.file_name() == Some(std::ffi::OsStr::new("admin.key"))
155        && !key_matches_existing_state(path.parent(), key)
156    {
157        refuse_write_if_encrypted_state(path, force)?;
158    }
159    atomic_write(path, format!("{key}\n").as_bytes(), 0o600)
160}
161
162/// The sibling path a root-rotation candidate is staged at, next to the live
163/// administrator key file `path`.
164///
165/// A dotted sibling rather than a subdirectory, so the candidate lands on the
166/// same filesystem as `path` and inherits the directory's permissions.
167pub fn staged_admin_key_path(path: &Path) -> PathBuf {
168    let name = path
169        .file_name()
170        .and_then(|name| name.to_str())
171        .unwrap_or("admin.key");
172    path.with_file_name(format!(".{name}.next"))
173}
174
175/// Write a root-rotation candidate to the staged sibling of `path`, returning
176/// the staged path.
177///
178/// Refuses to replace a candidate that is already staged under a *different*
179/// key. Rotation is not idempotent, so a staged file that outlived its rotation
180/// attempt means the relay's active key is unknown: it is either the live key at
181/// `path` or that candidate. Overwriting it would destroy the only copy of a key
182/// the relay may already have installed, locking the operator out. Restaging the
183/// same key is allowed, so retrying a rotation with the candidate in hand works.
184pub fn stage_admin_key_candidate(path: &Path, key: &str) -> Result<PathBuf, AuthFailure> {
185    let staged_path = staged_admin_key_path(path);
186    if let Some(staged) = read_admin_key_file(&staged_path)? {
187        return accept_staged_candidate(path, staged_path, &staged, key);
188    }
189    validate_admin_key(key)?;
190    // Claims the path itself rather than renaming onto it. The read above is a
191    // check-then-act: two rotations racing here both pass it, and a rename always
192    // wins, so the loser's candidate — possibly the only copy of the key the
193    // relay just installed — would vanish. `O_EXCL` makes exactly one of them the
194    // winner and sends the other through the same decision as a pre-existing file.
195    if !create_new_write(&staged_path, format!("{key}\n").as_bytes(), 0o600)? {
196        let staged = read_admin_key_file(&staged_path)?.unwrap_or_default();
197        return accept_staged_candidate(path, staged_path, &staged, key);
198    }
199    Ok(staged_path)
200}
201
202/// Whether the candidate already at `staged_path` can stand in for `key`.
203///
204/// The same key means this is a retry of the rotation that staged it, so the
205/// staged file is exactly what the caller wanted. A different one — including
206/// the empty or short file a crash mid-write leaves behind — means the relay's
207/// active key is unknown, and only the operator can say which it is.
208fn accept_staged_candidate(
209    path: &Path,
210    staged_path: PathBuf,
211    staged: &str,
212    key: &str,
213) -> Result<PathBuf, AuthFailure> {
214    if staged.trim() == key.trim() {
215        return Ok(staged_path);
216    }
217    Err(AuthFailure::new(
218        "administrator_key_staged",
219        format!(
220            "a previous root rotation left an unresolved candidate at `{}`, so the relay's active \
221             administrator key is either `{}` or that candidate; determine which key the relay \
222             accepts, install it at `{}`, and delete the staged file before rotating again",
223            staged_path.display(),
224            path.display(),
225            path.display()
226        ),
227        false,
228    ))
229}
230
231/// Drop a staged candidate once the rotation it belongs to is durably recorded
232/// at the live key file.
233///
234/// A leftover staged file blocks the next rotation, but failing to remove it is
235/// not worth failing a rotation that already succeeded, so this only warns.
236pub fn discard_staged_admin_key(staged_path: &Path) {
237    if let Err(error) = std::fs::remove_file(staged_path) {
238        tracing::warn!(
239            path = %staged_path.display(),
240            %error,
241            "administrator key was rotated, but the staged candidate could not be removed"
242        );
243    }
244}
245
246/// Read an administrator key file, or `None` when it does not exist.
247fn read_admin_key_file(path: &Path) -> Result<Option<String>, AuthFailure> {
248    match std::fs::read_to_string(path) {
249        Ok(contents) => Ok(Some(contents)),
250        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
251        Err(error) => Err(AuthFailure::new(
252            "auth_state_unavailable",
253            format!("failed to read `{}`: {error}", path.display()),
254            false,
255        )),
256    }
257}
258
259pub(crate) fn reset_already_installed(
260    state_dir: &Path,
261    admin_key: &AesKeyType,
262    new_instance_id: &[u8; INSTANCE_ID_LEN],
263) -> bool {
264    let Ok(Some(live)) = read_instance_id_file(&state_dir.join("server-instance-id")) else {
265        return false;
266    };
267    if live != *new_instance_id {
268        return false;
269    }
270    let Ok(bytes) = std::fs::read(auth_snapshot_path(state_dir)) else {
271        return false;
272    };
273    let Ok(plain) = open_blob(admin_key, &bytes) else {
274        return false;
275    };
276    let Ok(snapshot) = serde_json::from_slice::<PersistedSnapshot>(&plain) else {
277        return false;
278    };
279    snapshot.instance_id == *new_instance_id
280}
281
282pub(crate) fn rotation_already_installed(state_dir: &Path, new_key: &str) -> bool {
283    key_matches_existing_snapshot(Some(state_dir), new_key)
284        && live_admin_key_matches(state_dir, new_key)
285}
286
287fn live_admin_key_matches(state_dir: &Path, new_key: &str) -> bool {
288    let Ok(raw) = std::fs::read(state_dir.join("admin.key")) else {
289        return false;
290    };
291    let Ok(text) = std::str::from_utf8(&raw) else {
292        return false;
293    };
294    text.trim().as_bytes() == new_key.trim().as_bytes()
295}
296
297pub(crate) fn key_matches_existing_snapshot(state_dir: Option<&Path>, key: &str) -> bool {
298    let Some(state_dir) = state_dir else {
299        return false;
300    };
301    let snapshot_path = auth_snapshot_path(state_dir);
302    if !snapshot_path.exists() {
303        return false;
304    }
305    let Ok(Credential::Admin(admin_key)) = parse_credential(key) else {
306        return false;
307    };
308    let Ok(bytes) = std::fs::read(&snapshot_path) else {
309        return false;
310    };
311    open_blob(&admin_key, &bytes).is_ok()
312}
313
314pub(crate) fn key_matches_existing_state(state_dir: Option<&Path>, key: &str) -> bool {
315    if key_matches_existing_snapshot(state_dir, key) {
316        return true;
317    }
318    let Some(state_dir) = state_dir else {
319        return false;
320    };
321    if auth_snapshot_path(state_dir).exists() {
322        return false;
323    }
324    let wal_path = auth_wal_path(state_dir);
325    if !wal_path.exists() {
326        return false;
327    }
328    let Ok(Credential::Admin(admin_key)) = parse_credential(key) else {
329        return false;
330    };
331    wal_decrypts_with_key(&wal_path, &admin_key)
332}
333
334fn wal_decrypts_with_key(path: &Path, admin_key: &AesKeyType) -> bool {
335    let Ok(mut file) = File::open(path) else {
336        return false;
337    };
338    let Ok(metadata) = file.metadata() else {
339        return false;
340    };
341    if metadata.len() == 0 {
342        return true;
343    }
344    let mut length = [0_u8; 4];
345    if file.read_exact(&mut length).is_err() {
346        return false;
347    }
348    let length = u32::from_be_bytes(length) as usize;
349    if length == 0 || length > 1024 * 1024 {
350        return false;
351    }
352    let mut sealed = vec![0_u8; length];
353    if file.read_exact(&mut sealed).is_err() {
354        return false;
355    }
356    open_blob(admin_key, &sealed).is_ok()
357}
358
359fn refuse_write_if_encrypted_state(path: &Path, force: bool) -> Result<(), AuthFailure> {
360    // Creating or replacing the live root while snapshot/WAL remain leaves
361    // those files encrypted under the previous key. Staging `admin.key.next`
362    // is the rotate path and must stay allowed.
363    let Some(state_dir) = path.parent() else {
364        return Ok(());
365    };
366    if !encrypted_auth_state_exists(state_dir) {
367        return Ok(());
368    }
369    Err(AuthFailure::new(
370        "administrator_key_state_exists",
371        format!(
372            "refusing to {} `{}` while encrypted auth state exists; use `pb-mapper admin root-key rotate` or `pb-mapper admin auth-state reset --confirm`",
373            if force { "replace" } else { "create" },
374            path.display()
375        ),
376        false,
377    ))
378}