Skip to main content

auths_sdk/workflows/ci/
machine_identity.rs

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