Skip to content
175 linesCodeBlameRaw

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.

The runner base keeps its old comment, so the pushed base image still matches its folder1//! 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}

This file's history is long; its oldest lines are credited to the oldest commit read.