Skip to main content

vgi_forge_github/
jwt.rs

1//! GitHub App authentication: the RS256 App JWT.
2//!
3//! The App's private key signs a short JWT; the JWT buys an installation
4//! access token; the installation token does the work. Only the first step
5//! touches the key, and it goes through [`AppKeySigner`] so the key can live
6//! in an enclave signer that will sign (and log) but never export it (ยง5.7).
7//! [`InProcessKey`] is the in-memory implementation for deployments without
8//! one.
9
10use std::fmt;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use aws_lc_rs::rand::SystemRandom;
14use aws_lc_rs::signature::{RSA_PKCS1_SHA256, RsaKeyPair};
15use base64::Engine;
16use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
17use vgi_forge::async_trait;
18use vgi_forge::{ForgeError, Result};
19use zeroize::Zeroizing;
20
21use crate::secret::Secret;
22
23/// How far `iat` is backdated. GitHub's documented allowance for clock
24/// drift between the bridge and GitHub.
25pub const JWT_BACKDATE_SECS: u64 = 60;
26
27/// How long an App JWT is valid from now. GitHub refuses more than ten
28/// minutes; nine leaves room for our clock running slightly slow.
29pub const JWT_LIFETIME_SECS: u64 = 9 * 60;
30
31/// Signs App JWTs with the App's private key (RSASSA-PKCS1-v1_5, SHA-256).
32///
33/// Async because a real deployment's signer is another process โ€” the
34/// enclave โ€” and signing is a round-trip to it.
35#[async_trait]
36pub trait AppKeySigner: Send + Sync {
37    /// RS256 signature over `message`.
38    async fn sign_rs256(&self, message: &[u8]) -> Result<Vec<u8>>;
39}
40
41/// The App private key held in this process.
42pub struct InProcessKey {
43    key: RsaKeyPair,
44}
45
46impl InProcessKey {
47    /// Load the PEM GitHub issues (`-----BEGIN RSA PRIVATE KEY-----`, PKCS#1)
48    /// or a PKCS#8 `-----BEGIN PRIVATE KEY-----`. Encrypted keys are refused.
49    pub fn from_pem(pem: &str) -> Result<Self> {
50        let (label, der) = decode_pem(pem)?;
51        let key = match label.as_str() {
52            "RSA PRIVATE KEY" => RsaKeyPair::from_der(&der),
53            "PRIVATE KEY" => RsaKeyPair::from_pkcs8(&der),
54            other => {
55                return Err(ForgeError::Config(format!(
56                    "expected an RSA private key PEM (`RSA PRIVATE KEY` or `PRIVATE KEY`), got \
57                     `{other}`"
58                )));
59            }
60        }
61        .map_err(|e| ForgeError::Config(format!("the App private key was rejected: {e}")))?;
62        Ok(InProcessKey { key })
63    }
64
65    /// Load from a [`Secret`] holding the PEM.
66    pub fn from_secret(pem: &Secret) -> Result<Self> {
67        Self::from_pem(pem.expose())
68    }
69}
70
71impl fmt::Debug for InProcessKey {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        f.write_str("InProcessKey(<redacted>)")
74    }
75}
76
77#[async_trait]
78impl AppKeySigner for InProcessKey {
79    async fn sign_rs256(&self, message: &[u8]) -> Result<Vec<u8>> {
80        let mut sig = vec![0u8; self.key.public_modulus_len()];
81        self.key
82            .sign(&RSA_PKCS1_SHA256, &SystemRandom::new(), message, &mut sig)
83            .map_err(|_| ForgeError::Config("RS256 signing failed".into()))?;
84        Ok(sig)
85    }
86}
87
88/// The JWT claims, for tests and logs. `iss` is the App's client id โ€” GitHub
89/// accepts the numeric App id too, but recommends the client id.
90#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
91pub struct AppClaims {
92    /// Issued-at, backdated by [`JWT_BACKDATE_SECS`].
93    pub iat: u64,
94    /// Expiry, [`JWT_LIFETIME_SECS`] from now.
95    pub exp: u64,
96    /// The App's client id.
97    pub iss: String,
98}
99
100impl AppClaims {
101    /// Claims for a JWT minted at `now` (Unix seconds).
102    pub fn at(now: u64, issuer: &str) -> Self {
103        AppClaims {
104            iat: now.saturating_sub(JWT_BACKDATE_SECS),
105            exp: now + JWT_LIFETIME_SECS,
106            iss: issuer.to_string(),
107        }
108    }
109}
110
111/// Mint an App JWT for `issuer` at `now`.
112pub async fn app_jwt_at(signer: &dyn AppKeySigner, issuer: &str, now: u64) -> Result<Secret> {
113    let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"RS256","typ":"JWT"}"#);
114    let claims = serde_json::to_vec(&AppClaims::at(now, issuer))
115        .map_err(|e| ForgeError::Protocol(e.to_string()))?;
116    let signing_input = format!("{header}.{}", URL_SAFE_NO_PAD.encode(claims));
117    let signature = signer.sign_rs256(signing_input.as_bytes()).await?;
118    Ok(Secret::new(format!(
119        "{signing_input}.{}",
120        URL_SAFE_NO_PAD.encode(signature)
121    )))
122}
123
124/// Mint an App JWT now.
125pub async fn app_jwt(signer: &dyn AppKeySigner, issuer: &str) -> Result<Secret> {
126    let now = SystemTime::now()
127        .duration_since(UNIX_EPOCH)
128        .map_err(|_| ForgeError::Config("system clock is before 1970".into()))?
129        .as_secs();
130    app_jwt_at(signer, issuer, now).await
131}
132
133/// Decode one PEM block into its label and DER bytes (zeroized on drop).
134fn decode_pem(pem: &str) -> Result<(String, Zeroizing<Vec<u8>>)> {
135    let bad = |m: &str| ForgeError::Config(format!("App private key: {m}"));
136    let pem = pem.trim();
137    let first = pem.lines().next().unwrap_or_default().trim();
138    let label = first
139        .strip_prefix("-----BEGIN ")
140        .and_then(|l| l.strip_suffix("-----"))
141        .ok_or_else(|| bad("not a PEM block"))?
142        .to_string();
143    if pem.contains("ENCRYPTED") || pem.contains("Proc-Type:") {
144        return Err(bad(
145            "encrypted keys are not supported; supply the key GitHub issued",
146        ));
147    }
148    let end = format!("-----END {label}-----");
149    let body: Zeroizing<String> = Zeroizing::new(
150        pem.lines()
151            .skip(1)
152            .take_while(|l| l.trim() != end)
153            .map(str::trim)
154            .collect(),
155    );
156    if !pem.lines().any(|l| l.trim() == end) {
157        return Err(bad("missing the END line"));
158    }
159    let der = Zeroizing::new(
160        STANDARD
161            .decode(body.as_bytes())
162            .map_err(|_| bad("the PEM body is not valid base64"))?,
163    );
164    Ok((label, der))
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn claims_backdate_iat_and_stay_under_ten_minutes() {
173        let c = AppClaims::at(1_000_000, "Iv1.abc");
174        assert_eq!(c.iat, 1_000_000 - 60);
175        assert_eq!(c.exp, 1_000_000 + 540);
176        assert!(c.exp - 1_000_000 <= 600);
177        assert_eq!(c.iss, "Iv1.abc");
178    }
179
180    #[test]
181    fn pem_errors_are_specific() {
182        let e = InProcessKey::from_pem("not a key").unwrap_err();
183        assert!(e.to_string().contains("not a PEM block"), "{e}");
184        let e =
185            InProcessKey::from_pem("-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----")
186                .unwrap_err();
187        assert!(e.to_string().contains("CERTIFICATE"), "{e}");
188        let e = InProcessKey::from_pem(
189            "-----BEGIN ENCRYPTED PRIVATE KEY-----\nAAAA\n-----END ENCRYPTED PRIVATE KEY-----",
190        )
191        .unwrap_err();
192        assert!(e.to_string().contains("encrypted"), "{e}");
193        let e = InProcessKey::from_pem("-----BEGIN RSA PRIVATE KEY-----\nAAAA\n").unwrap_err();
194        assert!(e.to_string().contains("END"), "{e}");
195    }
196}