Skip to main content

release_kit/setup/
secrets.rs

1//! The bot credentials a setup step consumes, and what the operator's
2//! environment is allowed to carry.
3//!
4//! An identifier and a short-lived token are values: the forge CLIs' own
5//! convention carries them in the environment, and rotating one is a
6//! command. Key material is not. An App private key downloads exactly once,
7//! lives until a browser replaces it, and an environment is a poor vault
8//! for it: the block is readable at `/proc/<pid>/environ`, and every later
9//! child of that shell inherits it. So the
10//! operator names the key's path to `rk`, and `rk` reads the file and
11//! writes the bytes to the step's standard input. The path goes no further
12//! than `rk`: no child is told it, so no child can open it.
13//!
14//! `rk` reads the file exactly once: to refuse a wrong one before anything
15//! is written to the forge, to hold the redaction needle that keeps the
16//! journal's `redacted` claim honest, and to be the bytes the step sends.
17//! One read means the file that was validated is the file that is stored —
18//! nothing between the check and the forge can substitute another. That
19//! read lands in a [`Zeroizing`] buffer, scrubbed on drop, and is never
20//! exported, echoed, or recorded.
21
22use std::ffi::OsString;
23use std::io::Read as _;
24
25use camino::{Utf8Path, Utf8PathBuf};
26use zeroize::Zeroizing;
27
28use crate::diagnostic::{Diagnostic, Reason};
29use crate::error::RkError;
30
31/// The variable that once carried the key's contents. It is refused now,
32/// rather than ignored: a stale export is the leak this module exists to
33/// end, and silence would let it stand.
34pub const LEGACY_PRIVATE_KEY: &str = "RK_BOT_PRIVATE_KEY";
35
36/// The variable naming the App private key file.
37pub const PRIVATE_KEY_FILE: &str = "RK_BOT_PRIVATE_KEY_FILE";
38
39/// The variables whose value the environment may carry: an App identifier,
40/// which the App's settings page shows, and a project access token, which
41/// the forge mints and a command rotates.
42pub const VALUE_VARS: [&str; 2] = ["RK_BOT_APP_ID", "RK_BOT_TOKEN"];
43
44/// The largest file this accepts as a private key. An App key is a few
45/// kilobytes; the cap is what stops a mistyped path from being slurped.
46const MAX_KEY_BYTES: u64 = 64 * 1024;
47
48/// A validated private key file.
49///
50/// The bytes are what the step transmits and what the redactor holds. No
51/// consumer ever learns the path, which is why the path here serves a
52/// diagnostic and nothing else.
53pub struct KeyFile {
54    /// The canonical path, for a diagnostic that must name the file.
55    pub path: Utf8PathBuf,
56    /// The file's bytes, scrubbed when this is dropped.
57    pub bytes: Zeroizing<Vec<u8>>,
58}
59
60impl std::fmt::Debug for KeyFile {
61    /// The path only: a derived `Debug` would print key material into any
62    /// log that formats a context.
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        f.debug_struct("KeyFile")
65            .field("path", &self.path)
66            .finish_non_exhaustive()
67    }
68}
69
70/// The environment's value for `name`, absent when unset or empty.
71#[must_use]
72pub fn value_of(name: &str) -> Option<OsString> {
73    std::env::var_os(name).filter(|value| !value.is_empty())
74}
75
76/// Refuse a stale `RK_BOT_PRIVATE_KEY` export wherever a run starts, so
77/// `rk setup`, `rk setup step`, and `rk setup check` all catch it rather
78/// than one step alone.
79///
80/// # Errors
81///
82/// Refuses when the environment carries the key's contents.
83pub fn refuse_legacy_key() -> Result<(), RkError> {
84    if value_of(LEGACY_PRIVATE_KEY).is_none() {
85        return Ok(());
86    }
87    Err(RkError::refusal(
88        Diagnostic::new(
89            Reason::PrerequisiteUnmet,
90            format!("{LEGACY_PRIVATE_KEY} carries key material"),
91        )
92        .expected("the key's path in the environment, never the key's contents")
93        .action(format!(
94            "unset {LEGACY_PRIVATE_KEY}, then export {PRIVATE_KEY_FILE} with the path to the .pem"
95        )),
96    ))
97}
98
99/// The validated private key file, where the operator named one.
100///
101/// Every refusal happens before the step spawns, so a wrong path, a wrong
102/// mode, or a wrong encoding never reaches the forge. What the encoding
103/// wraps is the forge's judgment: this refuses a file that is not a PEM
104/// private key, not a key the forge would reject.
105///
106/// # Errors
107///
108/// Refuses a stale contents variable, and a named file that is missing,
109/// unreadable, not a regular file, readable beyond its owner, empty,
110/// oversized, inside the target, or not a PEM private key.
111pub fn resolve_key_file(target: &Utf8Path) -> Result<Option<KeyFile>, RkError> {
112    match look_up_key_file(target)? {
113        KeyLookup::Absent => Ok(None),
114        KeyLookup::Found(key) => Ok(Some(key)),
115        KeyLookup::Unavailable(unavailable) => Err(unavailable.refusal()),
116    }
117}
118
119/// What the named key file turned out to be.
120pub enum KeyLookup {
121    /// No file is named.
122    Absent,
123    /// A named file this runtime cannot reach: a path that does not exist
124    /// here, or a file it may not open or read. Under a read-only check
125    /// that is an observation boundary, not a defect: the file can exist
126    /// on the host that named it and be absent from a container.
127    Unavailable(Unavailable),
128    /// A readable, owner-only PEM private key.
129    Found(KeyFile),
130}
131
132/// A named key file this runtime cannot reach, with the refusal a
133/// mutating run gives for it.
134pub struct Unavailable {
135    /// What was found, one line.
136    pub message: String,
137    action: &'static str,
138}
139
140impl Unavailable {
141    /// The refusal a run that needs the key gives.
142    #[must_use]
143    pub fn refusal(self) -> RkError {
144        refuse(self.message, self.action)
145    }
146}
147
148/// Look the named key file up, and tell a file this runtime cannot reach
149/// from a file that is wrong.
150///
151/// A wrong file is refused in every mode: an unsafe mode, a directory or
152/// a pipe, an oversized or empty file, a file inside the target, or bytes
153/// that are not a PEM private key. A file that cannot be reached is
154/// [`KeyLookup::Unavailable`], and the caller decides what that means.
155///
156/// SATISFIES setup-proof:an-unavailable-credential-is-an-observation-boundary
157///
158/// # Errors
159///
160/// Refuses a stale contents variable and every wrong file, as
161/// [`resolve_key_file`] names them.
162pub fn look_up_key_file(target: &Utf8Path) -> Result<KeyLookup, RkError> {
163    refuse_legacy_key()?;
164    let Some(raw) = value_of(PRIVATE_KEY_FILE) else {
165        return Ok(KeyLookup::Absent);
166    };
167    let unavailable = |message: String, action: &'static str| {
168        Ok(KeyLookup::Unavailable(Unavailable { message, action }))
169    };
170    let path = match resolve_path(&raw, target)? {
171        Resolved::Path(path) => path,
172        Resolved::Unreachable(message) => return unavailable(message, "name an existing .pem"),
173    };
174
175    // One handle answers every question that follows. Asking the path twice
176    // — once for metadata, once for contents — would let a replacement
177    // satisfy the checks with one object and supply the bytes of another;
178    // what is checked here is what is read here.
179    //
180    // The open must not block, because what it opens is not yet known to be
181    // a file: a FIFO with no writer would hang the run instead of earning
182    // the refusal below. `O_NONBLOCK` changes nothing for the regular file
183    // this is supposed to be, and its value comes from the target's own ABI
184    // — it varies by architecture, not only by operating system.
185    let mut options = std::fs::OpenOptions::new();
186    options.read(true);
187    #[cfg(unix)]
188    {
189        use std::os::unix::fs::OpenOptionsExt as _;
190        options.custom_flags(libc::O_NONBLOCK);
191    }
192    let file = match options.open(&path) {
193        Ok(file) => file,
194        Err(err) => {
195            return unavailable(
196                format!("{path} is unreadable: {err}"),
197                "name an existing .pem",
198            );
199        }
200    };
201    let meta = match file.metadata() {
202        Ok(meta) => meta,
203        Err(err) => {
204            return unavailable(
205                format!("{path} is unreadable: {err}"),
206                "name an existing .pem",
207            );
208        }
209    };
210
211    // rk reads this handle and hands its bytes on, so a source that yields
212    // them once, or has none of its own, is wrong here.
213    if !meta.is_file() {
214        return Err(refuse(
215            format!("{path} is not a regular file"),
216            "name the .pem itself, not a directory, a device, or a pipe",
217        ));
218    }
219
220    #[cfg(unix)]
221    {
222        use std::os::unix::fs::PermissionsExt;
223        let mode = meta.permissions().mode();
224        if mode & 0o077 != 0 {
225            return Err(refuse(
226                format!(
227                    "{path} is readable by group or other ({:04o})",
228                    mode & 0o7777
229                ),
230                format!("chmod 600 {path}"),
231            ));
232        }
233    }
234
235    // Bounded by the read itself rather than by the length the metadata
236    // reported: one byte past the cap is enough to know, and the cap holds
237    // even where a handle's reported length and its contents disagree.
238    let mut bytes = Zeroizing::new(Vec::new());
239    if let Err(err) = file.take(MAX_KEY_BYTES + 1).read_to_end(&mut bytes) {
240        return unavailable(
241            format!("{path} is unreadable: {err}"),
242            "name a readable .pem",
243        );
244    }
245    if bytes.len() as u64 > MAX_KEY_BYTES {
246        return Err(refuse(
247            format!("{path} is larger than {MAX_KEY_BYTES} bytes"),
248            "name the .pem itself; a private key is a few kilobytes",
249        ));
250    }
251    if bytes.is_empty() {
252        return Err(refuse(
253            format!("{path} is empty"),
254            "name the downloaded .pem",
255        ));
256    }
257    if !is_private_key_pem(&bytes) {
258        return Err(refuse(
259            format!("{path} is not a PEM-encoded private key"),
260            "name the key the App's settings page downloaded, not a public key or an id",
261        ));
262    }
263
264    Ok(KeyLookup::Found(KeyFile { path, bytes }))
265}
266
267/// The canonical path the operator named, refused where the name itself is
268/// wrong: before anything is opened, and before a diagnostic could leak
269/// what the file holds.
270/// A named path, resolved, or a name this runtime cannot reach.
271enum Resolved {
272    Path(Utf8PathBuf),
273    Unreachable(String),
274}
275
276fn resolve_path(raw: &OsString, target: &Utf8Path) -> Result<Resolved, RkError> {
277    let Ok(named) = Utf8PathBuf::from_path_buf(raw.clone().into()) else {
278        return Err(refuse(
279            format!("{PRIVATE_KEY_FILE} is not valid UTF-8"),
280            "name the .pem by a UTF-8 path",
281        ));
282    };
283
284    // A quoted `export RK_BOT_PRIVATE_KEY_FILE="~/key.pem"` leaves the tilde
285    // for a program to expand, and no program does.
286    if named.as_str().starts_with('~') {
287        return Err(refuse(
288            format!("{named} begins with an unexpanded tilde"),
289            "name the .pem by an absolute path, or leave the tilde unquoted for the shell",
290        ));
291    }
292
293    let path = match std::fs::canonicalize(&named) {
294        Ok(path) => path,
295        Err(err) => {
296            return Ok(Resolved::Unreachable(format!(
297                "{named} is unreadable: {err}"
298            )));
299        }
300    };
301    let Ok(path) = Utf8PathBuf::from_path_buf(path) else {
302        return Err(refuse(
303            format!("{named} resolves to a path that is not valid UTF-8"),
304            "name the .pem by a UTF-8 path",
305        ));
306    };
307
308    // A key inside the repository is one `git add .` from being published.
309    if let Ok(inside) = std::fs::canonicalize(target)
310        && path.as_std_path().starts_with(&inside)
311    {
312        return Err(refuse(
313            format!("{path} is inside the repository being set up"),
314            "keep the .pem outside the working tree",
315        ));
316    }
317
318    Ok(Resolved::Path(path))
319}
320
321/// Whether the bytes are the RFC 7468 textual encoding of a private key.
322///
323/// The check is the encoding, not the key: `rk` stores the file and the
324/// forge parses it, so nothing here decodes a key or judges an algorithm.
325/// What it does assert is everything RFC 7468 gives — a begin line whose
326/// label ends in `PRIVATE KEY`, base64 between the boundaries, and an end
327/// line carrying the same label. That grammar admits no header fields, so
328/// neither does this. Anything less accepts a file holding the right
329/// markers around the wrong content, and the forge would store it happily;
330/// the failure would surface as a release that cannot authenticate, weeks
331/// later.
332fn is_private_key_pem(bytes: &[u8]) -> bool {
333    let Ok(text) = std::str::from_utf8(bytes) else {
334        return false;
335    };
336    let mut lines = text.lines().map(str::trim);
337    let Some(label) = lines.find_map(|line| boundary_label(line, "BEGIN")) else {
338        return false;
339    };
340    if !label.ends_with("PRIVATE KEY") {
341        return false;
342    }
343    let mut body = String::new();
344    for line in lines {
345        if let Some(end) = boundary_label(line, "END") {
346            return end == label && is_base64(&body);
347        }
348        body.push_str(line);
349    }
350    false
351}
352
353/// Whether `text` is non-empty base64, in the alphabet and padding RFC 4648
354/// gives: a multiple of four characters, padding only at the end, and at
355/// most two padding characters.
356fn is_base64(text: &str) -> bool {
357    if text.is_empty() || !text.len().is_multiple_of(4) {
358        return false;
359    }
360    let payload = text.trim_end_matches('=');
361    if text.len() - payload.len() > 2 {
362        return false;
363    }
364    payload
365        .bytes()
366        .all(|byte| byte.is_ascii_alphanumeric() || byte == b'+' || byte == b'/')
367}
368
369/// The label of a `-----BEGIN <label>-----` or `-----END <label>-----`
370/// line, where the line is exactly one and its label is non-empty.
371fn boundary_label<'a>(line: &'a str, keyword: &str) -> Option<&'a str> {
372    let label = line
373        .strip_prefix("-----")?
374        .strip_suffix("-----")?
375        .strip_prefix(keyword)?
376        .strip_prefix(' ')?;
377    (!label.is_empty() && !label.contains('-')).then_some(label)
378}
379
380/// One refusal, in this module's shape.
381fn refuse(message: impl Into<String>, action: impl Into<String>) -> RkError {
382    RkError::refusal(
383        Diagnostic::new(Reason::PrerequisiteUnmet, message)
384            .expected(format!(
385                "{PRIVATE_KEY_FILE} naming a readable, owner-only PEM private key"
386            ))
387            .action(action)
388            .step("bot-secrets"),
389    )
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    /// PEM armor around `label`, assembled rather than written out: a
397    /// literal header here is what the repository's own private-key scan
398    /// is for, and it should keep firing on real ones.
399    fn armored(label: &str) -> Vec<u8> {
400        format!("-----BEGIN {label}-----\n{BODY}\n-----END {label}-----\n").into_bytes()
401    }
402
403    /// A base64 body, which RFC 7468 requires between the boundaries.
404    const BODY: &str = "c2VrcmV0LXBlbS1ieXRlcyE=";
405
406    #[test]
407    fn armor_is_the_shape_the_check_accepts() {
408        assert!(is_private_key_pem(&armored("RSA PRIVATE KEY")));
409        assert!(is_private_key_pem(&armored("PRIVATE KEY")));
410        assert!(is_private_key_pem(&armored("ENCRYPTED PRIVATE KEY")));
411        assert!(!is_private_key_pem(&armored("PUBLIC KEY")));
412        assert!(!is_private_key_pem(&armored("CERTIFICATE")));
413        assert!(!is_private_key_pem(b"314159\n"));
414        assert!(!is_private_key_pem(&[0xff, 0xfe, 0x00]));
415    }
416
417    #[test]
418    fn armor_that_is_only_the_two_markers_is_refused() {
419        // The markers in any arrangement are not armor: a boundary is a
420        // whole line, the labels must match, and something must sit
421        // between them.
422        let begin = |label: &str| format!("-----BEGIN {label}-----");
423        let end = |label: &str| format!("-----END {label}-----");
424        let key = "PRIVATE KEY";
425
426        let split_marker = format!("-----BEGIN\n{key}-----\n{BODY}\n");
427        assert!(!is_private_key_pem(split_marker.as_bytes()));
428
429        let mismatched = format!("{}\n{BODY}\n{}\n", begin("RSA PRIVATE KEY"), end(key));
430        assert!(!is_private_key_pem(mismatched.as_bytes()));
431
432        let unterminated = format!("{}\n{BODY}\n", begin(key));
433        assert!(!is_private_key_pem(unterminated.as_bytes()));
434
435        let bodyless = format!("{}\n{}\n", begin(key), end(key));
436        assert!(!is_private_key_pem(bodyless.as_bytes()));
437
438        let inline = format!("a {} inline\n{BODY}\n{}\n", begin(key), end(key));
439        assert!(!is_private_key_pem(inline.as_bytes()));
440    }
441
442    #[test]
443    fn a_body_that_is_not_base64_is_refused() {
444        // Matching boundaries around arbitrary text are not a key, and the
445        // forge would store them without complaint.
446        let key = "PRIVATE KEY";
447        let wrap = |body: &str| {
448            format!("-----BEGIN {key}-----\n{body}\n-----END {key}-----\n").into_bytes()
449        };
450        assert!(!is_private_key_pem(&wrap("x")));
451        assert!(!is_private_key_pem(&wrap("sekret-pem-bytes")));
452        assert!(!is_private_key_pem(&wrap("c2Vrcm V0")));
453        assert!(!is_private_key_pem(&wrap("c2VrcmV0=b")));
454        assert!(is_private_key_pem(&wrap(BODY)));
455        // A wrapped body joins into one base64 string, as an encoder emits.
456        assert!(is_private_key_pem(&wrap("c2Vrcm\nV0LXBl\nbS1ieXRlcyE=")));
457        // RFC 7468's grammar admits no header fields, so neither does this;
458        // a colon buys a line nothing.
459        assert!(!is_private_key_pem(&wrap(&format!(
460            "Proc-Type: 4,ENCRYPTED\n{BODY}"
461        ))));
462        assert!(!is_private_key_pem(&wrap("garbage:\nstill-garbage:\nQUJD")));
463        assert!(!is_private_key_pem(&wrap(&format!("empty:\n{BODY}"))));
464    }
465
466    #[test]
467    fn a_key_file_debug_prints_no_key_material() {
468        let key = KeyFile {
469            path: Utf8PathBuf::from("/keys/bot.pem"),
470            bytes: Zeroizing::new(armored("PRIVATE KEY")),
471        };
472        let rendered = format!("{key:?}");
473        assert!(rendered.contains("/keys/bot.pem"));
474        assert!(!rendered.contains("BEGIN"));
475    }
476}