Skip to main content

auths_id/attestation/
create.rs

1use crate::storage::git_refs::AttestationMetadata;
2
3use auths_core::signing::{PassphraseProvider, SecureSigner};
4use auths_core::storage::keychain::{IdentityDID, KeyAlias};
5use auths_verifier::core::{
6    Attestation, Ed25519Signature, ResourceId, SignerType, canonicalize_attestation_data,
7};
8use auths_verifier::error::AttestationError;
9use auths_verifier::types::CanonicalDid;
10
11use chrono::{DateTime, Utc};
12use log::debug;
13use serde::Serialize;
14use serde_json::Value;
15
16/// Current attestation version - includes org fields in signed envelope
17pub const ATTESTATION_VERSION: u32 = 1;
18
19/// Maximum allowed clock drift at creation time (seconds)
20const MAX_CREATION_SKEW_SECS: i64 = 5 * 60;
21
22/// NEW: Data structure specifically for canonicalizing revocation statements.
23/// Excludes fields not relevant to the revocation itself (device_pk, payload, expires_at).
24#[derive(Serialize, Debug)] // Added Debug
25pub struct CanonicalRevocationData<'a> {
26    pub version: u32,
27    pub rid: &'a str,
28    pub issuer: &'a CanonicalDid,
29    pub subject: &'a CanonicalDid,
30    pub timestamp: &'a Option<DateTime<Utc>>,
31    pub revoked_at: &'a Option<DateTime<Utc>>, // Should always be Some(...)
32    pub note: &'a Option<String>,
33}
34
35/// Inputs for `create_signed_attestation`.
36///
37/// Collapses the long positional argument list into a named-field struct so
38/// the call sites stay readable as the shape of an attestation evolves.
39/// Callers build one of these and hand it in; the function signs over the
40/// canonical bytes and returns the complete attestation.
41pub struct AttestationInput<'a> {
42    /// Resource identifier for this attestation.
43    pub rid: &'a str,
44    /// The issuer DID — `did:keri:` for an identity, or a `did:key:` for an
45    /// ephemeral key acting as its own issuer. Typed as `&CanonicalDid` so both
46    /// shapes are representable without an unvalidated construction.
47    pub issuer: &'a CanonicalDid,
48    /// The subject of the attestation — typed as `&CanonicalDid` so
49    /// callers can supply either `did:key:` or `did:keri:` shapes. The
50    /// wire format (`Attestation.subject`) is also `CanonicalDid`, so
51    /// this field type matches the downstream serialized shape
52    /// exactly. Field named `subject` to match wire semantics.
53    pub subject: &'a CanonicalDid,
54    /// Raw device public key bytes (32 Ed25519, 33 P-256 compressed).
55    pub device_public_key: &'a [u8],
56    /// Signing curve of `device_public_key`. Carried in-band so the
57    /// attestation interior never infers curve from byte length.
58    pub device_curve: auths_crypto::CurveType,
59    /// Optional JSON payload for the attestation.
60    pub payload: Option<Value>,
61    /// Attestation metadata (timestamp, expiry, notes).
62    pub meta: &'a AttestationMetadata,
63    /// Identity-key alias in the keychain; `None` = device-only signing.
64    pub identity_alias: Option<&'a KeyAlias>,
65    /// Device-key alias; `None` = no device signature.
66    pub device_alias: Option<&'a KeyAlias>,
67    /// Optional delegator DID included in the signed envelope.
68    pub delegated_by: Option<IdentityDID>,
69    /// Git commit SHA this attestation anchors, if any.
70    pub commit_sha: Option<String>,
71    /// Signer type (machine, human, etc.).
72    pub signer_type: Option<SignerType>,
73    /// Verified OIDC workload binding, if the signer presented one.
74    /// Included in the signed envelope so verify-time policy joins can
75    /// trust the claims.
76    pub oidc_binding: Option<auths_verifier::core::OidcBinding>,
77}
78
79/// Creates a signed attestation by signing internally using the provided SecureSigner.
80///
81/// Constructs the canonical attestation data, signs it using the signer for
82/// both identity and device (if `device_alias` is provided), and returns the
83/// complete attestation with embedded signatures.
84///
85/// Args:
86/// * `now`: Creation timestamp, validated against `input.meta.timestamp` for clock drift.
87/// * `input`: Attestation payload + metadata (see [`AttestationInput`]).
88/// * `signer`: SecureSigner implementation for signing operations.
89/// * `passphrase_provider`: Provider for obtaining passphrases during signing.
90///
91/// Usage:
92/// ```ignore
93/// let att = create_signed_attestation(now, input, signer, passphrase)?;
94/// ```
95pub fn create_signed_attestation(
96    now: DateTime<Utc>,
97    input: AttestationInput<'_>,
98    signer: &dyn SecureSigner,
99    passphrase_provider: &dyn PassphraseProvider,
100) -> Result<Attestation, AttestationError> {
101    let AttestationInput {
102        rid,
103        issuer,
104        subject,
105        device_public_key,
106        device_curve,
107        payload,
108        meta,
109        identity_alias,
110        device_alias,
111        delegated_by,
112        commit_sha,
113        signer_type,
114        oidc_binding,
115    } = input;
116    // Length must match the declared curve. No length dispatch — the curve
117    // came in-band from the caller, so this is pure validation.
118    let expected = device_curve.public_key_len();
119    if device_public_key.len() != expected {
120        return Err(AttestationError::InvalidInput(format!(
121            "Device public key length {} does not match {} (expected {} bytes)",
122            device_public_key.len(),
123            device_curve,
124            expected
125        )));
126    }
127
128    // Validate timestamp is not too far from current time (clock drift protection)
129    if let Some(ts) = meta.timestamp {
130        let drift = (now - ts).num_seconds().abs();
131        if drift > MAX_CREATION_SKEW_SECS {
132            return Err(AttestationError::InvalidInput(format!(
133                "System clock drift {}s exceeds {}s limit",
134                drift, MAX_CREATION_SKEW_SECS
135            )));
136        }
137    }
138
139    // Build attestation with empty signatures first (ActionEnvelope pattern). The issuer is
140    // already a CanonicalDid (did:keri for an identity, did:key for an ephemeral self-issuer).
141    let issuer_canonical = issuer.clone();
142    #[allow(clippy::disallowed_methods)]
143    // INVARIANT: subject is a validated CanonicalDid from the caller
144    let subject_canonical = CanonicalDid::new_unchecked(subject.as_str());
145    let delegated_canonical = delegated_by.as_ref().map(|d| CanonicalDid::from(d.clone()));
146
147    let mut attestation = Attestation {
148        version: ATTESTATION_VERSION,
149        subject: subject_canonical,
150        issuer: issuer_canonical,
151        rid: ResourceId::new(rid),
152        payload: payload.clone(),
153        timestamp: meta.timestamp,
154        expires_at: meta.expires_at,
155        revoked_at: None,
156        note: meta.note.clone(),
157        device_public_key: auths_verifier::DevicePublicKey::try_new(
158            device_curve,
159            device_public_key,
160        )
161        .map_err(|e| AttestationError::InvalidInput(e.to_string()))?,
162        identity_signature: Ed25519Signature::empty(),
163        device_signature: Ed25519Signature::empty(),
164        delegated_by: delegated_canonical,
165        signer_type,
166        environment_claim: None,
167        commit_sha,
168        commit_message: None,
169        author: None,
170        oidc_binding,
171    };
172
173    // Canonicalize using single source of truth
174    let message_to_sign = canonicalize_attestation_data(&attestation.canonical_data())?;
175
176    // Sign with the identity key (if alias provided)
177    if let Some(alias) = identity_alias {
178        debug!("Signing attestation with identity alias '{}'", alias);
179        let sig = signer
180            .sign_with_alias(alias, passphrase_provider, &message_to_sign)
181            .map_err(|e| {
182                AttestationError::SigningError(format!(
183                    "Failed to sign with identity key '{}': {}",
184                    alias, e
185                ))
186            })?;
187        debug!("Identity signature obtained successfully");
188        attestation.identity_signature = Ed25519Signature::try_from_slice(&sig)
189            .map_err(|e| AttestationError::SigningError(e.to_string()))?;
190    } else {
191        debug!("No identity alias provided, skipping identity signature (device-only attestation)");
192    }
193
194    // Sign with the device key if alias provided
195    if let Some(alias) = device_alias {
196        debug!("Signing attestation with device alias '{}'", alias);
197        let sig = signer
198            .sign_with_alias(alias, passphrase_provider, &message_to_sign)
199            .map_err(|e| {
200                AttestationError::SigningError(format!(
201                    "Failed to sign with device key '{}': {}",
202                    alias, e
203                ))
204            })?;
205        debug!("Device signature obtained successfully");
206        attestation.device_signature = Ed25519Signature::try_from_slice(&sig)
207            .map_err(|e| AttestationError::SigningError(e.to_string()))?;
208    } else {
209        debug!("No device alias provided, skipping device signature");
210    }
211
212    Ok(attestation)
213}
214
215/// Generates the canonical byte representation specifically for revocation data.
216pub fn canonicalize_revocation_data(
217    data: &CanonicalRevocationData,
218) -> Result<Vec<u8>, AttestationError> {
219    let canonical_json_string = json_canon::to_string(data).map_err(|e| {
220        AttestationError::SerializationError(format!(
221            "Failed to create canonical JSON for revocation: {}",
222            e
223        ))
224    })?;
225    debug!(
226        "Generated canonical data (revocation): {}",
227        canonical_json_string
228    );
229    Ok(canonical_json_string.into_bytes())
230}