use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::Value;
use zeroize::Zeroizing;
use super::context::Ctx;
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(configured: Option<&str>) -> Result<Option<String>, RkError> {
let from_env = secrets::value_of("RK_BOT_APP_ID");
let held = from_env
.as_ref()
.and_then(|value| value.to_str())
.or(configured);
let Some(app_id) = held else {
return Ok(None);
};
let numeric =
Some(app_id).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,
"the bot App id is not numeric",
)
.expected("the App ID from the App's settings page, digits only")
.action("set setup.bot.app_id in .release-kit/config.toml, or export RK_BOT_APP_ID; copy the App ID, not the Client ID, as the setup guide's step 5 says")
.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(credentials, input.as_bytes())?;
Ok(format!("{input}.{}", base64url(&signature)))
}
fn sign(credentials: &AppCredentials, input: &[u8]) -> Result<Vec<u8>, String> {
use rustls_pki_types::pem::PemObject as _;
let der = rustls_pki_types::PrivateKeyDer::from_pem_slice(&credentials.key_bytes)
.map_err(|_| "the App key is not a PEM private key".to_owned())?;
let pair = match &der {
rustls_pki_types::PrivateKeyDer::Pkcs1(key) => {
ring::rsa::KeyPair::from_der(key.secret_pkcs1_der())
}
rustls_pki_types::PrivateKeyDer::Pkcs8(key) => {
ring::rsa::KeyPair::from_pkcs8(key.secret_pkcs8_der())
}
_ => return Err("the App key is not an RSA private key".to_owned()),
}
.map_err(|rejected| format!("the App key is not a usable RSA key: {rejected}"))?;
let mut signature = Zeroizing::new(vec![0; pair.public().modulus_len()]);
pair.sign(
&ring::signature::RSA_PKCS1_SHA256,
&ring::rand::SystemRandom::new(),
input,
&mut signature,
)
.map_err(|_| "the App key could not sign the JWT".to_owned())?;
Ok(signature.to_vec())
}
pub enum AppApi {
Ok(Value),
Missing,
Refused(String),
Failed(String),
}
#[must_use]
pub fn api_get(_ctx: &Ctx, jwt: &str, path: &str) -> AppApi {
let bearer = Zeroizing::new(format!("Bearer {jwt}"));
let url = format!("https://api.github.com/{path}");
let headers = [
("Authorization", bearer.as_str()),
("Accept", "application/vnd.github+json"),
("X-GitHub-Api-Version", "2022-11-28"),
];
let request = crate::http::Request {
method: crate::http::Method::Get,
url: &url,
headers: &headers,
body: &[],
timeout: std::time::Duration::from_secs(10),
follow: false,
};
let scrub = |text: &str| -> String {
let signature = jwt.rsplit('.').next().unwrap_or(jwt);
text.replace(jwt, "[redacted]")
.replace(signature, "[redacted]")
};
let answer = match crate::http::send(&request) {
Ok(answer) => answer,
Err(reason) => {
return AppApi::Failed(format!(
"the request did not reach the forge: {}",
scrub(&reason)
));
}
};
match answer.status {
200 => serde_json::from_slice::<Value>(&answer.body).map_or_else(
|_| AppApi::Failed("the forge answer did not parse as JSON".into()),
AppApi::Ok,
),
404 => AppApi::Missing,
status @ (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 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::{AppCredentials, base64url, mint};
fn unbase64url(text: &str) -> Vec<u8> {
const ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut bits: u32 = 0;
let mut count = 0;
let mut out = Vec::new();
for byte in text.bytes() {
let value = ALPHABET
.iter()
.position(|c| *c == byte)
.expect("a base64url byte");
bits = (bits << 6) | u32::try_from(value).expect("fits");
count += 6;
if count >= 8 {
count -= 8;
out.push(u8::try_from((bits >> count) & 0xff).expect("a byte"));
}
}
out
}
#[test]
fn the_app_jwt_verifies_with_the_public_key() {
let key = <rsa::PrivateKey as rsa::Generate>::generate();
let pem = rsa::pkcs1::EncodeRsaPrivateKey::to_pkcs1_pem(&key, rsa::pkcs1::LineEnding::LF)
.expect("the key encodes");
let public = rsa::pkcs1::EncodeRsaPublicKey::to_pkcs1_der(&key.to_public_key())
.expect("the public key encodes");
let ctx = crate::setup::context::Ctx::for_tests(
camino::Utf8PathBuf::from("/srv/widget"),
"acme/widget".to_owned(),
crate::detect::Forge::Github,
std::path::PathBuf::from("gh"),
None,
);
let credentials = AppCredentials {
app_id: "314159".to_owned(),
key_bytes: zeroize::Zeroizing::new(pem.as_bytes().to_vec()),
};
let token = mint(&ctx, &credentials).expect("the token signs");
let (input, signature) = token.rsplit_once('.').expect("three segments");
ring::signature::UnparsedPublicKey::new(
&ring::signature::RSA_PKCS1_2048_8192_SHA256,
public.as_bytes(),
)
.verify(input.as_bytes(), &unbase64url(signature))
.expect("the signature verifies with the public key");
let (header, claims) = input.split_once('.').expect("a header and claims");
assert_eq!(unbase64url(header), br#"{"alg":"RS256","typ":"JWT"}"#);
let claims: serde_json::Value =
serde_json::from_slice(&unbase64url(claims)).expect("the claims parse");
assert_eq!(claims["iss"], "314159");
let iat = claims["iat"].as_u64().expect("an iat");
let exp = claims["exp"].as_u64().expect("an exp");
assert_eq!(exp - iat, 600, "a minute back and nine minutes out");
}
#[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"
);
}
}