use std::ffi::OsString;
use std::io::Write as _;
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::Value;
use zeroize::Zeroizing;
use super::context::Ctx;
use super::process::{self, Exec};
use super::secrets;
use crate::diagnostic::{Diagnostic, Reason};
use crate::error::RkError;
pub const REMEDIATION: &str = "export RK_BOT_APP_ID and RK_BOT_PRIVATE_KEY_FILE, the second naming the App's .pem, or verify the installation by eye at github.com/settings/installations";
pub struct AppCredentials {
pub app_id: String,
pub key_bytes: Zeroizing<Vec<u8>>,
}
pub fn app_id() -> Result<Option<String>, RkError> {
let Some(app_id) = secrets::value_of("RK_BOT_APP_ID") else {
return Ok(None);
};
let numeric = app_id
.to_str()
.filter(|id| !id.is_empty() && id.bytes().all(|byte| byte.is_ascii_digit()));
let Some(app_id) = numeric else {
return Err(RkError::refusal(
Diagnostic::new(
Reason::PrerequisiteUnmet,
"RK_BOT_APP_ID is not a numeric App id",
)
.expected("the App ID from the App's settings page, digits only")
.action("copy the App ID, not the Client ID; the setup guide's step 5 collects it")
.step("install-bot"),
));
};
Ok(Some(app_id.to_owned()))
}
pub fn mint(ctx: &Ctx, credentials: &AppCredentials) -> Result<String, String> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| "the system clock is before the epoch".to_owned())?
.as_secs();
let header = base64url(br#"{"alg":"RS256","typ":"JWT"}"#);
let claims = base64url(
format!(
r#"{{"iat":{},"exp":{},"iss":"{}"}}"#,
now.saturating_sub(60),
now + 540,
credentials.app_id
)
.as_bytes(),
);
let input = format!("{header}.{claims}");
let signature = sign(ctx, credentials, input.as_bytes())?;
Ok(format!("{input}.{}", base64url(&signature)))
}
fn helper_env() -> Vec<(OsString, OsString)> {
std::env::var_os("PATH")
.map(|path| vec![(OsString::from("PATH"), path)])
.unwrap_or_default()
}
fn carrier_env() -> Vec<(OsString, OsString)> {
const TRUST: [&str; 4] = [
"CURL_CA_BUNDLE",
"SSL_CERT_DIR",
"SSL_CERT_FILE",
"NIX_SSL_CERT_FILE",
];
let mut env = helper_env();
env.extend(
TRUST
.iter()
.filter_map(|name| std::env::var_os(name).map(|value| (OsString::from(*name), value))),
);
env
}
fn sign(ctx: &Ctx, credentials: &AppCredentials, input: &[u8]) -> Result<Vec<u8>, String> {
let scratch = scratch_input(input)?;
let program = std::env::var_os("RK_OPENSSL_BIN").unwrap_or_else(|| "openssl".into());
let exec = Exec {
program,
args: [
"dgst",
"-sha256",
"-binary",
"-sign",
"/dev/stdin",
scratch.file.as_str(),
]
.map(OsString::from)
.to_vec(),
env: helper_env(),
cwd: ctx.target.as_std_path().to_path_buf(),
stdin: Some(credentials.key_bytes.clone()),
};
let outcome = process::run(&exec, |_, _| {})
.map_err(|source| format!("openssl did not spawn: {source}; install OpenSSL"))?;
if !outcome.success() {
let stderr = process::redact(
&outcome.stderr,
std::slice::from_ref(&credentials.key_bytes),
);
return Err(format!(
"openssl could not sign the App JWT: {}",
last_line(&stderr)
));
}
if outcome.stdout.is_empty() {
return Err("openssl signed the App JWT to an empty signature".to_owned());
}
Ok(outcome.stdout)
}
struct ScratchInput {
dir: std::path::PathBuf,
file: camino::Utf8PathBuf,
}
impl Drop for ScratchInput {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn scratch_input(input: &[u8]) -> Result<ScratchInput, String> {
let dir = std::env::temp_dir().join(format!("rk-app-jwt-{}", std::process::id()));
let make = || -> std::io::Result<()> {
std::fs::create_dir_all(&dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?;
}
Ok(())
};
make().map_err(|source| format!("no scratch directory for the JWT input: {source}"))?;
let path = dir.join("signing-input");
std::fs::write(&path, input)
.map_err(|source| format!("the JWT input did not write: {source}"))?;
let Ok(file) = camino::Utf8PathBuf::from_path_buf(path) else {
return Err("the scratch path is not valid UTF-8".to_owned());
};
Ok(ScratchInput { dir, file })
}
pub enum AppApi {
Ok(Value),
Missing,
Refused(String),
Failed(String),
}
#[must_use]
pub fn api_get(ctx: &Ctx, jwt: &str, path: &str) -> AppApi {
let mut headers = Zeroizing::new(Vec::new());
let _ = write!(
headers,
"Authorization: Bearer {jwt}\nAccept: application/vnd.github+json\nX-GitHub-Api-Version: 2022-11-28\n"
);
let program = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
let exec = Exec {
program,
args: [
"-q",
"-sS",
"--max-time",
"10",
"-H",
"@-",
"-w",
"\n%{http_code}",
&format!("https://api.github.com/{path}"),
]
.map(OsString::from)
.to_vec(),
env: carrier_env(),
cwd: ctx.target.as_std_path().to_path_buf(),
stdin: Some(headers),
};
let outcome = match process::run(&exec, |_, _| {}) {
Ok(outcome) => outcome,
Err(source) => {
return AppApi::Failed(format!("curl did not spawn: {source}; install curl"));
}
};
let needles = [
jwt.as_bytes(),
jwt.rsplit('.').next().unwrap_or(jwt).as_bytes(),
];
let stderr = process::redact(&outcome.stderr, &needles);
let stdout = process::redact(&outcome.stdout, &needles);
if !outcome.success() {
return AppApi::Failed(format!(
"curl could not reach the forge (exit {}): {}",
outcome.exit_code,
first_line(&stderr)
));
}
let stdout = String::from_utf8_lossy(&stdout);
let (body, status) = stdout
.trim_end()
.rsplit_once('\n')
.unwrap_or_else(|| ("", stdout.trim_end()));
match status {
"200" => serde_json::from_str::<Value>(body).map_or_else(
|_| AppApi::Failed("the forge answer did not parse as JSON".into()),
AppApi::Ok,
),
"404" => AppApi::Missing,
"401" | "403" => AppApi::Refused(format!(
"the forge refused the App credentials ({status}); RK_BOT_APP_ID and the key file must name the same App"
)),
other => AppApi::Failed(format!("the forge answered {other}")),
}
}
fn first_line(bytes: &[u8]) -> String {
non_empty_line(bytes, End::First)
}
fn last_line(bytes: &[u8]) -> String {
non_empty_line(bytes, End::Last)
}
#[derive(Clone, Copy)]
enum End {
First,
Last,
}
fn non_empty_line(bytes: &[u8], end: End) -> String {
let text = String::from_utf8_lossy(bytes);
let mut lines = text.lines().filter(|line| !line.trim().is_empty());
match end {
End::First => lines.next(),
End::Last => lines.next_back(),
}
.unwrap_or("no output")
.trim()
.to_owned()
}
fn base64url(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let mut word: u32 = 0;
for (index, byte) in chunk.iter().enumerate() {
word |= u32::from(*byte) << (16 - 8 * index);
}
for position in 0..=chunk.len() {
let sextet = (word >> (18 - 6 * position)) & 0x3f;
let Ok(index) = usize::try_from(sextet) else {
continue;
};
out.push(char::from(ALPHABET[index]));
}
}
out
}
#[cfg(test)]
mod tests {
use super::base64url;
#[test]
fn base64url_matches_the_rfc_vectors() {
assert_eq!(base64url(b""), "");
assert_eq!(base64url(b"f"), "Zg");
assert_eq!(base64url(b"fo"), "Zm8");
assert_eq!(base64url(b"foo"), "Zm9v");
assert_eq!(base64url(b"foob"), "Zm9vYg");
assert_eq!(base64url(b"fooba"), "Zm9vYmE");
assert_eq!(base64url(b"foobar"), "Zm9vYmFy");
assert_eq!(base64url(&[0xfb, 0xef, 0xff]), "--__");
assert_eq!(base64url(&[0xff, 0xff, 0xfe]), "___-");
}
#[test]
fn the_fixed_header_encodes_stably() {
assert_eq!(
base64url(br#"{"alg":"RS256","typ":"JWT"}"#),
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
);
}
}