Skip to main content

auths_sdk/workflows/ci/
machine_identity.rs

1use chrono::{DateTime, Utc};
2use std::sync::Arc;
3
4use auths_oidc_port::{
5    JwksClient, JwtValidator, OidcError, OidcValidationConfig, TimestampClient, TimestampConfig,
6};
7use auths_verifier::core::{Attestation, Ed25519Signature, OidcBinding, ResourceId};
8use auths_verifier::types::CanonicalDid;
9use ring::signature::Ed25519KeyPair;
10
11/// Configuration for creating a machine identity from an OIDC token.
12///
13/// # Usage
14///
15/// ```ignore
16/// use auths_sdk::workflows::machine_identity::{OidcMachineIdentityConfig, create_machine_identity_from_oidc_token};
17/// use chrono::Utc;
18///
19/// let config = OidcMachineIdentityConfig {
20///     issuer: "https://token.actions.githubusercontent.com".to_string(),
21///     audience: "sigstore".to_string(),
22///     platform: "github".to_string(),
23/// };
24///
25/// let identity = create_machine_identity_from_oidc_token(
26///     token,
27///     config,
28///     jwt_validator,
29///     jwks_client,
30///     timestamp_client,
31///     Utc::now(),
32/// ).await?;
33/// ```
34#[derive(Debug, Clone)]
35pub struct OidcMachineIdentityConfig {
36    /// OIDC issuer URL
37    pub issuer: String,
38    /// Expected audience
39    pub audience: String,
40    /// CI platform name (github, gitlab, circleci)
41    pub platform: String,
42}
43
44/// Machine identity created from an OIDC token.
45///
46/// Contains the binding proof (issuer, subject, audience, expiration) so verifiers
47/// can reconstruct the identity later without needing the ephemeral key.
48#[derive(Debug, Clone)]
49pub struct OidcMachineIdentity {
50    /// Platform (github, gitlab, circleci)
51    pub platform: String,
52    /// Subject claim (unique workload identifier)
53    pub subject: String,
54    /// Token expiration
55    pub token_exp: i64,
56    /// Issuer
57    pub issuer: String,
58    /// Audience
59    pub audience: String,
60    /// JTI for replay detection
61    pub jti: Option<String>,
62    /// Platform-normalized claims
63    pub normalized_claims: serde_json::Map<String, serde_json::Value>,
64}
65
66/// Create a machine identity from an OIDC token.
67///
68/// Validates the token, extracts claims, performs replay detection,
69/// and optionally timestamps the identity.
70///
71/// # Args
72///
73/// * `token`: Raw JWT OIDC token
74/// * `config`: Machine identity configuration
75/// * `jwt_validator`: JWT validator implementation
76/// * `jwks_client`: JWKS client for key resolution
77/// * `timestamp_client`: Optional timestamp client
78/// * `now`: Current UTC time for validation
79pub async fn create_machine_identity_from_oidc_token(
80    token: &str,
81    config: OidcMachineIdentityConfig,
82    jwt_validator: Arc<dyn JwtValidator>,
83    _jwks_client: Arc<dyn JwksClient>,
84    timestamp_client: Arc<dyn TimestampClient>,
85    now: DateTime<Utc>,
86) -> Result<OidcMachineIdentity, OidcError> {
87    let validation_config = OidcValidationConfig::builder()
88        .issuer(&config.issuer)
89        .audience(&config.audience)
90        .build()
91        .map_err(OidcError::JwtDecode)?;
92
93    let claims =
94        validate_and_extract_oidc_claims(token, &validation_config, &*jwt_validator, now).await?;
95
96    let jti = claims
97        .get("jti")
98        .and_then(|j| j.as_str())
99        .map(|s| s.to_string());
100
101    check_jti_and_register(&jti)?;
102
103    let subject = claims
104        .get("sub")
105        .and_then(|s| s.as_str())
106        .ok_or_else(|| OidcError::ClaimsValidationFailed {
107            claim: "sub".to_string(),
108            reason: "missing subject".to_string(),
109        })?
110        .to_string();
111
112    let issuer = claims
113        .get("iss")
114        .and_then(|i| i.as_str())
115        .ok_or_else(|| OidcError::ClaimsValidationFailed {
116            claim: "iss".to_string(),
117            reason: "missing issuer".to_string(),
118        })?
119        .to_string();
120
121    let audience = claims
122        .get("aud")
123        .and_then(|a| a.as_str())
124        .ok_or_else(|| OidcError::ClaimsValidationFailed {
125            claim: "aud".to_string(),
126            reason: "missing audience".to_string(),
127        })?
128        .to_string();
129
130    let token_exp = claims.get("exp").and_then(|e| e.as_i64()).ok_or_else(|| {
131        OidcError::ClaimsValidationFailed {
132            claim: "exp".to_string(),
133            reason: "missing or invalid expiration".to_string(),
134        }
135    })?;
136
137    let normalized_claims = normalize_platform_claims(&config.platform, &claims)?;
138
139    let _timestamp = timestamp_client
140        .timestamp(token.as_bytes(), &TimestampConfig::default())
141        .await
142        .ok();
143
144    Ok(OidcMachineIdentity {
145        platform: config.platform,
146        subject,
147        token_exp,
148        issuer,
149        audience,
150        jti,
151        normalized_claims,
152    })
153}
154
155async fn validate_and_extract_oidc_claims(
156    token: &str,
157    config: &OidcValidationConfig,
158    validator: &dyn JwtValidator,
159    now: DateTime<Utc>,
160) -> Result<serde_json::Value, OidcError> {
161    validator.validate(token, config, now).await
162}
163
164fn check_jti_and_register(jti: &Option<String>) -> Result<(), OidcError> {
165    if let Some(jti_value) = jti
166        && jti_value.is_empty()
167    {
168        return Err(OidcError::TokenReplayDetected("empty jti".to_string()));
169    }
170    Ok(())
171}
172
173fn normalize_platform_claims(
174    platform: &str,
175    claims: &serde_json::Value,
176) -> Result<serde_json::Map<String, serde_json::Value>, OidcError> {
177    use auths_infra_http::normalize_workload_claims;
178
179    normalize_workload_claims(platform, claims.clone()).map_err(|e| {
180        OidcError::ClaimsValidationFailed {
181            claim: "platform_claims".to_string(),
182            reason: e,
183        }
184    })
185}
186
187/// Parameters for signing a commit with an identity.
188///
189/// Args:
190/// * `commit_sha`: The Git commit SHA (40 hex characters)
191/// * `issuer_did`: The issuer identity DID
192/// * `device_did`: The device DID
193/// * `commit_message`: Optional commit message
194/// * `author`: Optional commit author info
195/// * `oidc_binding`: Optional OIDC binding from a machine identity
196/// * `timestamp`: When the attestation was created
197#[derive(Debug, Clone)]
198pub struct SignCommitParams {
199    /// Git commit SHA
200    pub commit_sha: String,
201    /// Issuer identity DID
202    pub issuer_did: String,
203    /// Device DID for the signing device
204    pub device_did: String,
205    /// Git commit message (optional)
206    pub commit_message: Option<String>,
207    /// Commit author (optional)
208    pub author: Option<String>,
209    /// OIDC binding if signed from CI (optional)
210    pub oidc_binding: Option<OidcMachineIdentity>,
211    /// Timestamp of attestation creation
212    pub timestamp: DateTime<Utc>,
213}
214
215/// Sign a commit with an identity, producing a signed attestation.
216///
217/// Creates an attestation with commit metadata and OIDC binding (if available),
218/// signs it with the identity's keypair, and returns the attestation structure.
219///
220/// # Args
221///
222/// * `params`: Signing parameters including commit SHA, DIDs, and optional OIDC binding
223/// * `issuer_keypair`: Ed25519 keypair for signing (issuer side)
224/// * `device_public_key`: Device's Ed25519 public key
225///
226/// # Usage:
227///
228/// ```ignore
229/// let params = SignCommitParams {
230///     commit_sha: "abc123...".to_string(),
231///     issuer_did: "did:keri:E...".to_string(),
232///     device_did: "did:key:z...".to_string(),
233///     commit_message: Some("feat: add X".to_string()),
234///     author: Some("alice".to_string()),
235///     oidc_binding: Some(machine_identity),
236///     timestamp: Utc::now(),
237/// };
238///
239/// let attestation = sign_commit_with_identity(
240///     &params,
241///     &issuer_keypair,
242///     &device_public_key,
243/// )?;
244/// ```
245pub fn sign_commit_with_identity(
246    params: &SignCommitParams,
247    issuer_keypair: &Ed25519KeyPair,
248    device_public_key: &[u8; 32],
249) -> Result<Attestation, Box<dyn std::error::Error>> {
250    let issuer = CanonicalDid::parse(&params.issuer_did)
251        .map_err(|e| format!("Invalid issuer DID: {}", e))?;
252    let subject = CanonicalDid::parse(&params.device_did)
253        .map_err(|e| format!("Invalid device DID: {}", e))?;
254
255    let device_pk = auths_verifier::DevicePublicKey::ed25519(device_public_key);
256
257    let oidc_binding = params.oidc_binding.as_ref().map(|mi| OidcBinding {
258        issuer: mi.issuer.clone(),
259        subject: mi.subject.clone(),
260        audience: mi.audience.clone(),
261        token_exp: mi.token_exp,
262        platform: Some(mi.platform.clone()),
263        jti: mi.jti.clone(),
264        normalized_claims: Some(mi.normalized_claims.clone()),
265    });
266
267    let rid = format!("auths/commits/{}", params.commit_sha);
268
269    let mut attestation = Attestation {
270        version: 1,
271        rid: ResourceId::new(rid),
272        issuer: issuer.clone(),
273        #[allow(clippy::disallowed_methods)]
274        // INVARIANT: subject is a validated CanonicalDid parsed on line 255
275        subject: CanonicalDid::new_unchecked(subject.as_str()),
276        device_public_key: device_pk,
277        identity_signature: Ed25519Signature::empty(),
278        device_signature: Ed25519Signature::empty(),
279        revoked_at: None,
280        expires_at: None,
281        timestamp: Some(params.timestamp),
282        note: None,
283        payload: None,
284        delegated_by: None,
285        signer_type: None,
286        environment_claim: None,
287        commit_sha: Some(params.commit_sha.clone()),
288        commit_message: params.commit_message.clone(),
289        author: params.author.clone(),
290        oidc_binding,
291    };
292
293    // Create canonical form and sign
294    let canonical_bytes =
295        auths_verifier::core::canonicalize_attestation_data(&attestation.canonical_data())
296            .map_err(|e| format!("Canonicalization failed: {}", e))?;
297
298    let signature = issuer_keypair.sign(&canonical_bytes);
299    attestation.identity_signature = Ed25519Signature::try_from_slice(signature.as_ref())
300        .map_err(|e| format!("Signature encoding failed: {}", e))?;
301
302    Ok(attestation)
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn test_jti_validation_empty() {
311        let result = check_jti_and_register(&Some("".to_string()));
312        assert!(matches!(result, Err(OidcError::TokenReplayDetected(_))));
313    }
314
315    #[test]
316    fn test_jti_validation_none() {
317        let result = check_jti_and_register(&None);
318        assert!(result.is_ok());
319    }
320
321    #[test]
322    fn test_jti_validation_valid() {
323        let result = check_jti_and_register(&Some("valid-jti".to_string()));
324        assert!(result.is_ok());
325    }
326
327    #[test]
328    fn test_sign_commit_params_structure() {
329        #[allow(clippy::disallowed_methods)] // test code
330        let timestamp = Utc::now();
331        let params = SignCommitParams {
332            commit_sha: "abc123def456".to_string(),
333            issuer_did: "did:keri:Eissuer".to_string(),
334            device_did: "did:key:z6Mk...".to_string(),
335            commit_message: Some("feat: add X".to_string()),
336            author: Some("Alice".to_string()),
337            oidc_binding: None,
338            timestamp,
339        };
340
341        assert_eq!(params.commit_sha, "abc123def456");
342        assert_eq!(params.issuer_did, "did:keri:Eissuer");
343        assert_eq!(params.device_did, "did:key:z6Mk...");
344        assert!(params.oidc_binding.is_none());
345    }
346
347    #[test]
348    fn test_oidc_machine_identity_structure() {
349        let mut claims = serde_json::Map::new();
350        claims.insert("repo".to_string(), "owner/repo".into());
351
352        let identity = OidcMachineIdentity {
353            platform: "github".to_string(),
354            subject: "repo:owner/repo:ref:refs/heads/main".to_string(),
355            token_exp: 1704067200,
356            issuer: "https://token.actions.githubusercontent.com".to_string(),
357            audience: "sigstore".to_string(),
358            jti: Some("jti-123".to_string()),
359            normalized_claims: claims,
360        };
361
362        assert_eq!(identity.platform, "github");
363        assert_eq!(
364            identity.issuer,
365            "https://token.actions.githubusercontent.com"
366        );
367        assert!(identity.jti.is_some());
368    }
369
370    #[test]
371    fn test_oidc_binding_from_machine_identity() {
372        let mut claims = serde_json::Map::new();
373        claims.insert("run_id".to_string(), "12345".into());
374
375        let machine_id = OidcMachineIdentity {
376            platform: "github".to_string(),
377            subject: "workload_subject".to_string(),
378            token_exp: 1704067200,
379            issuer: "https://token.actions.githubusercontent.com".to_string(),
380            audience: "sigstore".to_string(),
381            jti: Some("jti-456".to_string()),
382            normalized_claims: claims,
383        };
384
385        let binding = OidcBinding {
386            issuer: machine_id.issuer.clone(),
387            subject: machine_id.subject.clone(),
388            audience: machine_id.audience.clone(),
389            token_exp: machine_id.token_exp,
390            platform: Some(machine_id.platform.clone()),
391            jti: machine_id.jti.clone(),
392            normalized_claims: Some(machine_id.normalized_claims.clone()),
393        };
394
395        assert_eq!(
396            binding.issuer,
397            "https://token.actions.githubusercontent.com"
398        );
399        assert_eq!(binding.platform, Some("github".to_string()));
400        assert!(binding.normalized_claims.is_some());
401    }
402}