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(configured: Option<&str>) -> Result<Option<String>, RkError> {
54 let from_env = secrets::value_of("RK_BOT_APP_ID");
59 let held = from_env
60 .as_ref()
61 .and_then(|value| value.to_str())
62 .or(configured);
63 let Some(app_id) = held else {
64 return Ok(None);
65 };
66 let numeric =
67 Some(app_id).filter(|id| !id.is_empty() && id.bytes().all(|byte| byte.is_ascii_digit()));
68 let Some(app_id) = numeric else {
69 return Err(RkError::refusal(
70 Diagnostic::new(
71 Reason::PrerequisiteUnmet,
72 "the bot App id is not numeric",
73 )
74 .expected("the App ID from the App's settings page, digits only")
75 .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")
76 .step("install-bot"),
77 ));
78 };
79 Ok(Some(app_id.to_owned()))
80}
81
82pub fn mint(ctx: &Ctx, credentials: &AppCredentials) -> Result<String, String> {
92 let now = SystemTime::now()
93 .duration_since(UNIX_EPOCH)
94 .map_err(|_| "the system clock is before the epoch".to_owned())?
95 .as_secs();
96 let header = base64url(br#"{"alg":"RS256","typ":"JWT"}"#);
97 let claims = base64url(
98 format!(
99 r#"{{"iat":{},"exp":{},"iss":"{}"}}"#,
100 now.saturating_sub(60),
101 now + 540,
102 credentials.app_id
103 )
104 .as_bytes(),
105 );
106 let input = format!("{header}.{claims}");
107 let signature = sign(ctx, credentials, input.as_bytes())?;
108 Ok(format!("{input}.{}", base64url(&signature)))
109}
110
111fn helper_env() -> Vec<(OsString, OsString)> {
118 std::env::var_os("PATH")
119 .map(|path| vec![(OsString::from("PATH"), path)])
120 .unwrap_or_default()
121}
122
123fn carrier_env() -> Vec<(OsString, OsString)> {
136 const TRUST: [&str; 4] = [
137 "CURL_CA_BUNDLE",
138 "SSL_CERT_DIR",
139 "SSL_CERT_FILE",
140 "NIX_SSL_CERT_FILE",
141 ];
142 let mut env = helper_env();
143 env.extend(
144 TRUST
145 .iter()
146 .filter_map(|name| std::env::var_os(name).map(|value| (OsString::from(*name), value))),
147 );
148 env
149}
150
151fn sign(ctx: &Ctx, credentials: &AppCredentials, input: &[u8]) -> Result<Vec<u8>, String> {
155 let scratch = scratch_input(input)?;
156 let program = std::env::var_os("RK_OPENSSL_BIN").unwrap_or_else(|| "openssl".into());
157 let exec = Exec {
158 program,
159 args: [
160 "dgst",
161 "-sha256",
162 "-binary",
163 "-sign",
164 "/dev/stdin",
165 scratch.file.as_str(),
166 ]
167 .map(OsString::from)
168 .to_vec(),
169 env: helper_env(),
170 cwd: ctx.target.as_std_path().to_path_buf(),
171 stdin: Some(credentials.key_bytes.clone()),
172 };
173 let outcome = process::run(&exec, |_, _| {})
176 .map_err(|source| format!("openssl did not spawn: {source}; install OpenSSL"))?;
177 if !outcome.success() {
178 let stderr = process::redact(
182 &outcome.stderr,
183 std::slice::from_ref(&credentials.key_bytes),
184 );
185 return Err(format!(
186 "openssl could not sign the App JWT: {}",
187 last_line(&stderr)
188 ));
189 }
190 if outcome.stdout.is_empty() {
191 return Err("openssl signed the App JWT to an empty signature".to_owned());
192 }
193 Ok(outcome.stdout)
194}
195
196struct ScratchInput {
201 dir: std::path::PathBuf,
202 file: camino::Utf8PathBuf,
203}
204
205impl Drop for ScratchInput {
206 fn drop(&mut self) {
207 let _ = std::fs::remove_dir_all(&self.dir);
208 }
209}
210
211fn scratch_input(input: &[u8]) -> Result<ScratchInput, String> {
212 let dir = std::env::temp_dir().join(format!("rk-app-jwt-{}", std::process::id()));
213 let make = || -> std::io::Result<()> {
214 std::fs::create_dir_all(&dir)?;
215 #[cfg(unix)]
216 {
217 use std::os::unix::fs::PermissionsExt as _;
218 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?;
219 }
220 Ok(())
221 };
222 make().map_err(|source| format!("no scratch directory for the JWT input: {source}"))?;
223 let path = dir.join("signing-input");
224 std::fs::write(&path, input)
225 .map_err(|source| format!("the JWT input did not write: {source}"))?;
226 let Ok(file) = camino::Utf8PathBuf::from_path_buf(path) else {
227 return Err("the scratch path is not valid UTF-8".to_owned());
228 };
229 Ok(ScratchInput { dir, file })
230}
231
232pub enum AppApi {
234 Ok(Value),
236 Missing,
238 Refused(String),
240 Failed(String),
242}
243
244#[must_use]
253pub fn api_get(ctx: &Ctx, jwt: &str, path: &str) -> AppApi {
254 let mut headers = Zeroizing::new(Vec::new());
255 let _ = write!(
256 headers,
257 "Authorization: Bearer {jwt}\nAccept: application/vnd.github+json\nX-GitHub-Api-Version: 2022-11-28\n"
258 );
259 let program = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
260 let exec = Exec {
261 program,
262 args: [
263 "-q",
264 "-sS",
265 "--max-time",
266 "10",
267 "-H",
268 "@-",
269 "-w",
270 "\n%{http_code}",
271 &format!("https://api.github.com/{path}"),
272 ]
273 .map(OsString::from)
274 .to_vec(),
275 env: carrier_env(),
276 cwd: ctx.target.as_std_path().to_path_buf(),
277 stdin: Some(headers),
278 };
279 let outcome = match process::run(&exec, |_, _| {}) {
280 Ok(outcome) => outcome,
281 Err(source) => {
282 return AppApi::Failed(format!("curl did not spawn: {source}; install curl"));
283 }
284 };
285 let needles = [
289 jwt.as_bytes(),
290 jwt.rsplit('.').next().unwrap_or(jwt).as_bytes(),
291 ];
292 let stderr = process::redact(&outcome.stderr, &needles);
293 let stdout = process::redact(&outcome.stdout, &needles);
294 if !outcome.success() {
295 return AppApi::Failed(format!(
296 "curl could not reach the forge (exit {}): {}",
297 outcome.exit_code,
298 first_line(&stderr)
299 ));
300 }
301 let stdout = String::from_utf8_lossy(&stdout);
302 let (body, status) = stdout
303 .trim_end()
304 .rsplit_once('\n')
305 .unwrap_or_else(|| ("", stdout.trim_end()));
306 match status {
307 "200" => serde_json::from_str::<Value>(body).map_or_else(
308 |_| AppApi::Failed("the forge answer did not parse as JSON".into()),
309 AppApi::Ok,
310 ),
311 "404" => AppApi::Missing,
312 "401" | "403" => AppApi::Refused(format!(
313 "the forge refused the App credentials ({status}); RK_BOT_APP_ID and the key file must name the same App"
314 )),
315 other => AppApi::Failed(format!("the forge answered {other}")),
316 }
317}
318
319fn first_line(bytes: &[u8]) -> String {
323 non_empty_line(bytes, End::First)
324}
325
326fn last_line(bytes: &[u8]) -> String {
328 non_empty_line(bytes, End::Last)
329}
330
331#[derive(Clone, Copy)]
333enum End {
334 First,
336 Last,
338}
339
340fn non_empty_line(bytes: &[u8], end: End) -> String {
342 let text = String::from_utf8_lossy(bytes);
343 let mut lines = text.lines().filter(|line| !line.trim().is_empty());
344 match end {
345 End::First => lines.next(),
346 End::Last => lines.next_back(),
347 }
348 .unwrap_or("no output")
349 .trim()
350 .to_owned()
351}
352
353fn base64url(bytes: &[u8]) -> String {
356 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
357 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
358 for chunk in bytes.chunks(3) {
359 let mut word: u32 = 0;
360 for (index, byte) in chunk.iter().enumerate() {
361 word |= u32::from(*byte) << (16 - 8 * index);
362 }
363 for position in 0..=chunk.len() {
364 let sextet = (word >> (18 - 6 * position)) & 0x3f;
365 let Ok(index) = usize::try_from(sextet) else {
366 continue;
367 };
368 out.push(char::from(ALPHABET[index]));
369 }
370 }
371 out
372}
373
374#[cfg(test)]
375mod tests {
376 use super::base64url;
377
378 #[test]
380 fn base64url_matches_the_rfc_vectors() {
381 assert_eq!(base64url(b""), "");
382 assert_eq!(base64url(b"f"), "Zg");
383 assert_eq!(base64url(b"fo"), "Zm8");
384 assert_eq!(base64url(b"foo"), "Zm9v");
385 assert_eq!(base64url(b"foob"), "Zm9vYg");
386 assert_eq!(base64url(b"fooba"), "Zm9vYmE");
387 assert_eq!(base64url(b"foobar"), "Zm9vYmFy");
388 assert_eq!(base64url(&[0xfb, 0xef, 0xff]), "--__");
389 assert_eq!(base64url(&[0xff, 0xff, 0xfe]), "___-");
390 }
391
392 #[test]
394 fn the_fixed_header_encodes_stably() {
395 assert_eq!(
396 base64url(br#"{"alg":"RS256","typ":"JWT"}"#),
397 "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
398 );
399 }
400}