1use std::time::{SystemTime, UNIX_EPOCH};
20
21use serde_json::Value;
22use zeroize::Zeroizing;
23
24use super::context::Ctx;
25use super::secrets;
26use crate::diagnostic::{Diagnostic, Reason};
27use crate::error::RkError;
28
29pub 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";
32
33pub struct AppCredentials {
37 pub app_id: String,
39 pub key_bytes: Zeroizing<Vec<u8>>,
41}
42
43pub fn app_id(configured: Option<&str>) -> Result<Option<String>, RkError> {
50 let from_env = secrets::value_of("RK_BOT_APP_ID");
55 let held = from_env
56 .as_ref()
57 .and_then(|value| value.to_str())
58 .or(configured);
59 let Some(app_id) = held else {
60 return Ok(None);
61 };
62 let numeric =
63 Some(app_id).filter(|id| !id.is_empty() && id.bytes().all(|byte| byte.is_ascii_digit()));
64 let Some(app_id) = numeric else {
65 return Err(RkError::refusal(
66 Diagnostic::new(
67 Reason::PrerequisiteUnmet,
68 "the bot App id is not numeric",
69 )
70 .expected("the App ID from the App's settings page, digits only")
71 .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")
72 .step("install-bot"),
73 ));
74 };
75 Ok(Some(app_id.to_owned()))
76}
77
78pub fn mint(_ctx: &Ctx, credentials: &AppCredentials) -> Result<String, String> {
88 let now = SystemTime::now()
89 .duration_since(UNIX_EPOCH)
90 .map_err(|_| "the system clock is before the epoch".to_owned())?
91 .as_secs();
92 let header = base64url(br#"{"alg":"RS256","typ":"JWT"}"#);
93 let claims = base64url(
94 format!(
95 r#"{{"iat":{},"exp":{},"iss":"{}"}}"#,
96 now.saturating_sub(60),
97 now + 540,
98 credentials.app_id
99 )
100 .as_bytes(),
101 );
102 let input = format!("{header}.{claims}");
103 let signature = sign(credentials, input.as_bytes())?;
104 Ok(format!("{input}.{}", base64url(&signature)))
105}
106
107fn sign(credentials: &AppCredentials, input: &[u8]) -> Result<Vec<u8>, String> {
112 use rustls_pki_types::pem::PemObject as _;
113 let der = rustls_pki_types::PrivateKeyDer::from_pem_slice(&credentials.key_bytes)
114 .map_err(|_| "the App key is not a PEM private key".to_owned())?;
115 let pair = match &der {
116 rustls_pki_types::PrivateKeyDer::Pkcs1(key) => {
117 ring::rsa::KeyPair::from_der(key.secret_pkcs1_der())
118 }
119 rustls_pki_types::PrivateKeyDer::Pkcs8(key) => {
120 ring::rsa::KeyPair::from_pkcs8(key.secret_pkcs8_der())
121 }
122 _ => return Err("the App key is not an RSA private key".to_owned()),
123 }
124 .map_err(|rejected| format!("the App key is not a usable RSA key: {rejected}"))?;
125 let mut signature = Zeroizing::new(vec![0; pair.public().modulus_len()]);
126 pair.sign(
127 &ring::signature::RSA_PKCS1_SHA256,
128 &ring::rand::SystemRandom::new(),
129 input,
130 &mut signature,
131 )
132 .map_err(|_| "the App key could not sign the JWT".to_owned())?;
133 Ok(signature.to_vec())
134}
135
136pub enum AppApi {
138 Ok(Value),
140 Missing,
142 Refused(String),
144 Failed(String),
146}
147
148#[must_use]
155pub fn api_get(_ctx: &Ctx, jwt: &str, path: &str) -> AppApi {
156 let bearer = Zeroizing::new(format!("Bearer {jwt}"));
157 let url = format!("https://api.github.com/{path}");
158 let headers = [
159 ("Authorization", bearer.as_str()),
160 ("Accept", "application/vnd.github+json"),
161 ("X-GitHub-Api-Version", "2022-11-28"),
162 ];
163 let request = crate::http::Request {
164 method: crate::http::Method::Get,
165 url: &url,
166 headers: &headers,
167 body: &[],
168 timeout: std::time::Duration::from_secs(10),
169 follow: false,
171 };
172 let scrub = |text: &str| -> String {
173 let signature = jwt.rsplit('.').next().unwrap_or(jwt);
174 text.replace(jwt, "[redacted]")
175 .replace(signature, "[redacted]")
176 };
177 let answer = match crate::http::send(&request) {
178 Ok(answer) => answer,
179 Err(reason) => {
180 return AppApi::Failed(format!(
181 "the request did not reach the forge: {}",
182 scrub(&reason)
183 ));
184 }
185 };
186 match answer.status {
187 200 => serde_json::from_slice::<Value>(&answer.body).map_or_else(
188 |_| AppApi::Failed("the forge answer did not parse as JSON".into()),
189 AppApi::Ok,
190 ),
191 404 => AppApi::Missing,
192 status @ (401 | 403) => AppApi::Refused(format!(
193 "the forge refused the App credentials ({status}); RK_BOT_APP_ID and the key file must name the same App"
194 )),
195 other => AppApi::Failed(format!("the forge answered {other}")),
196 }
197}
198
199fn base64url(bytes: &[u8]) -> String {
202 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
203 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
204 for chunk in bytes.chunks(3) {
205 let mut word: u32 = 0;
206 for (index, byte) in chunk.iter().enumerate() {
207 word |= u32::from(*byte) << (16 - 8 * index);
208 }
209 for position in 0..=chunk.len() {
210 let sextet = (word >> (18 - 6 * position)) & 0x3f;
211 let Ok(index) = usize::try_from(sextet) else {
212 continue;
213 };
214 out.push(char::from(ALPHABET[index]));
215 }
216 }
217 out
218}
219
220#[cfg(test)]
221mod tests {
222 use super::{AppCredentials, base64url, mint};
223
224 fn unbase64url(text: &str) -> Vec<u8> {
226 const ALPHABET: &[u8; 64] =
227 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
228 let mut bits: u32 = 0;
229 let mut count = 0;
230 let mut out = Vec::new();
231 for byte in text.bytes() {
232 let value = ALPHABET
233 .iter()
234 .position(|c| *c == byte)
235 .expect("a base64url byte");
236 bits = (bits << 6) | u32::try_from(value).expect("fits");
237 count += 6;
238 if count >= 8 {
239 count -= 8;
240 out.push(u8::try_from((bits >> count) & 0xff).expect("a byte"));
241 }
242 }
243 out
244 }
245
246 #[test]
250 fn the_app_jwt_verifies_with_the_public_key() {
251 let key = <rsa::PrivateKey as rsa::Generate>::generate();
252 let pem = rsa::pkcs1::EncodeRsaPrivateKey::to_pkcs1_pem(&key, rsa::pkcs1::LineEnding::LF)
253 .expect("the key encodes");
254 let public = rsa::pkcs1::EncodeRsaPublicKey::to_pkcs1_der(&key.to_public_key())
255 .expect("the public key encodes");
256 let ctx = crate::setup::context::Ctx::for_tests(
257 camino::Utf8PathBuf::from("/srv/widget"),
258 "acme/widget".to_owned(),
259 crate::detect::Forge::Github,
260 std::path::PathBuf::from("gh"),
261 None,
262 );
263 let credentials = AppCredentials {
264 app_id: "314159".to_owned(),
265 key_bytes: zeroize::Zeroizing::new(pem.as_bytes().to_vec()),
266 };
267 let token = mint(&ctx, &credentials).expect("the token signs");
268 let (input, signature) = token.rsplit_once('.').expect("three segments");
269 ring::signature::UnparsedPublicKey::new(
270 &ring::signature::RSA_PKCS1_2048_8192_SHA256,
271 public.as_bytes(),
272 )
273 .verify(input.as_bytes(), &unbase64url(signature))
274 .expect("the signature verifies with the public key");
275 let (header, claims) = input.split_once('.').expect("a header and claims");
276 assert_eq!(unbase64url(header), br#"{"alg":"RS256","typ":"JWT"}"#);
277 let claims: serde_json::Value =
278 serde_json::from_slice(&unbase64url(claims)).expect("the claims parse");
279 assert_eq!(claims["iss"], "314159");
280 let iat = claims["iat"].as_u64().expect("an iat");
281 let exp = claims["exp"].as_u64().expect("an exp");
282 assert_eq!(exp - iat, 600, "a minute back and nine minutes out");
283 }
284
285 #[test]
287 fn base64url_matches_the_rfc_vectors() {
288 assert_eq!(base64url(b""), "");
289 assert_eq!(base64url(b"f"), "Zg");
290 assert_eq!(base64url(b"fo"), "Zm8");
291 assert_eq!(base64url(b"foo"), "Zm9v");
292 assert_eq!(base64url(b"foob"), "Zm9vYg");
293 assert_eq!(base64url(b"fooba"), "Zm9vYmE");
294 assert_eq!(base64url(b"foobar"), "Zm9vYmFy");
295 assert_eq!(base64url(&[0xfb, 0xef, 0xff]), "--__");
296 assert_eq!(base64url(&[0xff, 0xff, 0xfe]), "___-");
297 }
298
299 #[test]
301 fn the_fixed_header_encodes_stably() {
302 assert_eq!(
303 base64url(br#"{"alg":"RS256","typ":"JWT"}"#),
304 "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
305 );
306 }
307}