Skip to main content

release_kit/setup/
app_jwt.rs

1//! Authenticating to the forge as the bot App itself.
2//!
3//! GitHub serves the installation-reading endpoints to App credentials
4//! only: `GET /repos/{owner}/{repo}/installation` takes a JWT signed with
5//! the App's private key, and no personal access token of any class is
6//! accepted there. So the `install-bot` step observes as the App: `rk`
7//! builds the RS256 signing input, signs it in-process with the key bytes
8//! it already holds, and carries the resulting token to the forge through
9//! `crate::http`, in a header — the JWT is a credential, and
10//! `forge-setup:a-secret-never-reaches-argv` binds it like any other. No
11//! child process holds the key or the token, so neither can reach an
12//! argument list, an environment, or a stream.
13//!
14//! The answers surface only as the classified [`AppApi`] and the step
15//! states built from it: no request or response reaches a journal, an
16//! event, or a transcript, and a failure's reason is scrubbed against the
17//! token before it is returned.
18
19use 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
29/// What exporting the credentials would enable, named wherever they are
30/// absent.
31pub 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
33/// The two halves of the App identity. The caller resolves both — the id
34/// from the environment, the key from the run's one read of the named
35/// file — so this module never opens anything itself.
36pub struct AppCredentials {
37    /// The numeric App id, which becomes the token's `iss`.
38    pub app_id: String,
39    /// The validated private key's bytes.
40    pub key_bytes: Zeroizing<Vec<u8>>,
41}
42
43/// The App id from the environment, absent when unset.
44///
45/// # Errors
46///
47/// Refuses an `RK_BOT_APP_ID` that is not the numeric id — the value
48/// lands in a JSON claim, so anything else would sign a malformed token.
49pub fn app_id(configured: Option<&str>) -> Result<Option<String>, RkError> {
50    // The environment wins: an operator running against another App for
51    // one command must not be overruled by a committed file. The
52    // committed identifier is the fallback, so the common case needs no
53    // export at all.
54    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
78/// Mint a short-lived RS256 JWT for the App: `iss` is the App id, `iat`
79/// sits sixty seconds back against clock drift, and `exp` nine minutes
80/// out, inside the forge's ten-minute cap.
81///
82/// # Errors
83///
84/// A failure is a one-line detail for the caller to report — an
85/// observation maps it to `unknown`, an apply to a refusal — never a
86/// token that might be wrong.
87pub 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
107/// Sign the token's input with the App key, in-process: RSASSA-PKCS1-v1_5
108/// with SHA-256, which is RS256. The key is the PEM the App's settings
109/// page issues, PKCS#1 or PKCS#8. Its bytes are read from what the run
110/// already holds, and no error names them.
111fn 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
136/// One read-only forge answer, asked as the App itself.
137pub enum AppApi {
138    /// The call succeeded and parsed.
139    Ok(Value),
140    /// The forge answered 404: the thing is not there.
141    Missing,
142    /// The forge refused the App credentials.
143    Refused(String),
144    /// The call failed for another reason.
145    Failed(String),
146}
147
148/// `GET https://api.github.com/{path}` with the JWT as a bearer token.
149///
150/// The call goes through `crate::http` in-process, with a ten-second
151/// bound: the Authorization header lives in this process alone, and a
152/// failure's reason is scrubbed against the token and its signature
153/// before it is returned.
154#[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        // The curl call this replaced followed no redirect.
170        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
199/// RFC 4648 base64url without padding, which is what a JWT's segments
200/// carry.
201fn 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    /// The inverse of [`base64url`], for reading a token back.
225    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    /// The token rk signs in-process verifies against the key pair's public
247    /// half under RS256, and carries the claims GitHub requires: `iss`, the
248    /// App id, `iat` a minute back, and `exp` inside the ten-minute cap.
249    #[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    /// The RFC 4648 vectors, in the url-safe alphabet, unpadded.
286    #[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    /// A JWT header encodes to the value every RS256 example shows.
300    #[test]
301    fn the_fixed_header_encodes_stably() {
302        assert_eq!(
303            base64url(br#"{"alg":"RS256","typ":"JWT"}"#),
304            "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
305        );
306    }
307}