Skip to content
397 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.

Merge main into the run-protection branch1//! OIDC tokens for workflow jobs: g1t as an OpenID Connect issuer, so a
2//! job with `permissions: id-token: write` can trade a short-lived token
3//! for a cloud provider's credentials instead of keeping a long-lived key
4//! in a secret.
5//!
6//! - The issuer is `{API}/actions/oidc` (`https://api.g1t.sh/actions/oidc`
7//! hosted): its discovery document at
8//! `/.well-known/openid-configuration` under it, and its keys at
9//! `/.well-known/jwks`. No host of its own: the API's.
10//! - A job asks `GET {issuer}/token?api-version=2.0&audience=…` with its
11//! runtime token (`ACTIONS_ID_TOKEN_REQUEST_URL` and `…_TOKEN`, as the
12//! toolkit's `core.getIDToken` reads them) and gets `{ "value": jwt }`.
13//! - Tokens are RS256, good for five minutes, with GitHub's claims (the
14//! actions service decides them and whether the job may have one).
15//! - The signing key is the Worker secret `ACTIONS_OIDC_KEY`, an RSA
16//! private key in PEM (PKCS#8 or PKCS#1). `ACTIONS_OIDC_KEY_PREVIOUS`,
17//! while it is set, is published too, so tokens it signed still verify
18//! while the new key takes over. Each key's `kid` is its RFC 7638
19//! thumbprint. docs/DEPLOYING.md says how to make and rotate them.
20
21use base64::Engine;
22use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
23use g1t_contracts::actions::RuntimeAuthArgs;
24use g1t_contracts::{FailureCode, Outcome};
25use g1t_kit::js;
26use serde_json::{Value, json};
27use sha2::{Digest, Sha256};
28use worker::js_sys::{self, Uint8Array};
29use worker::{Env, Error, Request, Response, Result};
30
31use crate::operations::Services;
32
33/// How long a token is good for.
34const LIFETIME_SECONDS: u64 = 5 * 60;
35pub const KEY_SECRET: &str = "ACTIONS_OIDC_KEY";
36pub const PREVIOUS_KEY_SECRET: &str = "ACTIONS_OIDC_KEY_PREVIOUS";
37
38/// The issuer, under the API's address.
39pub fn issuer(api: &str) -> String {
40 format!("{api}/actions/oidc")
41}
42
43/// The OpenID Provider configuration, as relying parties fetch it.
44pub fn discovery(api: &str) -> Value {
45 let issuer = issuer(api);
46 json!({
47 "issuer": issuer,
48 "jwks_uri": format!("{issuer}/.well-known/jwks"),
49 "subject_types_supported": ["public", "pairwise"],
50 "response_types_supported": ["id_token"],
51 "claims_supported": [
52 "sub", "aud", "exp", "iat", "iss", "jti", "nbf", "ref", "sha", "repository", "repository_id", "repository_owner",
53 "repository_owner_id", "repository_visibility", "run_id", "run_number", "run_attempt", "actor", "actor_id", "workflow",
54 "workflow_ref", "workflow_sha", "job_workflow_ref", "job_workflow_sha", "head_ref", "base_ref", "event_name", "ref_type",
55 "ref_protected", "environment", "runner_environment"
56 ],
57 "id_token_signing_alg_values_supported": ["RS256"],
58 "scopes_supported": ["openid"],
59 })
60}
61
62// ── Keys ────────────────────────────────────────────────────────────────────
63
64/// A DER element: its tag, and its contents; and what follows it.
65fn der(input: &[u8]) -> Option<(u8, &[u8], &[u8])> {
66 let (&tag, rest) = input.split_first()?;
67 let (&first, rest) = rest.split_first()?;
68 let (length, rest) = if first < 0x80 {
69 (first as usize, rest)
70 } else {
71 let count = (first & 0x7f) as usize;
72 if count == 0 || count > 4 || rest.len() < count {
73 return None;
74 }
75 let length = rest[..count].iter().fold(0usize, |n, b| (n << 8) | *b as usize);
76 (length, &rest[count..])
77 };
78 if rest.len() < length {
79 return None;
80 }
81 Some((tag, &rest[..length], &rest[length..]))
82}
83
84/// An INTEGER's magnitude, without the sign byte DER may put in front.
85fn unsigned(bytes: &[u8]) -> &[u8] {
86 let mut bytes = bytes;
87 while bytes.len() > 1 && bytes[0] == 0 {
88 bytes = &bytes[1..];
89 }
90 bytes
91}
92
93/// The modulus and public exponent of an RSA private key: PKCS#1
94/// `RSAPrivateKey`, or PKCS#8 `PrivateKeyInfo` holding one.
95pub fn public_numbers(key: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
96 let (0x30, body, _) = der(key)? else { return None };
97 let (0x02, _version, rest) = der(body)? else { return None };
98 let rsa = match der(rest)? {
99 // PKCS#8: the algorithm, then the key in an OCTET STRING.
100 (0x30, _algorithm, after) => {
101 let (0x04, inner, _) = der(after)? else { return None };
102 let (0x30, rsa, _) = der(inner)? else { return None };
103 let (0x02, _version, rsa) = der(rsa)? else { return None };
104 rsa
105 }
106 // PKCS#1: the modulus is next.
107 (0x02, _, _) => rest,
108 _ => return None,
109 };
110 let (0x02, n, rsa) = der(rsa)? else { return None };
111 let (0x02, e, _) = der(rsa)? else { return None };
112 Some((unsigned(n).to_vec(), unsigned(e).to_vec()))
113}
114
115/// A DER length.
116fn der_length(length: usize) -> Vec<u8> {
117 if length < 0x80 {
118 return vec![length as u8];
119 }
120 let bytes: Vec<u8> = length.to_be_bytes().into_iter().skip_while(|byte| *byte == 0).collect();
121 let mut out = vec![0x80 | bytes.len() as u8];
122 out.extend(bytes);
123 out
124}
125
126/// Wraps a PKCS#1 key in PKCS#8, which is all WebCrypto imports.
127fn pkcs1_to_pkcs8(pkcs1: &[u8]) -> Vec<u8> {
128 const RSA_ALGORITHM: [u8; 15] = [0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00];
129 let mut octets = vec![0x04];
130 octets.extend(der_length(pkcs1.len()));
131 octets.extend_from_slice(pkcs1);
132 let mut body = vec![0x02, 0x01, 0x00];
133 body.extend_from_slice(&RSA_ALGORITHM);
134 body.extend(octets);
135 let mut out = vec![0x30];
136 out.extend(der_length(body.len()));
137 out.extend(body);
138 out
139}
140
141/// A signing key: its PKCS#8 DER, and its public half as a JWK.
142pub struct SigningKey {
143 pub pkcs8: Vec<u8>,
144 pub jwk: Value,
145}
146
147impl SigningKey {
148 /// From PEM, either form; line breaks pasted as `\n` are read too.
149 pub fn from_pem(pem: &str) -> std::result::Result<SigningKey, String> {
150 let pem = pem.replace("\\n", "\n");
151 let pkcs1 = pem.contains("BEGIN RSA PRIVATE KEY");
152 if !pkcs1 && !pem.contains("BEGIN PRIVATE KEY") {
153 return Err(format!("{KEY_SECRET} is not a PEM RSA private key."));
154 }
155 let body: String = pem.lines().filter(|l| !l.starts_with("-----")).flat_map(str::chars).filter(|c| !c.is_whitespace()).collect();
156 let der = STANDARD.decode(body).map_err(|_| format!("{KEY_SECRET} is not valid base64."))?;
157 let (n, e) = public_numbers(&der).ok_or_else(|| format!("{KEY_SECRET} is not an RSA key."))?;
158 let pkcs8 = if pkcs1 { pkcs1_to_pkcs8(&der) } else { der };
159 Ok(SigningKey { pkcs8, jwk: jwk(&n, &e) })
160 }
161
162 pub fn kid(&self) -> String {
163 self.jwk["kid"].as_str().unwrap_or_default().to_owned()
164 }
165}
166
167/// The public JWK of a key, its `kid` the RFC 7638 thumbprint.
168pub fn jwk(n: &[u8], e: &[u8]) -> Value {
169 let (n, e) = (URL_SAFE_NO_PAD.encode(n), URL_SAFE_NO_PAD.encode(e));
170 // RFC 7638: the required members, in lexicographic order, no spaces.
171 let canonical = format!(r#"{{"e":"{e}","kty":"RSA","n":"{n}"}}"#);
172 let kid = URL_SAFE_NO_PAD.encode(Sha256::digest(canonical.as_bytes()));
173 json!({ "kty": "RSA", "alg": "RS256", "use": "sig", "kid": kid, "n": n, "e": e })
174}
175
176/// The keys configured: the current one first.
177pub fn keys(env: &Env) -> (Option<std::result::Result<SigningKey, String>>, Option<SigningKey>) {
178 let read = |name: &str| env.secret(name).ok().map(|s| s.to_string()).filter(|s| !s.trim().is_empty());
179 let current = read(KEY_SECRET).map(|pem| SigningKey::from_pem(&pem));
180 let previous = read(PREVIOUS_KEY_SECRET).and_then(|pem| SigningKey::from_pem(&pem).ok());
181 (current, previous)
182}
183
184/// Whether this installation can issue OIDC tokens.
185pub fn configured(env: &Env) -> bool {
186 matches!(keys(env).0, Some(Ok(_)))
187}
188
189pub fn jwks(current: Option<&SigningKey>, previous: Option<&SigningKey>) -> Value {
190 json!({ "keys": current.into_iter().chain(previous).map(|k| k.jwk.clone()).collect::<Vec<_>>() })
191}
192
193// ── Tokens ──────────────────────────────────────────────────────────────────
194
195/// The token's claims: what the actions service said about the job, and
196/// who issued it, for whom and when.
197pub fn full_claims(mut claims: Value, issuer: &str, audience: &str, jti: &str, now: u64) -> Value {
198 claims["iss"] = json!(issuer);
199 claims["aud"] = json!(audience);
200 claims["jti"] = json!(jti);
201 claims["iat"] = json!(now);
202 claims["nbf"] = json!(now.saturating_sub(60));
203 claims["exp"] = json!(now + LIFETIME_SECONDS);
204 claims
205}
206
207/// The header and claims, base64url-encoded and joined: what is signed.
208pub fn signing_input(kid: &str, claims: &Value) -> String {
209 let header = json!({ "typ": "JWT", "alg": "RS256", "kid": kid, "x5t": kid });
210 format!("{}.{}", URL_SAFE_NO_PAD.encode(header.to_string()), URL_SAFE_NO_PAD.encode(claims.to_string()))
211}
212
213/// RSASSA-PKCS1-v1_5 with SHA-256, by WebCrypto.
214async fn sign_rs256(pkcs8: &[u8], data: &[u8]) -> Result<Vec<u8>> {
215 let subtle = js::get(&js::get(&js_sys::global(), "crypto"), "subtle");
216 let algorithm = js::to_js(&json!({ "name": "RSASSA-PKCS1-v1_5", "hash": "SHA-256" }))?;
217 let usages = js::to_js(&json!(["sign"]))?;
218 let key = js::call(&subtle, "importKey", &["pkcs8".into(), Uint8Array::from(pkcs8).into(), algorithm.clone(), false.into(), usages])
219 .await
220 .map_err(|thrown| Error::RustError(format!("{KEY_SECRET} could not be used: {thrown}")))?;
221 let signature = js::call(&subtle, "sign", &[algorithm, key, Uint8Array::from(data).into()]).await?;
222 Ok(Uint8Array::new(&signature).to_vec())
223}
224
225fn error(status: u16, message: &str) -> Result<Response> {
226 Ok(crate::reply(&json!({ "error": { "message": message } }))?.with_status(status))
227}
228
229/// `/actions/oidc/…`: discovery, keys, and a job's token.
230pub async fn handle(request: &Request, env: &Env, services: &Services, path: &str) -> Result<Response> {
231 let api = services.addresses.api.clone();
232 let (current, previous) = keys(env);
233 let current = match current {
234 Some(Ok(key)) => key,
235 Some(Err(problem)) => {
236 worker::console_error!("oidc: {problem}");
237 return error(503, "OIDC tokens are not set up on this installation.");
238 }
239 None => return error(404, "OIDC tokens are not set up on this installation."),
240 };
241 let cached = |value: &Value| -> Result<Response> {
242 let mut response = Response::from_json(value)?;
243 response.headers_mut().set("cache-control", "public, max-age=300")?;
244 Ok(response)
245 };
246 match path {
247 "/actions/oidc/.well-known/openid-configuration" => cached(&discovery(&api)),
248 "/actions/oidc/.well-known/jwks" => cached(&jwks(Some(&current), previous.as_ref())),
249 "/actions/oidc/token" => {
250 let token = crate::toolkit::bearer(request);
251 let Some(job) = crate::toolkit::runtime_job(&token) else {
252 return error(401, "Send the job's ACTIONS_ID_TOKEN_REQUEST_TOKEN as a bearer token.");
253 };
254 let claims: Outcome<Value> = g1t_kit::call(&services.actions, "oidc_claims", &RuntimeAuthArgs { job, token }).await?;
255 let claims = match claims {
256 Outcome::Ok(claims) => claims,
257 Outcome::Fail(refused) => {
258 let status = match refused.code {
259 FailureCode::Unauthenticated => 401,
260 FailureCode::Forbidden => 403,
261 _ => 404,
262 };
263 return error(status, &refused.message);
264 }
265 };
266 let url = request.url()?;
267 let owner = claims["repository_owner"].as_str().unwrap_or_default().to_owned();
268 let audience = url
269 .query_pairs()
270 .find(|(k, _)| k == "audience")
271 .map(|(_, v)| v.into_owned())
272 .filter(|a| !a.trim().is_empty())
273 // GitHub's default: the owner's address.
274 .unwrap_or_else(|| format!("{}/{owner}", services.addresses.site));
275 let now = g1t_kit::now_ms() / 1000;
276 let jti = g1t_contracts::new_id("oidc", g1t_kit::now_ms());
277 let claims = full_claims(claims, &issuer(&api), &audience, &jti, now);
278 let input = signing_input(&current.kid(), &claims);
279 let signature = sign_rs256(&current.pkcs8, input.as_bytes()).await?;
280 let value = format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature));
281 Response::from_json(&json!({ "count": value.len(), "value": value }))
282 }
283 _ => error(404, "No such endpoint."),
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use std::process::Command;
291
292 #[test]
293 fn discovery_names_the_issuer_and_its_keys() {
294 let doc = discovery("https://api.g1t.sh");
295 assert_eq!(doc["issuer"], "https://api.g1t.sh/actions/oidc");
296 assert_eq!(doc["jwks_uri"], "https://api.g1t.sh/actions/oidc/.well-known/jwks");
297 assert_eq!(doc["id_token_signing_alg_values_supported"], json!(["RS256"]));
298 assert!(doc["claims_supported"].as_array().unwrap().contains(&json!("job_workflow_ref")));
299 }
300
301 #[test]
302 fn a_thumbprint_is_rfc_7638s() {
303 // RFC 7638, section 3.1: the example key and its thumbprint.
304 let n = URL_SAFE_NO_PAD
305 .decode("0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw")
306 .unwrap();
307 let key = jwk(&n, &[1, 0, 1]);
308 assert_eq!(key["e"], "AQAB");
309 assert_eq!(key["kid"], "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs");
310 }
311
312 #[test]
313 fn claims_get_who_issued_them_and_a_short_life() {
314 let claims = full_claims(json!({ "sub": "repo:acme/web:ref:refs/heads/main" }), "https://api.g1t.sh/actions/oidc", "sts.amazonaws.com", "oidc_1", 1_700_000_000);
315 assert_eq!(claims["iss"], "https://api.g1t.sh/actions/oidc");
316 assert_eq!(claims["aud"], "sts.amazonaws.com");
317 assert_eq!(claims["exp"].as_u64().unwrap() - claims["iat"].as_u64().unwrap(), LIFETIME_SECONDS);
318 assert!(claims["nbf"].as_u64().unwrap() <= claims["iat"].as_u64().unwrap());
319 let input = signing_input("kid1", &claims);
320 let header: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(input.split('.').next().unwrap()).unwrap()).unwrap();
321 assert_eq!((header["alg"].as_str(), header["kid"].as_str()), (Some("RS256"), Some("kid1")));
322 }
323
324 #[test]
325 fn a_key_that_is_not_one_is_refused() {
326 assert!(SigningKey::from_pem("hello").is_err());
327 let pem = format!("{}\nAAAA\n{}", concat!("-----BEGIN ", "PRIVATE KEY-----"), concat!("-----END ", "PRIVATE KEY-----"));
328 assert!(SigningKey::from_pem(&pem).is_err());
329 }
330
331 fn openssl(args: &[&str]) -> Option<std::process::Output> {
332 Command::new("openssl").args(args).output().ok().filter(|o| o.status.success())
333 }
334
335 /// With openssl on the machine: a key made here, read in both PEM
336 /// forms, gives the modulus openssl gives, and a token signed with it
337 /// (by openssl, as WebCrypto is not here) verifies against the public
338 /// key built from the JWKS.
339 #[test]
340 fn a_real_key_signs_tokens_its_jwks_verifies() {
341 let dir = std::env::temp_dir().join(format!("g1t-oidc-test-{}", std::process::id()));
342 let _ = std::fs::create_dir_all(&dir);
343 let path = |name: &str| dir.join(name).display().to_string();
344 if openssl(&["genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", "-out", &path("key.pem")]).is_none() {
345 eprintln!("openssl is not here; skipped");
346 return;
347 }
348 let pem = std::fs::read_to_string(path("key.pem")).unwrap();
349 let key = SigningKey::from_pem(&pem).unwrap();
350 // The modulus is openssl's.
351 let modulus = openssl(&["rsa", "-in", &path("key.pem"), "-noout", "-modulus"]).unwrap();
352 let modulus = String::from_utf8_lossy(&modulus.stdout).trim().trim_start_matches("Modulus=").to_lowercase();
353 let n = URL_SAFE_NO_PAD.decode(key.jwk["n"].as_str().unwrap()).unwrap();
354 assert_eq!(n.iter().map(|b| format!("{b:02x}")).collect::<String>(), modulus);
355 assert_eq!(key.jwk["e"], "AQAB");
356 // The traditional form reads to the same key.
357 if openssl(&["rsa", "-in", &path("key.pem"), "-traditional", "-out", &path("key1.pem")]).is_some() {
358 let pkcs1 = std::fs::read_to_string(path("key1.pem")).unwrap();
359 if pkcs1.contains("BEGIN RSA PRIVATE KEY") {
360 let again = SigningKey::from_pem(&pkcs1).unwrap();
361 assert_eq!(again.kid(), key.kid());
362 assert_eq!(again.pkcs8, key.pkcs8, "PKCS#1 is wrapped as openssl writes PKCS#8");
363 }
364 }
365 // Sign the token's input with openssl, verify with the JWKS's key.
366 let claims = full_claims(json!({ "sub": "repo:acme/web:environment:prod" }), "https://api.g1t.sh/actions/oidc", "sts.amazonaws.com", "j", 1_700_000_000);
367 let input = signing_input(&key.kid(), &claims);
368 std::fs::write(path("input"), &input).unwrap();
369 openssl(&["dgst", "-sha256", "-sign", &path("key.pem"), "-out", &path("sig"), &path("input")]).unwrap();
370 // The public key from n and e alone: RSAPublicKey DER.
371 let integer = |bytes: &[u8]| {
372 let mut value = bytes.to_vec();
373 if value[0] & 0x80 != 0 {
374 value.insert(0, 0);
375 }
376 let mut out = vec![0x02];
377 out.extend(der_length(value.len()));
378 out.extend(value);
379 out
380 };
381 let mut body = integer(&n);
382 body.extend(integer(&URL_SAFE_NO_PAD.decode(key.jwk["e"].as_str().unwrap()).unwrap()));
383 let mut public = vec![0x30];
384 public.extend(der_length(body.len()));
385 public.extend(body);
386 std::fs::write(path("public.der"), &public).unwrap();
387 let checked = openssl(&["rsa", "-RSAPublicKey_in", "-inform", "DER", "-in", &path("public.der"), "-pubout", "-out", &path("public.pem")]);
388 assert!(checked.is_some(), "openssl reads the public key built from the JWKS");
389 let verified = openssl(&["dgst", "-sha256", "-verify", &path("public.pem"), "-signature", &path("sig"), &path("input")]);
390 assert!(verified.is_some(), "the JWKS key verifies the token's signature");
391 // And not a token whose claims were changed.
392 std::fs::write(path("input"), format!("{input}A")).unwrap();
393 let forged = openssl(&["dgst", "-sha256", "-verify", &path("public.pem"), "-signature", &path("sig"), &path("input")]);
394 assert!(forged.is_none());
395 let _ = std::fs::remove_dir_all(&dir);
396 }
397}

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