Skip to main content

gha_github_service_proof/
oidc.rs

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
14/// Public constant: the HS256 secret used to sign all stub-local OIDC tokens.
15/// This is intentionally documented so consumers (and reviewers) can see that
16/// the resulting token is not authoritative.
17pub const STUB_LOCAL_SECRET: &[u8] = b"gha-github-service-proof:stub-local:v1";
18
19/// Public constant: the issuer claim that distinguishes a stub-local token
20/// from a real GitHub-issued OIDC token.
21pub const STUB_LOCAL_ISSUER: &str = "stub-local://gha-github-service-proof";
22
23/// Public constant: the "this token is not trusted by cloud providers" warning
24/// included verbatim in every OIDC receipt.
25pub 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        // the receipt always carries the stub-local advisory warning
287        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}