1use 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 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
119pub enum KeyLookup {
121 Absent,
123 Unavailable(Unavailable),
128 Found(KeyFile),
130}
131
132pub struct Unavailable {
135 pub message: String,
137 action: &'static str,
138}
139
140impl Unavailable {
141 #[must_use]
143 pub fn refusal(self) -> RkError {
144 refuse(self.message, self.action)
145 }
146}
147
148pub 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 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 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 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
267enum 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 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 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
321fn 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
353fn 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
369fn 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
380fn 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 fn armored(label: &str) -> Vec<u8> {
400 format!("-----BEGIN {label}-----\n{BODY}\n-----END {label}-----\n").into_bytes()
401 }
402
403 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 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 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 assert!(is_private_key_pem(&wrap("c2Vrcm\nV0LXBl\nbS1ieXRlcyE=")));
457 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}