release_kit/setup/
secrets.rs1use 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
31pub const LEGACY_PRIVATE_KEY: &str = "RK_BOT_PRIVATE_KEY";
35
36pub const PRIVATE_KEY_FILE: &str = "RK_BOT_PRIVATE_KEY_FILE";
38
39pub const VALUE_VARS: [&str; 2] = ["RK_BOT_APP_ID", "RK_BOT_TOKEN"];
43
44const MAX_KEY_BYTES: u64 = 64 * 1024;
47
48pub struct KeyFile {
54 pub path: Utf8PathBuf,
56 pub bytes: Zeroizing<Vec<u8>>,
58}
59
60impl std::fmt::Debug for KeyFile {
61 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#[must_use]
72pub fn value_of(name: &str) -> Option<OsString> {
73 std::env::var_os(name).filter(|value| !value.is_empty())
74}
75
76pub 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
99pub 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 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 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 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
206fn 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 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 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
252fn 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
284fn 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
300fn 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
311fn 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 fn armored(label: &str) -> Vec<u8> {
333 format!("-----BEGIN {label}-----\n{BODY}\n-----END {label}-----\n").into_bytes()
334 }
335
336 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 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 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 assert!(is_private_key_pem(&wrap("c2Vrcm\nV0LXBl\nbS1ieXRlcyE=")));
390 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}