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, has the OpenSSL CLI sign it with the
8//! key bytes on standard input, and carries the resulting token to the
9//! forge through `curl`, in a header read from standard input — the JWT
10//! is a credential, and `forge-setup:a-secret-never-reaches-argv` binds
11//! it like any other.
12//!
13//! Both spawns deliberately bypass the run's journaling executor: the
14//! executor records child output, the signer's output is the token's
15//! third segment, and a `curl` made verbose by a host configuration would
16//! echo the very header it was handed. Neither child's streams reach a
17//! journal, an event, or a transcript; the answers surface only as the
18//! classified [`AppApi`] and the step states built from it.
19
20use 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
33/// What exporting the credentials would enable, named wherever they are
34/// absent.
35pub 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
37/// The two halves of the App identity. The caller resolves both — the id
38/// from the environment, the key from the run's one read of the named
39/// file — so this module never opens anything itself.
40pub struct AppCredentials {
41    /// The numeric App id, which becomes the token's `iss`.
42    pub app_id: String,
43    /// The validated private key's bytes.
44    pub key_bytes: Zeroizing<Vec<u8>>,
45}
46
47/// The App id from the environment, absent when unset.
48///
49/// # Errors
50///
51/// Refuses an `RK_BOT_APP_ID` that is not the numeric id — the value
52/// lands in a JSON claim, so anything else would sign a malformed token.
53pub 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
74/// Mint a short-lived RS256 JWT for the App: `iss` is the App id, `iat`
75/// sits sixty seconds back against clock drift, and `exp` nine minutes
76/// out, inside the forge's ten-minute cap.
77///
78/// # Errors
79///
80/// A failure is a one-line detail for the caller to report — an
81/// observation maps it to `unknown`, an apply to a refusal — never a
82/// token that might be wrong.
83pub 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
103/// The minimal environment a helper child receives: the search path and
104/// nothing else. Each helper holds one credential on its standard input,
105/// and an environment carrying any other — the forge CLI tokens the
106/// setup's own children inherit — would put that one within reach of a
107/// helper's error stream, which only the helper's own credential is
108/// scrubbed against.
109fn 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
115/// What the carrying child adds to that: the variables naming where this
116/// host keeps its certificate authorities.
117///
118/// They earn their exception by naming public files, and only the carrier
119/// takes them. A `curl` that locates its trust store by environment rather
120/// than by a compiled-in path — a Nix-provided one on a host whose
121/// distribution keeps its own bundle elsewhere is the ordinary case —
122/// verifies no certificate at all once they are cleared, and the call
123/// fails before the forge answers. The signer receives none of them: it
124/// opens no connection and has nothing to verify. Proxy variables stay
125/// out of both, because a proxy URL can carry a credential of its own,
126/// which is the one thing these environments must not hold.
127fn 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
143/// Sign the token's input with the App key, through the OpenSSL CLI: the
144/// key bytes travel on standard input, the non-secret signing input as a
145/// private scratch file, and no child is told the key's path.
146fn 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    // Chunks are dropped as they stream: the signature is credential
166    // material, and nothing here may record it.
167    let outcome = process::run(&exec, |_, _| {})
168        .map_err(|source| format!("openssl did not spawn: {source}; install OpenSSL"))?;
169    if !outcome.success() {
170        // The child held the key on its standard input, so its error
171        // stream is scrubbed against the key bytes before one line of it
172        // can reach a diagnostic or a journal.
173        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
188/// The signing input, written under a fresh owner-only scratch directory
189/// that lives exactly as long as the signing spawn. The bytes are not
190/// secret — an algorithm, two timestamps, and the public App id — but the
191/// directory is 0700 anyway, because a looser scratch is a habit.
192struct 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
224/// One read-only forge answer, asked as the App itself.
225pub enum AppApi {
226    /// The call succeeded and parsed.
227    Ok(Value),
228    /// The forge answered 404: the thing is not there.
229    Missing,
230    /// The forge refused the App credentials.
231    Refused(String),
232    /// The call failed for another reason.
233    Failed(String),
234}
235
236/// `GET https://api.github.com/{path}` with the JWT as a bearer token.
237///
238/// The call goes through `curl`, spawned directly rather than through the
239/// run's executor so no stream of it can be journaled: the Authorization
240/// header arrives on standard input via `-H @-`, so the token never
241/// reaches an argument list, `-q` leads the arguments so no `.curlrc` can
242/// turn on an echoing verbosity or reshape the call, and the trailing
243/// `-w` line carries the status the answer is classified by.
244#[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    // The child held the bearer header on its standard input, so both of
278    // its streams are scrubbed against the token and its signature before
279    // one line of either can reach a diagnostic or an output stream.
280    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
311/// The first non-empty line of a byte stream, which is where `curl` names
312/// what went wrong; the lines after it are prose pointing at a web page,
313/// so the last line of a failure carries none of the reason.
314fn first_line(bytes: &[u8]) -> String {
315    non_empty_line(bytes, End::First)
316}
317
318/// The last non-empty line of a byte stream, where a CLI puts its verdict.
319fn last_line(bytes: &[u8]) -> String {
320    non_empty_line(bytes, End::Last)
321}
322
323/// Which end of a stream a line is taken from.
324#[derive(Clone, Copy)]
325enum End {
326    /// The first non-empty line.
327    First,
328    /// The last non-empty line.
329    Last,
330}
331
332/// One non-empty line of a byte stream, taken from the named end.
333fn 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
345/// RFC 4648 base64url without padding, which is what a JWT's segments
346/// carry.
347fn 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    /// The RFC 4648 vectors, in the url-safe alphabet, unpadded.
371    #[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    /// A JWT header encodes to the value every RS256 example shows.
385    #[test]
386    fn the_fixed_header_encodes_stably() {
387        assert_eq!(
388            base64url(br#"{"alg":"RS256","typ":"JWT"}"#),
389            "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
390        );
391    }
392}