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 identity DID (e.g., "did:keri:...") issuing the attestation.
45    pub identity_did: &'a IdentityDID,
46    /// The subject of the attestation — typed as `&CanonicalDid` so
47    /// callers can supply either `did:key:` or `did:keri:` shapes. The
48    /// wire format (`Attestation.subject`) is also `CanonicalDid`, so
49    /// this field type matches the downstream serialized shape
50    /// exactly. Field named `subject` to match wire semantics.
51    pub subject: &'a CanonicalDid,
52    /// Raw device public key bytes (32 Ed25519, 33 P-256 compressed).
53    pub device_public_key: &'a [u8],
54    /// Signing curve of `device_public_key`. Carried in-band so the
55    /// attestation interior never infers curve from byte length.
56    pub device_curve: auths_crypto::CurveType,
57    /// Optional JSON payload for the attestation.
58    pub payload: Option<Value>,
59    /// Attestation metadata (timestamp, expiry, notes).
60    pub meta: &'a AttestationMetadata,
61    /// Identity-key alias in the keychain; `None` = device-only signing.
62    pub identity_alias: Option<&'a KeyAlias>,
63    /// Device-key alias; `None` = no device signature.
64    pub device_alias: Option<&'a KeyAlias>,
65    /// Optional delegator DID included in the signed envelope.
66    pub delegated_by: Option<IdentityDID>,
67    /// Git commit SHA this attestation anchors, if any.
68    pub commit_sha: Option<String>,
69    /// Signer type (machine, human, etc.).
70    pub signer_type: Option<SignerType>,
71}
72
73/// Creates a signed attestation by signing internally using the provided SecureSigner.
74///
75/// Constructs the canonical attestation data, signs it using the signer for
76/// both identity and device (if `device_alias` is provided), and returns the
77/// complete attestation with embedded signatures.
78///
79/// Args:
80/// * `now`: Creation timestamp, validated against `input.meta.timestamp` for clock drift.
81/// * `input`: Attestation payload + metadata (see [`AttestationInput`]).
82/// * `signer`: SecureSigner implementation for signing operations.
83/// * `passphrase_provider`: Provider for obtaining passphrases during signing.
84///
85/// Usage:
86/// ```ignore
87/// let att = create_signed_attestation(now, input, signer, passphrase)?;
88/// ```
89pub fn create_signed_attestation(
90    now: DateTime<Utc>,
91    input: AttestationInput<'_>,
92    signer: &dyn SecureSigner,
93    passphrase_provider: &dyn PassphraseProvider,
94) -> Result<Attestation, AttestationError> {
95    let AttestationInput {
96        rid,
97        identity_did,
98        subject,
99        device_public_key,
100        device_curve,
101        payload,
102        meta,
103        identity_alias,
104        device_alias,
105        delegated_by,
106        commit_sha,
107        signer_type,
108    } = input;
109    // Length must match the declared curve. No length dispatch — the curve
110    // came in-band from the caller, so this is pure validation.
111    let expected = device_curve.public_key_len();
112    if device_public_key.len() != expected {
113        return Err(AttestationError::InvalidInput(format!(
114            "Device public key length {} does not match {} (expected {} bytes)",
115            device_public_key.len(),
116            device_curve,
117            expected
118        )));
119    }
120
121    // Validate timestamp is not too far from current time (clock drift protection)
122    if let Some(ts) = meta.timestamp {
123        let drift = (now - ts).num_seconds().abs();
124        if drift > MAX_CREATION_SKEW_SECS {
125            return Err(AttestationError::InvalidInput(format!(
126                "System clock drift {}s exceeds {}s limit",
127                drift, MAX_CREATION_SKEW_SECS
128            )));
129        }
130    }
131
132    // Build attestation with empty signatures first (ActionEnvelope pattern)
133    #[allow(clippy::disallowed_methods)]
134    // INVARIANT: identity_did is an IdentityDID which guarantees valid DID format
135    let issuer_canonical = CanonicalDid::new_unchecked(identity_did.as_str());
136    #[allow(clippy::disallowed_methods)]
137    // INVARIANT: subject is a validated CanonicalDid from the caller
138    let subject_canonical = CanonicalDid::new_unchecked(subject.as_str());
139    let delegated_canonical = delegated_by.as_ref().map(|d| CanonicalDid::from(d.clone()));
140
141    let mut attestation = Attestation {
142        version: ATTESTATION_VERSION,
143        subject: subject_canonical,
144        issuer: issuer_canonical,
145        rid: ResourceId::new(rid),
146        payload: payload.clone(),
147        timestamp: meta.timestamp,
148        expires_at: meta.expires_at,
149        revoked_at: None,
150        note: meta.note.clone(),
151        device_public_key: auths_verifier::DevicePublicKey::try_new(
152            device_curve,
153            device_public_key,
154        )
155        .map_err(|e| AttestationError::InvalidInput(e.to_string()))?,
156        identity_signature: Ed25519Signature::empty(),
157        device_signature: Ed25519Signature::empty(),
158        delegated_by: delegated_canonical,
159        signer_type,
160        environment_claim: None,
161        commit_sha,
162        commit_message: None,
163        author: None,
164        oidc_binding: None,
165    };
166
167    // Canonicalize using single source of truth
168    let message_to_sign = canonicalize_attestation_data(&attestation.canonical_data())?;
169
170    // Sign with the identity key (if alias provided)
171    if let Some(alias) = identity_alias {
172        debug!("Signing attestation with identity alias '{}'", alias);
173        let sig = signer
174            .sign_with_alias(alias, passphrase_provider, &message_to_sign)
175            .map_err(|e| {
176                AttestationError::SigningError(format!(
177                    "Failed to sign with identity key '{}': {}",
178                    alias, e
179                ))
180            })?;
181        debug!("Identity signature obtained successfully");
182        attestation.identity_signature = Ed25519Signature::try_from_slice(&sig)
183            .map_err(|e| AttestationError::SigningError(e.to_string()))?;
184    } else {
185        debug!("No identity alias provided, skipping identity signature (device-only attestation)");
186    }
187
188    // Sign with the device key if alias provided
189    if let Some(alias) = device_alias {
190        debug!("Signing attestation with device alias '{}'", alias);
191        let sig = signer
192            .sign_with_alias(alias, passphrase_provider, &message_to_sign)
193            .map_err(|e| {
194                AttestationError::SigningError(format!(
195                    "Failed to sign with device key '{}': {}",
196                    alias, e
197                ))
198            })?;
199        debug!("Device signature obtained successfully");
200        attestation.device_signature = Ed25519Signature::try_from_slice(&sig)
201            .map_err(|e| AttestationError::SigningError(e.to_string()))?;
202    } else {
203        debug!("No device alias provided, skipping device signature");
204    }
205
206    Ok(attestation)
207}
208
209/// Generates the canonical byte representation specifically for revocation data.
210pub fn canonicalize_revocation_data(
211    data: &CanonicalRevocationData,
212) -> Result<Vec<u8>, AttestationError> {
213    let canonical_json_string = json_canon::to_string(data).map_err(|e| {
214        AttestationError::SerializationError(format!(
215            "Failed to create canonical JSON for revocation: {}",
216            e
217        ))
218    })?;
219    debug!(
220        "Generated canonical data (revocation): {}",
221        canonical_json_string
222    );
223    Ok(canonical_json_string.into_bytes())
224}