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 branch | 1 | //! 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 | ||
| 21 | use base64::Engine; | |
| 22 | use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; | |
| 23 | use g1t_contracts::actions::RuntimeAuthArgs; | |
| 24 | use g1t_contracts::{FailureCode, Outcome}; | |
| 25 | use g1t_kit::js; | |
| 26 | use serde_json::{Value, json}; | |
| 27 | use sha2::{Digest, Sha256}; | |
| 28 | use worker::js_sys::{self, Uint8Array}; | |
| 29 | use worker::{Env, Error, Request, Response, Result}; | |
| 30 | ||
| 31 | use crate::operations::Services; | |
| 32 | ||
| 33 | /// How long a token is good for. | |
| 34 | const LIFETIME_SECONDS: u64 = 5 * 60; | |
| 35 | pub const KEY_SECRET: &str = "ACTIONS_OIDC_KEY"; | |
| 36 | pub const PREVIOUS_KEY_SECRET: &str = "ACTIONS_OIDC_KEY_PREVIOUS"; | |
| 37 | ||
| 38 | /// The issuer, under the API's address. | |
| 39 | pub fn issuer(api: &str) -> String { | |
| 40 | format!("{api}/actions/oidc") | |
| 41 | } | |
| 42 | ||
| 43 | /// The OpenID Provider configuration, as relying parties fetch it. | |
| 44 | pub 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. | |
| 65 | fn 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. | |
| 85 | fn 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. | |
| 95 | pub 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. | |
| 116 | fn 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. | |
| 127 | fn 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. | |
| 142 | pub struct SigningKey { | |
| 143 | pub pkcs8: Vec<u8>, | |
| 144 | pub jwk: Value, | |
| 145 | } | |
| 146 | ||
| 147 | impl 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. | |
| 168 | pub 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. | |
| 177 | pub 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. | |
| 185 | pub fn configured(env: &Env) -> bool { | |
| 186 | matches!(keys(env).0, Some(Ok(_))) | |
| 187 | } | |
| 188 | ||
| 189 | pub 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. | |
| 197 | pub 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. | |
| 208 | pub 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. | |
| 214 | async 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 | ||
| 225 | fn 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. | |
| 230 | pub 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(¤t), 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(¤t.kid(), &claims); | |
| 279 | let signature = sign_rs256(¤t.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)] | |
| 288 | mod 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.