1use base64::Engine;
2use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3use chrono::{DateTime, Utc};
4use hmac::{Hmac, Mac};
5use serde::Serialize;
6use serde_json::{Map, Value};
7use sha2::Sha256;
8use std::collections::BTreeMap;
9
10use crate::model::{
11 Check, Compatibility, OidcReport, PermissionKey, PermissionLevel, PermissionSet,
12};
13
14pub const STUB_LOCAL_SECRET: &[u8] = b"gha-github-service-proof:stub-local:v1";
18
19pub const STUB_LOCAL_ISSUER: &str = "stub-local://gha-github-service-proof";
22
23pub const STUB_LOCAL_WARNING: &str = "OIDC token is deterministic and signed by gha-github-service-proof with a documented local secret. It is not GitHub-issued and must not be trusted by AWS/GCP/Azure or any other cloud provider as a federated identity. Use for offline CI assertions only.";
26
27pub const STUB_LOCAL_SIGNING_MODE: &str = "stub-local";
28
29pub const DEFAULT_TTL_SECONDS: i64 = 300;
30
31#[derive(Debug, Clone)]
32pub struct IssueOptions {
33 pub audience: String,
34 pub repository: String,
35 pub git_ref: String,
36 pub sha: String,
37 pub workflow: String,
38 pub job: String,
39 pub run_id: String,
40 pub job_workflow_ref: Option<String>,
41 pub permissions: Option<PermissionSet>,
42 pub now: Option<DateTime<Utc>>,
43 pub ttl_seconds: Option<i64>,
44 pub extra_claims: BTreeMap<String, Value>,
45}
46
47pub fn issue(options: IssueOptions) -> OidcReport {
48 let mut checks = Vec::new();
49
50 if options.audience.trim().is_empty() {
51 checks.push(Check::fail(
52 "oidc.audience_missing",
53 "audience must be a non-empty string",
54 ));
55 }
56 if options.repository.trim().is_empty() {
57 checks.push(Check::fail(
58 "oidc.repository_missing",
59 "repository must be in 'owner/repo' form",
60 ));
61 } else if !options.repository.contains('/') {
62 checks.push(Check::warn(
63 "oidc.repository_format",
64 format!(
65 "repository '{}' is not in 'owner/repo' form; ci-forge may treat the sub claim as malformed",
66 options.repository
67 ),
68 ));
69 }
70
71 let id_token_level = options
72 .permissions
73 .as_ref()
74 .map(|set| set.level(PermissionKey::IdToken))
75 .unwrap_or(PermissionLevel::None);
76
77 if !id_token_level.satisfies(PermissionLevel::Write) {
78 match options.permissions.as_ref() {
79 Some(_) => checks.push(Check::fail(
80 "oidc.id_token_not_granted",
81 "OIDC token requires `permissions: id-token: write`; current effective level is below `write`",
82 )),
83 None => checks.push(Check::warn(
84 "oidc.id_token_unverified",
85 "no permissions provided; cannot verify `id-token: write` is granted",
86 )),
87 }
88 } else {
89 checks.push(Check::pass(
90 "oidc.id_token_granted",
91 "`id-token: write` permission is granted",
92 ));
93 }
94
95 let now = options.now.unwrap_or_else(Utc::now);
96 let ttl = options.ttl_seconds.unwrap_or(DEFAULT_TTL_SECONDS).max(1);
97 let iat = now.timestamp();
98 let exp = iat + ttl;
99
100 let job_workflow_ref = options.job_workflow_ref.clone().unwrap_or_else(|| {
101 format!(
102 "{}/.github/workflows/{}",
103 options.repository, options.workflow
104 )
105 });
106
107 let sub = format!("repo:{}:ref:{}", options.repository, options.git_ref);
108
109 let mut claims = BTreeMap::new();
110 claims.insert(
111 "iss".to_owned(),
112 Value::String(STUB_LOCAL_ISSUER.to_owned()),
113 );
114 claims.insert("sub".to_owned(), Value::String(sub.clone()));
115 claims.insert("aud".to_owned(), Value::String(options.audience.clone()));
116 claims.insert("iat".to_owned(), Value::from(iat));
117 claims.insert("exp".to_owned(), Value::from(exp));
118 claims.insert(
119 "repository".to_owned(),
120 Value::String(options.repository.clone()),
121 );
122 if let Some((owner, _)) = options.repository.split_once('/') {
123 claims.insert(
124 "repository_owner".to_owned(),
125 Value::String(owner.to_owned()),
126 );
127 }
128 claims.insert("ref".to_owned(), Value::String(options.git_ref.clone()));
129 claims.insert("sha".to_owned(), Value::String(options.sha.clone()));
130 claims.insert(
131 "workflow".to_owned(),
132 Value::String(options.workflow.clone()),
133 );
134 claims.insert("job".to_owned(), Value::String(options.job.clone()));
135 claims.insert(
136 "job_workflow_ref".to_owned(),
137 Value::String(job_workflow_ref.clone()),
138 );
139 claims.insert("run_id".to_owned(), Value::String(options.run_id.clone()));
140 claims.insert("stub_local".to_owned(), Value::Bool(true));
141 for (key, value) in &options.extra_claims {
142 claims.insert(key.clone(), value.clone());
143 }
144
145 let header = OidcHeader {
146 alg: "HS256",
147 typ: "JWT",
148 kid: "stub-local",
149 };
150 let token = sign_hs256(&header, &claims);
151
152 checks.push(Check::warn(
153 "oidc.stub_local",
154 "OIDC token is stub-local: signed with a documented constant secret and not trusted by GitHub or cloud providers.",
155 ));
156
157 OidcReport {
158 audience: options.audience,
159 repository: options.repository,
160 git_ref: options.git_ref,
161 sha: options.sha,
162 workflow: options.workflow,
163 job: options.job,
164 job_workflow_ref,
165 run_id: options.run_id,
166 iat,
167 exp,
168 claims,
169 token,
170 signing_mode: STUB_LOCAL_SIGNING_MODE.to_owned(),
171 compatibility: Compatibility::Simulated,
172 permissions: options.permissions,
173 checks,
174 warning: STUB_LOCAL_WARNING.to_owned(),
175 }
176}
177
178#[derive(Serialize)]
179struct OidcHeader {
180 alg: &'static str,
181 typ: &'static str,
182 kid: &'static str,
183}
184
185fn sign_hs256(header: &OidcHeader, claims: &BTreeMap<String, Value>) -> String {
186 let header_bytes = serde_json::to_vec(header).expect("header is serializable");
187 let claims_object: Map<String, Value> =
188 claims.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
189 let claims_bytes =
190 serde_json::to_vec(&Value::Object(claims_object)).expect("claims serialize to JSON object");
191
192 let header_b64 = URL_SAFE_NO_PAD.encode(&header_bytes);
193 let claims_b64 = URL_SAFE_NO_PAD.encode(&claims_bytes);
194 let signing_input = format!("{header_b64}.{claims_b64}");
195
196 let mut mac = Hmac::<Sha256>::new_from_slice(STUB_LOCAL_SECRET)
197 .expect("HMAC-SHA256 accepts any key length");
198 mac.update(signing_input.as_bytes());
199 let signature = mac.finalize().into_bytes();
200 let signature_b64 = URL_SAFE_NO_PAD.encode(signature);
201
202 format!("{signing_input}.{signature_b64}")
203}
204
205#[allow(dead_code)]
206pub fn verify_stub_local(token: &str) -> bool {
207 let mut parts = token.split('.');
208 let (Some(header_b64), Some(payload_b64), Some(sig_b64), None) =
209 (parts.next(), parts.next(), parts.next(), parts.next())
210 else {
211 return false;
212 };
213 let signing_input = format!("{header_b64}.{payload_b64}");
214 let Ok(expected) = URL_SAFE_NO_PAD.decode(sig_b64) else {
215 return false;
216 };
217 let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(STUB_LOCAL_SECRET) else {
218 return false;
219 };
220 mac.update(signing_input.as_bytes());
221 mac.verify_slice(&expected).is_ok()
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use crate::model::{CheckStatus, PermissionKey, PermissionLevel};
228
229 fn perms_with_id_token_write() -> PermissionSet {
230 let mut set = PermissionSet::default();
231 set.entries.insert(
232 PermissionKey::IdToken.as_str().to_owned(),
233 PermissionLevel::Write,
234 );
235 set
236 }
237
238 #[test]
239 fn issue_with_id_token_write_passes() {
240 let report = issue(IssueOptions {
241 audience: "https://example.com".to_owned(),
242 repository: "wildmason/mortar".to_owned(),
243 git_ref: "refs/heads/main".to_owned(),
244 sha: "deadbeef".to_owned(),
245 workflow: "release.yml".to_owned(),
246 job: "deploy".to_owned(),
247 run_id: "1234567890".to_owned(),
248 job_workflow_ref: None,
249 permissions: Some(perms_with_id_token_write()),
250 now: Some(DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap()),
251 ttl_seconds: Some(300),
252 extra_claims: BTreeMap::new(),
253 });
254
255 assert_eq!(report.audience, "https://example.com");
256 assert_eq!(report.signing_mode, STUB_LOCAL_SIGNING_MODE);
257 assert!(matches!(report.compatibility, Compatibility::Simulated));
258 assert_eq!(report.iat, 1_700_000_000);
259 assert_eq!(report.exp, 1_700_000_300);
260 assert_eq!(
261 report.claims.get("sub").and_then(|v| v.as_str()).unwrap(),
262 "repo:wildmason/mortar:ref:refs/heads/main"
263 );
264 assert_eq!(
265 report
266 .claims
267 .get("repository_owner")
268 .and_then(|v| v.as_str())
269 .unwrap(),
270 "wildmason"
271 );
272 assert!(
273 report
274 .claims
275 .get("stub_local")
276 .and_then(|v| v.as_bool())
277 .unwrap()
278 );
279 assert!(verify_stub_local(&report.token));
280 assert!(
281 report
282 .checks
283 .iter()
284 .any(|c| matches!(c.status, CheckStatus::Pass) && c.id == "oidc.id_token_granted")
285 );
286 assert!(report.checks.iter().any(|c| c.id == "oidc.stub_local"));
288 }
289
290 #[test]
291 fn issue_without_id_token_write_fails() {
292 let mut set = PermissionSet::default();
293 set.entries.insert(
294 PermissionKey::Contents.as_str().to_owned(),
295 PermissionLevel::Write,
296 );
297
298 let report = issue(IssueOptions {
299 audience: "https://example.com".to_owned(),
300 repository: "wildmason/mortar".to_owned(),
301 git_ref: "refs/heads/main".to_owned(),
302 sha: "deadbeef".to_owned(),
303 workflow: "release.yml".to_owned(),
304 job: "deploy".to_owned(),
305 run_id: "1".to_owned(),
306 job_workflow_ref: None,
307 permissions: Some(set),
308 now: None,
309 ttl_seconds: None,
310 extra_claims: BTreeMap::new(),
311 });
312
313 assert!(
314 report
315 .checks
316 .iter()
317 .any(|c| matches!(c.status, CheckStatus::Fail)
318 && c.id == "oidc.id_token_not_granted")
319 );
320 }
321
322 #[test]
323 fn issue_with_empty_audience_fails() {
324 let report = issue(IssueOptions {
325 audience: "".to_owned(),
326 repository: "wildmason/mortar".to_owned(),
327 git_ref: "refs/heads/main".to_owned(),
328 sha: "deadbeef".to_owned(),
329 workflow: "release.yml".to_owned(),
330 job: "deploy".to_owned(),
331 run_id: "1".to_owned(),
332 job_workflow_ref: None,
333 permissions: Some(perms_with_id_token_write()),
334 now: None,
335 ttl_seconds: None,
336 extra_claims: BTreeMap::new(),
337 });
338
339 assert!(
340 report
341 .checks
342 .iter()
343 .any(|c| matches!(c.status, CheckStatus::Fail) && c.id == "oidc.audience_missing")
344 );
345 }
346
347 #[test]
348 fn token_round_trips_through_local_verifier() {
349 let report = issue(IssueOptions {
350 audience: "aud".to_owned(),
351 repository: "wildmason/mortar".to_owned(),
352 git_ref: "refs/heads/main".to_owned(),
353 sha: "abc".to_owned(),
354 workflow: "release.yml".to_owned(),
355 job: "deploy".to_owned(),
356 run_id: "1".to_owned(),
357 job_workflow_ref: None,
358 permissions: Some(perms_with_id_token_write()),
359 now: None,
360 ttl_seconds: None,
361 extra_claims: BTreeMap::new(),
362 });
363
364 assert!(verify_stub_local(&report.token));
365 let mutated = format!("{}x", report.token);
366 assert!(!verify_stub_local(&mutated));
367 }
368}