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(configured: Option<&str>) -> Result<Option<String>, RkError> {
54    // The environment wins: an operator running against another App for
55    // one command must not be overruled by a committed file. The
56    // committed identifier is the fallback, so the common case needs no
57    // export at all.
58    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
82/// Mint a short-lived RS256 JWT for the App: `iss` is the App id, `iat`
83/// sits sixty seconds back against clock drift, and `exp` nine minutes
84/// out, inside the forge's ten-minute cap.
85///
86/// # Errors
87///
88/// A failure is a one-line detail for the caller to report — an
89/// observation maps it to `unknown`, an apply to a refusal — never a
90/// token that might be wrong.
91pub 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
111/// The minimal environment a helper child receives: the search path and
112/// nothing else. Each helper holds one credential on its standard input,
113/// and an environment carrying any other — the forge CLI tokens the
114/// setup's own children inherit — would put that one within reach of a
115/// helper's error stream, which only the helper's own credential is
116/// scrubbed against.
117fn 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
123/// What the carrying child adds to that: the variables naming where this
124/// host keeps its certificate authorities.
125///
126/// They earn their exception by naming public files, and only the carrier
127/// takes them. A `curl` that locates its trust store by environment rather
128/// than by a compiled-in path — a Nix-provided one on a host whose
129/// distribution keeps its own bundle elsewhere is the ordinary case —
130/// verifies no certificate at all once they are cleared, and the call
131/// fails before the forge answers. The signer receives none of them: it
132/// opens no connection and has nothing to verify. Proxy variables stay
133/// out of both, because a proxy URL can carry a credential of its own,
134/// which is the one thing these environments must not hold.
135fn 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
151/// Sign the token's input with the App key, through the OpenSSL CLI: the
152/// key bytes travel on standard input, the non-secret signing input as a
153/// private scratch file, and no child is told the key's path.
154fn 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    // Chunks are dropped as they stream: the signature is credential
174    // material, and nothing here may record it.
175    let outcome = process::run(&exec, |_, _| {})
176        .map_err(|source| format!("openssl did not spawn: {source}; install OpenSSL"))?;
177    if !outcome.success() {
178        // The child held the key on its standard input, so its error
179        // stream is scrubbed against the key bytes before one line of it
180        // can reach a diagnostic or a journal.
181        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
196/// The signing input, written under a fresh owner-only scratch directory
197/// that lives exactly as long as the signing spawn. The bytes are not
198/// secret — an algorithm, two timestamps, and the public App id — but the
199/// directory is 0700 anyway, because a looser scratch is a habit.
200struct 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
232/// One read-only forge answer, asked as the App itself.
233pub enum AppApi {
234    /// The call succeeded and parsed.
235    Ok(Value),
236    /// The forge answered 404: the thing is not there.
237    Missing,
238    /// The forge refused the App credentials.
239    Refused(String),
240    /// The call failed for another reason.
241    Failed(String),
242}
243
244/// `GET https://api.github.com/{path}` with the JWT as a bearer token.
245///
246/// The call goes through `curl`, spawned directly rather than through the
247/// run's executor so no stream of it can be journaled: the Authorization
248/// header arrives on standard input via `-H @-`, so the token never
249/// reaches an argument list, `-q` leads the arguments so no `.curlrc` can
250/// turn on an echoing verbosity or reshape the call, and the trailing
251/// `-w` line carries the status the answer is classified by.
252#[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    // The child held the bearer header on its standard input, so both of
286    // its streams are scrubbed against the token and its signature before
287    // one line of either can reach a diagnostic or an output stream.
288    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
319/// The first non-empty line of a byte stream, which is where `curl` names
320/// what went wrong; the lines after it are prose pointing at a web page,
321/// so the last line of a failure carries none of the reason.
322fn first_line(bytes: &[u8]) -> String {
323    non_empty_line(bytes, End::First)
324}
325
326/// The last non-empty line of a byte stream, where a CLI puts its verdict.
327fn last_line(bytes: &[u8]) -> String {
328    non_empty_line(bytes, End::Last)
329}
330
331/// Which end of a stream a line is taken from.
332#[derive(Clone, Copy)]
333enum End {
334    /// The first non-empty line.
335    First,
336    /// The last non-empty line.
337    Last,
338}
339
340/// One non-empty line of a byte stream, taken from the named end.
341fn 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
353/// RFC 4648 base64url without padding, which is what a JWT's segments
354/// carry.
355fn 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    /// The RFC 4648 vectors, in the url-safe alphabet, unpadded.
379    #[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    /// A JWT header encodes to the value every RS256 example shows.
393    #[test]
394    fn the_fixed_header_encodes_stably() {
395        assert_eq!(
396            base64url(br#"{"alg":"RS256","typ":"JWT"}"#),
397            "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
398        );
399    }
400}