flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/integrations/src/github_jwt.rs

175 lines7,684 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1//! Signing as g1t's GitHub App: a short-lived RS256 JSON Web Token, made
2//! with WebCrypto, which GitHub trades for an installation access token.
3//!
4//! GitHub hands out app private keys as PKCS#1 PEM (`BEGIN RSA PRIVATE
5//! KEY`); WebCrypto imports only PKCS#8. A PKCS#1 key is wrapped in the
6//! PKCS#8 structure here, so the downloaded file can be stored as it is.
7//! A PKCS#8 key (`BEGIN PRIVATE KEY`) is used unchanged.
8
9use base64::Engine;
10use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
11use g1t_kit::js;
12use serde_json::json;
13use worker::js_sys::{self, Uint8Array};
14use worker::{Error, Result};
15
16/// `AlgorithmIdentifier` for rsaEncryption (1.2.840.113549.1.1.1) with
17/// NULL parameters, DER-encoded.
18const RSA_ALGORITHM: [u8; 15] = [
19 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00,
20];
21
22/// A DER length.
23fn der_length(length: usize) -> Vec<u8> {
24 if length < 0x80 {
25 return vec![length as u8];
26 }
27 let bytes: Vec<u8> = length.to_be_bytes().into_iter().skip_while(|byte| *byte == 0).collect();
28 let mut out = vec![0x80 | bytes.len() as u8];
29 out.extend(bytes);
30 out
31}
32
33/// Wraps a PKCS#1 `RSAPrivateKey` in a PKCS#8 `PrivateKeyInfo`:
34/// `SEQUENCE { INTEGER 0, rsaEncryption, OCTET STRING { pkcs1 } }`.
35pub fn pkcs1_to_pkcs8(pkcs1: &[u8]) -> Vec<u8> {
36 let mut octets = vec![0x04];
37 octets.extend(der_length(pkcs1.len()));
38 octets.extend_from_slice(pkcs1);
39 let mut body = vec![0x02, 0x01, 0x00];
40 body.extend_from_slice(&RSA_ALGORITHM);
41 body.extend(octets);
42 let mut out = vec![0x30];
43 out.extend(der_length(body.len()));
44 out.extend(body);
45 out
46}
47
48/// The PKCS#8 DER of a PEM private key, in either form. Tolerates the
49/// line breaks of a key pasted into a secret as `\n`.
50pub fn private_key_der(pem: &str) -> std::result::Result<Vec<u8>, String> {
51 let pem = pem.replace("\\n", "\n");
52 let pkcs1 = pem.contains("BEGIN RSA PRIVATE KEY");
53 if !pkcs1 && !pem.contains("BEGIN PRIVATE KEY") {
54 return Err("GITHUB_APP_PRIVATE_KEY is not a PEM private key.".to_owned());
55 }
56 let body: String = pem
57 .lines()
58 .filter(|line| !line.starts_with("-----"))
59 .flat_map(|line| line.chars())
60 .filter(|character| !character.is_whitespace())
61 .collect();
62 let der = STANDARD
63 .decode(body)
64 .map_err(|_| "GITHUB_APP_PRIVATE_KEY is not valid base64.".to_owned())?;
65 Ok(if pkcs1 { pkcs1_to_pkcs8(&der) } else { der })
66}
67
68/// The header and claims of an app JWT, base64url-encoded and joined:
69/// what is signed. Issued a minute in the past against clock drift, and
70/// good for nine minutes, under GitHub's ten.
71pub fn signing_input(issuer: &str, now_seconds: u64) -> String {
72 let header = json!({ "alg": "RS256", "typ": "JWT" });
73 let claims = json!({
74 "iat": now_seconds.saturating_sub(60),
75 "exp": now_seconds + 9 * 60,
76 "iss": issuer,
77 });
78 format!(
79 "{}.{}",
80 URL_SAFE_NO_PAD.encode(header.to_string()),
81 URL_SAFE_NO_PAD.encode(claims.to_string())
82 )
83}
84
85/// RSASSA-PKCS1-v1_5 with SHA-256, by WebCrypto.
86async fn sign_rs256(pkcs8: &[u8], data: &[u8]) -> Result<Vec<u8>> {
87 let subtle = js::get(&js::get(&js_sys::global(), "crypto"), "subtle");
88 let algorithm = js::to_js(&json!({ "name": "RSASSA-PKCS1-v1_5", "hash": "SHA-256" }))?;
89 let usages = js::to_js(&json!(["sign"]))?;
90 let key = js::call(
91 &subtle,
92 "importKey",
93 &["pkcs8".into(), Uint8Array::from(pkcs8).into(), algorithm.clone(), false.into(), usages],
94 )
95 .await
96 .map_err(|thrown| Error::RustError(format!("GITHUB_APP_PRIVATE_KEY could not be used: {thrown}")))?;
97 let signature = js::call(&subtle, "sign", &[algorithm, key, Uint8Array::from(data).into()]).await?;
98 Ok(Uint8Array::new(&signature).to_vec())
99}
100
101/// A JWT that authenticates as the app.
102pub async fn app_jwt(issuer: &str, private_key_pem: &str, now_seconds: u64) -> Result<String> {
103 let der = private_key_der(private_key_pem).map_err(Error::RustError)?;
104 let input = signing_input(issuer, now_seconds);
105 let signature = sign_rs256(&der, input.as_bytes()).await?;
106 Ok(format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature)))
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112
113 #[test]
114 fn lengths_are_der() {
115 assert_eq!(der_length(5), vec![5]);
116 assert_eq!(der_length(127), vec![127]);
117 assert_eq!(der_length(128), vec![0x81, 0x80]);
118 assert_eq!(der_length(608), vec![0x82, 0x02, 0x60]);
119 assert_eq!(der_length(70_000), vec![0x83, 0x01, 0x11, 0x70]);
120 }
121
122 #[test]
123 fn a_pkcs1_key_is_wrapped_as_openssl_does() {
124 // `openssl pkcs8 -topk8 -nocrypt` of a 1024-bit key, whose PKCS#1
125 // form is 608 bytes, begins with exactly these 26 bytes.
126 let pkcs1 = vec![0xab; 608];
127 let wrapped = pkcs1_to_pkcs8(&pkcs1);
128 let expected = hex::decode("30820276020100300d06092a864886f70d0101010500048202 60".replace(' ', "")).unwrap();
129 assert_eq!(&wrapped[..expected.len()], expected.as_slice());
130 assert_eq!(&wrapped[expected.len()..], pkcs1.as_slice());
131 assert_eq!(wrapped.len(), 634);
132 // A 2048-bit key, as GitHub issues: 1190-ish bytes, two-byte lengths.
133 let wrapped = pkcs1_to_pkcs8(&vec![1; 1191]);
134 assert_eq!(&wrapped[..4], &[0x30, 0x82, 0x04, 0xbd]);
135 assert_eq!(&wrapped[22..26], &[0x04, 0x82, 0x04, 0xa7]);
136 }
137
138 /// With G1T_TEST_PKCS1_PEM and G1T_TEST_PKCS8_DER naming a key made by
139 /// `openssl genrsa -traditional` and its `openssl pkcs8 -topk8 -nocrypt
140 /// -outform DER`, the conversion must match byte for byte.
141 #[test]
142 fn a_real_key_matches_openssl_when_given_one() {
143 let (Ok(pem), Ok(der)) = (std::env::var("G1T_TEST_PKCS1_PEM"), std::env::var("G1T_TEST_PKCS8_DER")) else {
144 return;
145 };
146 let pem = std::fs::read_to_string(pem).unwrap();
147 let expected = std::fs::read(der).unwrap();
148 assert_eq!(private_key_der(&pem).unwrap(), expected);
149 }
150
151 #[test]
152 fn either_pem_form_is_read() {
153 let body = STANDARD.encode([0x30, 0x03, 0x02, 0x01, 0x00]);
154 let pkcs1 = format!("{}\n{body}\n{}\n", concat!("-----BEGIN RSA ", "PRIVATE KEY-----"), concat!("-----END RSA ", "PRIVATE KEY-----"));
155 assert_eq!(private_key_der(&pkcs1).unwrap(), pkcs1_to_pkcs8(&[0x30, 0x03, 0x02, 0x01, 0x00]));
156 // Pasted into a secret with escaped line breaks.
157 assert_eq!(private_key_der(&pkcs1.replace('\n', "\\n")).unwrap(), private_key_der(&pkcs1).unwrap());
158 let pkcs8 = format!("{}\r\n{body}\r\n{}", concat!("-----BEGIN ", "PRIVATE KEY-----"), concat!("-----END ", "PRIVATE KEY-----"));
159 assert_eq!(private_key_der(&pkcs8).unwrap(), vec![0x30, 0x03, 0x02, 0x01, 0x00]);
160 assert!(private_key_der("not a key").is_err());
161 }
162
163 #[test]
164 fn the_claims_are_backdated_and_short() {
165 let input = signing_input("Iv23liZS94alfjIUn1eW", 1_700_000_000);
166 let (header, claims) = input.split_once('.').unwrap();
167 let header: serde_json::Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(header).unwrap()).unwrap();
168 let claims: serde_json::Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(claims).unwrap()).unwrap();
169 assert_eq!(header["alg"], "RS256");
170 assert_eq!(claims["iss"], "Iv23liZS94alfjIUn1eW");
171 assert_eq!(claims["iat"], 1_700_000_000 - 60);
172 let lifetime = claims["exp"].as_u64().unwrap() - claims["iat"].as_u64().unwrap();
173 assert!(lifetime <= 600, "GitHub refuses a JWT good for more than ten minutes");
174 }
175}