1use std::ffi::OsString;
21use std::io::Write as _;
22use std::time::{SystemTime, UNIX_EPOCH};
23
24use serde_json::Value;
25use zeroize::Zeroizing;
26
27use super::context::Ctx;
28use super::process::{self, Exec};
29use super::secrets;
30use crate::diagnostic::{Diagnostic, Reason};
31use crate::error::RkError;
32
33pub 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";
36
37pub struct AppCredentials {
41 pub app_id: String,
43 pub key_bytes: Zeroizing<Vec<u8>>,
45}
46
47pub fn app_id() -> Result<Option<String>, RkError> {
54 let Some(app_id) = secrets::value_of("RK_BOT_APP_ID") else {
55 return Ok(None);
56 };
57 let numeric = app_id
58 .to_str()
59 .filter(|id| !id.is_empty() && id.bytes().all(|byte| byte.is_ascii_digit()));
60 let Some(app_id) = numeric else {
61 return Err(RkError::refusal(
62 Diagnostic::new(
63 Reason::PrerequisiteUnmet,
64 "RK_BOT_APP_ID is not a numeric App id",
65 )
66 .expected("the App ID from the App's settings page, digits only")
67 .action("copy the App ID, not the Client ID; the setup guide's step 5 collects it")
68 .step("install-bot"),
69 ));
70 };
71 Ok(Some(app_id.to_owned()))
72}
73
74pub fn mint(ctx: &Ctx, credentials: &AppCredentials) -> Result<String, String> {
84 let now = SystemTime::now()
85 .duration_since(UNIX_EPOCH)
86 .map_err(|_| "the system clock is before the epoch".to_owned())?
87 .as_secs();
88 let header = base64url(br#"{"alg":"RS256","typ":"JWT"}"#);
89 let claims = base64url(
90 format!(
91 r#"{{"iat":{},"exp":{},"iss":"{}"}}"#,
92 now.saturating_sub(60),
93 now + 540,
94 credentials.app_id
95 )
96 .as_bytes(),
97 );
98 let input = format!("{header}.{claims}");
99 let signature = sign(ctx, credentials, input.as_bytes())?;
100 Ok(format!("{input}.{}", base64url(&signature)))
101}
102
103fn helper_env() -> Vec<(OsString, OsString)> {
110 std::env::var_os("PATH")
111 .map(|path| vec![(OsString::from("PATH"), path)])
112 .unwrap_or_default()
113}
114
115fn carrier_env() -> Vec<(OsString, OsString)> {
128 const TRUST: [&str; 4] = [
129 "CURL_CA_BUNDLE",
130 "SSL_CERT_DIR",
131 "SSL_CERT_FILE",
132 "NIX_SSL_CERT_FILE",
133 ];
134 let mut env = helper_env();
135 env.extend(
136 TRUST
137 .iter()
138 .filter_map(|name| std::env::var_os(name).map(|value| (OsString::from(*name), value))),
139 );
140 env
141}
142
143fn sign(ctx: &Ctx, credentials: &AppCredentials, input: &[u8]) -> Result<Vec<u8>, String> {
147 let scratch = scratch_input(input)?;
148 let program = std::env::var_os("RK_OPENSSL_BIN").unwrap_or_else(|| "openssl".into());
149 let exec = Exec {
150 program,
151 args: [
152 "dgst",
153 "-sha256",
154 "-binary",
155 "-sign",
156 "/dev/stdin",
157 scratch.file.as_str(),
158 ]
159 .map(OsString::from)
160 .to_vec(),
161 env: helper_env(),
162 cwd: ctx.target.as_std_path().to_path_buf(),
163 stdin: Some(credentials.key_bytes.clone()),
164 };
165 let outcome = process::run(&exec, |_, _| {})
168 .map_err(|source| format!("openssl did not spawn: {source}; install OpenSSL"))?;
169 if !outcome.success() {
170 let stderr = process::redact(
174 &outcome.stderr,
175 std::slice::from_ref(&credentials.key_bytes),
176 );
177 return Err(format!(
178 "openssl could not sign the App JWT: {}",
179 last_line(&stderr)
180 ));
181 }
182 if outcome.stdout.is_empty() {
183 return Err("openssl signed the App JWT to an empty signature".to_owned());
184 }
185 Ok(outcome.stdout)
186}
187
188struct ScratchInput {
193 dir: std::path::PathBuf,
194 file: camino::Utf8PathBuf,
195}
196
197impl Drop for ScratchInput {
198 fn drop(&mut self) {
199 let _ = std::fs::remove_dir_all(&self.dir);
200 }
201}
202
203fn scratch_input(input: &[u8]) -> Result<ScratchInput, String> {
204 let dir = std::env::temp_dir().join(format!("rk-app-jwt-{}", std::process::id()));
205 let make = || -> std::io::Result<()> {
206 std::fs::create_dir_all(&dir)?;
207 #[cfg(unix)]
208 {
209 use std::os::unix::fs::PermissionsExt as _;
210 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?;
211 }
212 Ok(())
213 };
214 make().map_err(|source| format!("no scratch directory for the JWT input: {source}"))?;
215 let path = dir.join("signing-input");
216 std::fs::write(&path, input)
217 .map_err(|source| format!("the JWT input did not write: {source}"))?;
218 let Ok(file) = camino::Utf8PathBuf::from_path_buf(path) else {
219 return Err("the scratch path is not valid UTF-8".to_owned());
220 };
221 Ok(ScratchInput { dir, file })
222}
223
224pub enum AppApi {
226 Ok(Value),
228 Missing,
230 Refused(String),
232 Failed(String),
234}
235
236#[must_use]
245pub fn api_get(ctx: &Ctx, jwt: &str, path: &str) -> AppApi {
246 let mut headers = Zeroizing::new(Vec::new());
247 let _ = write!(
248 headers,
249 "Authorization: Bearer {jwt}\nAccept: application/vnd.github+json\nX-GitHub-Api-Version: 2022-11-28\n"
250 );
251 let program = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
252 let exec = Exec {
253 program,
254 args: [
255 "-q",
256 "-sS",
257 "--max-time",
258 "10",
259 "-H",
260 "@-",
261 "-w",
262 "\n%{http_code}",
263 &format!("https://api.github.com/{path}"),
264 ]
265 .map(OsString::from)
266 .to_vec(),
267 env: carrier_env(),
268 cwd: ctx.target.as_std_path().to_path_buf(),
269 stdin: Some(headers),
270 };
271 let outcome = match process::run(&exec, |_, _| {}) {
272 Ok(outcome) => outcome,
273 Err(source) => {
274 return AppApi::Failed(format!("curl did not spawn: {source}; install curl"));
275 }
276 };
277 let needles = [
281 jwt.as_bytes(),
282 jwt.rsplit('.').next().unwrap_or(jwt).as_bytes(),
283 ];
284 let stderr = process::redact(&outcome.stderr, &needles);
285 let stdout = process::redact(&outcome.stdout, &needles);
286 if !outcome.success() {
287 return AppApi::Failed(format!(
288 "curl could not reach the forge (exit {}): {}",
289 outcome.exit_code,
290 first_line(&stderr)
291 ));
292 }
293 let stdout = String::from_utf8_lossy(&stdout);
294 let (body, status) = stdout
295 .trim_end()
296 .rsplit_once('\n')
297 .unwrap_or_else(|| ("", stdout.trim_end()));
298 match status {
299 "200" => serde_json::from_str::<Value>(body).map_or_else(
300 |_| AppApi::Failed("the forge answer did not parse as JSON".into()),
301 AppApi::Ok,
302 ),
303 "404" => AppApi::Missing,
304 "401" | "403" => AppApi::Refused(format!(
305 "the forge refused the App credentials ({status}); RK_BOT_APP_ID and the key file must name the same App"
306 )),
307 other => AppApi::Failed(format!("the forge answered {other}")),
308 }
309}
310
311fn first_line(bytes: &[u8]) -> String {
315 non_empty_line(bytes, End::First)
316}
317
318fn last_line(bytes: &[u8]) -> String {
320 non_empty_line(bytes, End::Last)
321}
322
323#[derive(Clone, Copy)]
325enum End {
326 First,
328 Last,
330}
331
332fn non_empty_line(bytes: &[u8], end: End) -> String {
334 let text = String::from_utf8_lossy(bytes);
335 let mut lines = text.lines().filter(|line| !line.trim().is_empty());
336 match end {
337 End::First => lines.next(),
338 End::Last => lines.next_back(),
339 }
340 .unwrap_or("no output")
341 .trim()
342 .to_owned()
343}
344
345fn base64url(bytes: &[u8]) -> String {
348 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
349 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
350 for chunk in bytes.chunks(3) {
351 let mut word: u32 = 0;
352 for (index, byte) in chunk.iter().enumerate() {
353 word |= u32::from(*byte) << (16 - 8 * index);
354 }
355 for position in 0..=chunk.len() {
356 let sextet = (word >> (18 - 6 * position)) & 0x3f;
357 let Ok(index) = usize::try_from(sextet) else {
358 continue;
359 };
360 out.push(char::from(ALPHABET[index]));
361 }
362 }
363 out
364}
365
366#[cfg(test)]
367mod tests {
368 use super::base64url;
369
370 #[test]
372 fn base64url_matches_the_rfc_vectors() {
373 assert_eq!(base64url(b""), "");
374 assert_eq!(base64url(b"f"), "Zg");
375 assert_eq!(base64url(b"fo"), "Zm8");
376 assert_eq!(base64url(b"foo"), "Zm9v");
377 assert_eq!(base64url(b"foob"), "Zm9vYg");
378 assert_eq!(base64url(b"fooba"), "Zm9vYmE");
379 assert_eq!(base64url(b"foobar"), "Zm9vYmFy");
380 assert_eq!(base64url(&[0xfb, 0xef, 0xff]), "--__");
381 assert_eq!(base64url(&[0xff, 0xff, 0xfe]), "___-");
382 }
383
384 #[test]
386 fn the_fixed_header_encodes_stably() {
387 assert_eq!(
388 base64url(br#"{"alg":"RS256","typ":"JWT"}"#),
389 "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
390 );
391 }
392}