use std::ffi::OsString;
use std::io::Read as _;
use camino::{Utf8Path, Utf8PathBuf};
use zeroize::Zeroizing;
use crate::diagnostic::{Diagnostic, Reason};
use crate::error::RkError;
pub const LEGACY_PRIVATE_KEY: &str = "RK_BOT_PRIVATE_KEY";
pub const PRIVATE_KEY_FILE: &str = "RK_BOT_PRIVATE_KEY_FILE";
pub const VALUE_VARS: [&str; 2] = ["RK_BOT_APP_ID", "RK_BOT_TOKEN"];
const MAX_KEY_BYTES: u64 = 64 * 1024;
pub struct KeyFile {
pub path: Utf8PathBuf,
pub bytes: Zeroizing<Vec<u8>>,
}
impl std::fmt::Debug for KeyFile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("KeyFile")
.field("path", &self.path)
.finish_non_exhaustive()
}
}
#[must_use]
pub fn value_of(name: &str) -> Option<OsString> {
std::env::var_os(name).filter(|value| !value.is_empty())
}
pub fn refuse_legacy_key() -> Result<(), RkError> {
if value_of(LEGACY_PRIVATE_KEY).is_none() {
return Ok(());
}
Err(RkError::refusal(
Diagnostic::new(
Reason::PrerequisiteUnmet,
format!("{LEGACY_PRIVATE_KEY} carries key material"),
)
.expected("the key's path in the environment, never the key's contents")
.action(format!(
"unset {LEGACY_PRIVATE_KEY}, then export {PRIVATE_KEY_FILE} with the path to the .pem"
)),
))
}
pub fn resolve_key_file(target: &Utf8Path) -> Result<Option<KeyFile>, RkError> {
refuse_legacy_key()?;
let Some(raw) = value_of(PRIVATE_KEY_FILE) else {
return Ok(None);
};
let path = resolve_path(&raw, target)?;
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.custom_flags(libc::O_NONBLOCK);
}
let file = options.open(&path).map_err(|err| {
refuse(
format!("{path} is unreadable: {err}"),
"name an existing .pem",
)
})?;
let meta = file.metadata().map_err(|err| {
refuse(
format!("{path} is unreadable: {err}"),
"name an existing .pem",
)
})?;
if !meta.is_file() {
return Err(refuse(
format!("{path} is not a regular file"),
"name the .pem itself, not a directory, a device, or a pipe",
));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = meta.permissions().mode();
if mode & 0o077 != 0 {
return Err(refuse(
format!(
"{path} is readable by group or other ({:04o})",
mode & 0o7777
),
format!("chmod 600 {path}"),
));
}
}
let mut bytes = Zeroizing::new(Vec::new());
file.take(MAX_KEY_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|err| {
refuse(
format!("{path} is unreadable: {err}"),
"name a readable .pem",
)
})?;
if bytes.len() as u64 > MAX_KEY_BYTES {
return Err(refuse(
format!("{path} is larger than {MAX_KEY_BYTES} bytes"),
"name the .pem itself; a private key is a few kilobytes",
));
}
if bytes.is_empty() {
return Err(refuse(
format!("{path} is empty"),
"name the downloaded .pem",
));
}
if !is_private_key_pem(&bytes) {
return Err(refuse(
format!("{path} is not a PEM-encoded private key"),
"name the key the App's settings page downloaded, not a public key or an id",
));
}
Ok(Some(KeyFile { path, bytes }))
}
fn resolve_path(raw: &OsString, target: &Utf8Path) -> Result<Utf8PathBuf, RkError> {
let Ok(named) = Utf8PathBuf::from_path_buf(raw.clone().into()) else {
return Err(refuse(
format!("{PRIVATE_KEY_FILE} is not valid UTF-8"),
"name the .pem by a UTF-8 path",
));
};
if named.as_str().starts_with('~') {
return Err(refuse(
format!("{named} begins with an unexpanded tilde"),
"name the .pem by an absolute path, or leave the tilde unquoted for the shell",
));
}
let path = std::fs::canonicalize(&named).map_err(|err| {
refuse(
format!("{named} is unreadable: {err}"),
"name an existing .pem",
)
})?;
let Ok(path) = Utf8PathBuf::from_path_buf(path) else {
return Err(refuse(
format!("{named} resolves to a path that is not valid UTF-8"),
"name the .pem by a UTF-8 path",
));
};
if let Ok(inside) = std::fs::canonicalize(target) {
if path.as_std_path().starts_with(&inside) {
return Err(refuse(
format!("{path} is inside the repository being set up"),
"keep the .pem outside the working tree",
));
}
}
Ok(path)
}
fn is_private_key_pem(bytes: &[u8]) -> bool {
let Ok(text) = std::str::from_utf8(bytes) else {
return false;
};
let mut lines = text.lines().map(str::trim);
let Some(label) = lines.find_map(|line| boundary_label(line, "BEGIN")) else {
return false;
};
if !label.ends_with("PRIVATE KEY") {
return false;
}
let mut body = String::new();
for line in lines {
if let Some(end) = boundary_label(line, "END") {
return end == label && is_base64(&body);
}
body.push_str(line);
}
false
}
fn is_base64(text: &str) -> bool {
if text.is_empty() || text.len() % 4 != 0 {
return false;
}
let payload = text.trim_end_matches('=');
if text.len() - payload.len() > 2 {
return false;
}
payload
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'+' || byte == b'/')
}
fn boundary_label<'a>(line: &'a str, keyword: &str) -> Option<&'a str> {
let label = line
.strip_prefix("-----")?
.strip_suffix("-----")?
.strip_prefix(keyword)?
.strip_prefix(' ')?;
(!label.is_empty() && !label.contains('-')).then_some(label)
}
fn refuse(message: impl Into<String>, action: impl Into<String>) -> RkError {
RkError::refusal(
Diagnostic::new(Reason::PrerequisiteUnmet, message)
.expected(format!(
"{PRIVATE_KEY_FILE} naming a readable, owner-only PEM private key"
))
.action(action)
.step("bot-secrets"),
)
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::*;
fn armored(label: &str) -> Vec<u8> {
format!("-----BEGIN {label}-----\n{BODY}\n-----END {label}-----\n").into_bytes()
}
const BODY: &str = "c2VrcmV0LXBlbS1ieXRlcyE=";
#[test]
fn armor_is_the_shape_the_check_accepts() {
assert!(is_private_key_pem(&armored("RSA PRIVATE KEY")));
assert!(is_private_key_pem(&armored("PRIVATE KEY")));
assert!(is_private_key_pem(&armored("ENCRYPTED PRIVATE KEY")));
assert!(!is_private_key_pem(&armored("PUBLIC KEY")));
assert!(!is_private_key_pem(&armored("CERTIFICATE")));
assert!(!is_private_key_pem(b"314159\n"));
assert!(!is_private_key_pem(&[0xff, 0xfe, 0x00]));
}
#[test]
fn armor_that_is_only_the_two_markers_is_refused() {
let begin = |label: &str| format!("-----BEGIN {label}-----");
let end = |label: &str| format!("-----END {label}-----");
let key = "PRIVATE KEY";
let split_marker = format!("-----BEGIN\n{key}-----\n{BODY}\n");
assert!(!is_private_key_pem(split_marker.as_bytes()));
let mismatched = format!("{}\n{BODY}\n{}\n", begin("RSA PRIVATE KEY"), end(key));
assert!(!is_private_key_pem(mismatched.as_bytes()));
let unterminated = format!("{}\n{BODY}\n", begin(key));
assert!(!is_private_key_pem(unterminated.as_bytes()));
let bodyless = format!("{}\n{}\n", begin(key), end(key));
assert!(!is_private_key_pem(bodyless.as_bytes()));
let inline = format!("a {} inline\n{BODY}\n{}\n", begin(key), end(key));
assert!(!is_private_key_pem(inline.as_bytes()));
}
#[test]
fn a_body_that_is_not_base64_is_refused() {
let key = "PRIVATE KEY";
let wrap = |body: &str| {
format!("-----BEGIN {key}-----\n{body}\n-----END {key}-----\n").into_bytes()
};
assert!(!is_private_key_pem(&wrap("x")));
assert!(!is_private_key_pem(&wrap("sekret-pem-bytes")));
assert!(!is_private_key_pem(&wrap("c2Vrcm V0")));
assert!(!is_private_key_pem(&wrap("c2VrcmV0=b")));
assert!(is_private_key_pem(&wrap(BODY)));
assert!(is_private_key_pem(&wrap("c2Vrcm\nV0LXBl\nbS1ieXRlcyE=")));
assert!(!is_private_key_pem(&wrap(&format!(
"Proc-Type: 4,ENCRYPTED\n{BODY}"
))));
assert!(!is_private_key_pem(&wrap("garbage:\nstill-garbage:\nQUJD")));
assert!(!is_private_key_pem(&wrap(&format!("empty:\n{BODY}"))));
}
#[test]
fn a_key_file_debug_prints_no_key_material() {
let key = KeyFile {
path: Utf8PathBuf::from("/keys/bot.pem"),
bytes: Zeroizing::new(armored("PRIVATE KEY")),
};
let rendered = format!("{key:?}");
assert!(rendered.contains("/keys/bot.pem"));
assert!(!rendered.contains("BEGIN"));
}
}