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    refuse_legacy_key()?;
113    let Some(raw) = value_of(PRIVATE_KEY_FILE) else {
114        return Ok(None);
115    };
116    let path = resolve_path(&raw, target)?;
117
118    // One handle answers every question that follows. Asking the path twice
119    // — once for metadata, once for contents — would let a replacement
120    // satisfy the checks with one object and supply the bytes of another;
121    // what is checked here is what is read here.
122    //
123    // The open must not block, because what it opens is not yet known to be
124    // a file: a FIFO with no writer would hang the run instead of earning
125    // the refusal below. `O_NONBLOCK` changes nothing for the regular file
126    // this is supposed to be, and its value comes from the target's own ABI
127    // — it varies by architecture, not only by operating system.
128    let mut options = std::fs::OpenOptions::new();
129    options.read(true);
130    #[cfg(unix)]
131    {
132        use std::os::unix::fs::OpenOptionsExt as _;
133        options.custom_flags(libc::O_NONBLOCK);
134    }
135    let file = options.open(&path).map_err(|err| {
136        refuse(
137            format!("{path} is unreadable: {err}"),
138            "name an existing .pem",
139        )
140    })?;
141    let meta = file.metadata().map_err(|err| {
142        refuse(
143            format!("{path} is unreadable: {err}"),
144            "name an existing .pem",
145        )
146    })?;
147
148    // rk reads this handle and hands its bytes on, so a source that yields
149    // them once, or has none of its own, is wrong here.
150    if !meta.is_file() {
151        return Err(refuse(
152            format!("{path} is not a regular file"),
153            "name the .pem itself, not a directory, a device, or a pipe",
154        ));
155    }
156
157    #[cfg(unix)]
158    {
159        use std::os::unix::fs::PermissionsExt;
160        let mode = meta.permissions().mode();
161        if mode & 0o077 != 0 {
162            return Err(refuse(
163                format!(
164                    "{path} is readable by group or other ({:04o})",
165                    mode & 0o7777
166                ),
167                format!("chmod 600 {path}"),
168            ));
169        }
170    }
171
172    // Bounded by the read itself rather than by the length the metadata
173    // reported: one byte past the cap is enough to know, and the cap holds
174    // even where a handle's reported length and its contents disagree.
175    let mut bytes = Zeroizing::new(Vec::new());
176    file.take(MAX_KEY_BYTES + 1)
177        .read_to_end(&mut bytes)
178        .map_err(|err| {
179            refuse(
180                format!("{path} is unreadable: {err}"),
181                "name a readable .pem",
182            )
183        })?;
184    if bytes.len() as u64 > MAX_KEY_BYTES {
185        return Err(refuse(
186            format!("{path} is larger than {MAX_KEY_BYTES} bytes"),
187            "name the .pem itself; a private key is a few kilobytes",
188        ));
189    }
190    if bytes.is_empty() {
191        return Err(refuse(
192            format!("{path} is empty"),
193            "name the downloaded .pem",
194        ));
195    }
196    if !is_private_key_pem(&bytes) {
197        return Err(refuse(
198            format!("{path} is not a PEM-encoded private key"),
199            "name the key the App's settings page downloaded, not a public key or an id",
200        ));
201    }
202
203    Ok(Some(KeyFile { path, bytes }))
204}
205
206/// The canonical path the operator named, refused where the name itself is
207/// wrong: before anything is opened, and before a diagnostic could leak
208/// what the file holds.
209fn resolve_path(raw: &OsString, target: &Utf8Path) -> Result<Utf8PathBuf, RkError> {
210    let Ok(named) = Utf8PathBuf::from_path_buf(raw.clone().into()) else {
211        return Err(refuse(
212            format!("{PRIVATE_KEY_FILE} is not valid UTF-8"),
213            "name the .pem by a UTF-8 path",
214        ));
215    };
216
217    // A quoted `export RK_BOT_PRIVATE_KEY_FILE="~/key.pem"` leaves the tilde
218    // for a program to expand, and no program does.
219    if named.as_str().starts_with('~') {
220        return Err(refuse(
221            format!("{named} begins with an unexpanded tilde"),
222            "name the .pem by an absolute path, or leave the tilde unquoted for the shell",
223        ));
224    }
225
226    let path = std::fs::canonicalize(&named).map_err(|err| {
227        refuse(
228            format!("{named} is unreadable: {err}"),
229            "name an existing .pem",
230        )
231    })?;
232    let Ok(path) = Utf8PathBuf::from_path_buf(path) else {
233        return Err(refuse(
234            format!("{named} resolves to a path that is not valid UTF-8"),
235            "name the .pem by a UTF-8 path",
236        ));
237    };
238
239    // A key inside the repository is one `git add .` from being published.
240    if let Ok(inside) = std::fs::canonicalize(target) {
241        if path.as_std_path().starts_with(&inside) {
242            return Err(refuse(
243                format!("{path} is inside the repository being set up"),
244                "keep the .pem outside the working tree",
245            ));
246        }
247    }
248
249    Ok(path)
250}
251
252/// Whether the bytes are the RFC 7468 textual encoding of a private key.
253///
254/// The check is the encoding, not the key: `rk` stores the file and the
255/// forge parses it, so nothing here decodes a key or judges an algorithm.
256/// What it does assert is everything RFC 7468 gives — a begin line whose
257/// label ends in `PRIVATE KEY`, base64 between the boundaries, and an end
258/// line carrying the same label. That grammar admits no header fields, so
259/// neither does this. Anything less accepts a file holding the right
260/// markers around the wrong content, and the forge would store it happily;
261/// the failure would surface as a release that cannot authenticate, weeks
262/// later.
263fn is_private_key_pem(bytes: &[u8]) -> bool {
264    let Ok(text) = std::str::from_utf8(bytes) else {
265        return false;
266    };
267    let mut lines = text.lines().map(str::trim);
268    let Some(label) = lines.find_map(|line| boundary_label(line, "BEGIN")) else {
269        return false;
270    };
271    if !label.ends_with("PRIVATE KEY") {
272        return false;
273    }
274    let mut body = String::new();
275    for line in lines {
276        if let Some(end) = boundary_label(line, "END") {
277            return end == label && is_base64(&body);
278        }
279        body.push_str(line);
280    }
281    false
282}
283
284/// Whether `text` is non-empty base64, in the alphabet and padding RFC 4648
285/// gives: a multiple of four characters, padding only at the end, and at
286/// most two padding characters.
287fn is_base64(text: &str) -> bool {
288    if text.is_empty() || text.len() % 4 != 0 {
289        return false;
290    }
291    let payload = text.trim_end_matches('=');
292    if text.len() - payload.len() > 2 {
293        return false;
294    }
295    payload
296        .bytes()
297        .all(|byte| byte.is_ascii_alphanumeric() || byte == b'+' || byte == b'/')
298}
299
300/// The label of a `-----BEGIN <label>-----` or `-----END <label>-----`
301/// line, where the line is exactly one and its label is non-empty.
302fn boundary_label<'a>(line: &'a str, keyword: &str) -> Option<&'a str> {
303    let label = line
304        .strip_prefix("-----")?
305        .strip_suffix("-----")?
306        .strip_prefix(keyword)?
307        .strip_prefix(' ')?;
308    (!label.is_empty() && !label.contains('-')).then_some(label)
309}
310
311/// One refusal, in this module's shape.
312fn refuse(message: impl Into<String>, action: impl Into<String>) -> RkError {
313    RkError::refusal(
314        Diagnostic::new(Reason::PrerequisiteUnmet, message)
315            .expected(format!(
316                "{PRIVATE_KEY_FILE} naming a readable, owner-only PEM private key"
317            ))
318            .action(action)
319            .step("bot-secrets"),
320    )
321}
322
323#[cfg(test)]
324mod tests {
325    #![allow(clippy::expect_used)]
326
327    use super::*;
328
329    /// PEM armor around `label`, assembled rather than written out: a
330    /// literal header here is what the repository's own private-key scan
331    /// is for, and it should keep firing on real ones.
332    fn armored(label: &str) -> Vec<u8> {
333        format!("-----BEGIN {label}-----\n{BODY}\n-----END {label}-----\n").into_bytes()
334    }
335
336    /// A base64 body, which RFC 7468 requires between the boundaries.
337    const BODY: &str = "c2VrcmV0LXBlbS1ieXRlcyE=";
338
339    #[test]
340    fn armor_is_the_shape_the_check_accepts() {
341        assert!(is_private_key_pem(&armored("RSA PRIVATE KEY")));
342        assert!(is_private_key_pem(&armored("PRIVATE KEY")));
343        assert!(is_private_key_pem(&armored("ENCRYPTED PRIVATE KEY")));
344        assert!(!is_private_key_pem(&armored("PUBLIC KEY")));
345        assert!(!is_private_key_pem(&armored("CERTIFICATE")));
346        assert!(!is_private_key_pem(b"314159\n"));
347        assert!(!is_private_key_pem(&[0xff, 0xfe, 0x00]));
348    }
349
350    #[test]
351    fn armor_that_is_only_the_two_markers_is_refused() {
352        // The markers in any arrangement are not armor: a boundary is a
353        // whole line, the labels must match, and something must sit
354        // between them.
355        let begin = |label: &str| format!("-----BEGIN {label}-----");
356        let end = |label: &str| format!("-----END {label}-----");
357        let key = "PRIVATE KEY";
358
359        let split_marker = format!("-----BEGIN\n{key}-----\n{BODY}\n");
360        assert!(!is_private_key_pem(split_marker.as_bytes()));
361
362        let mismatched = format!("{}\n{BODY}\n{}\n", begin("RSA PRIVATE KEY"), end(key));
363        assert!(!is_private_key_pem(mismatched.as_bytes()));
364
365        let unterminated = format!("{}\n{BODY}\n", begin(key));
366        assert!(!is_private_key_pem(unterminated.as_bytes()));
367
368        let bodyless = format!("{}\n{}\n", begin(key), end(key));
369        assert!(!is_private_key_pem(bodyless.as_bytes()));
370
371        let inline = format!("a {} inline\n{BODY}\n{}\n", begin(key), end(key));
372        assert!(!is_private_key_pem(inline.as_bytes()));
373    }
374
375    #[test]
376    fn a_body_that_is_not_base64_is_refused() {
377        // Matching boundaries around arbitrary text are not a key, and the
378        // forge would store them without complaint.
379        let key = "PRIVATE KEY";
380        let wrap = |body: &str| {
381            format!("-----BEGIN {key}-----\n{body}\n-----END {key}-----\n").into_bytes()
382        };
383        assert!(!is_private_key_pem(&wrap("x")));
384        assert!(!is_private_key_pem(&wrap("sekret-pem-bytes")));
385        assert!(!is_private_key_pem(&wrap("c2Vrcm V0")));
386        assert!(!is_private_key_pem(&wrap("c2VrcmV0=b")));
387        assert!(is_private_key_pem(&wrap(BODY)));
388        // A wrapped body joins into one base64 string, as an encoder emits.
389        assert!(is_private_key_pem(&wrap("c2Vrcm\nV0LXBl\nbS1ieXRlcyE=")));
390        // RFC 7468's grammar admits no header fields, so neither does this;
391        // a colon buys a line nothing.
392        assert!(!is_private_key_pem(&wrap(&format!(
393            "Proc-Type: 4,ENCRYPTED\n{BODY}"
394        ))));
395        assert!(!is_private_key_pem(&wrap("garbage:\nstill-garbage:\nQUJD")));
396        assert!(!is_private_key_pem(&wrap(&format!("empty:\n{BODY}"))));
397    }
398
399    #[test]
400    fn a_key_file_debug_prints_no_key_material() {
401        let key = KeyFile {
402            path: Utf8PathBuf::from("/keys/bot.pem"),
403            bytes: Zeroizing::new(armored("PRIVATE KEY")),
404        };
405        let rendered = format!("{key:?}");
406        assert!(rendered.contains("/keys/bot.pem"));
407        assert!(!rendered.contains("BEGIN"));
408    }
409}