Skip to main content

auths_sdk/workflows/
dsse.rs

1//! Generic DSSE signing/verification for arbitrary in-toto Statements.
2//!
3//! The auths DSSE crypto already exists, but it is reachable only welded to the
4//! compliance evidence-pack predicate. This module exposes the same envelope +
5//! PAE as a **predicate-agnostic** surface: the caller supplies a complete
6//! in-toto Statement (its own `predicateType`), and this signs its DSSE
7//! pre-authentication encoding with an agent identity's keychain key. The
8//! envelope is byte-compatible with the compliance one, so a verdict statement
9//! and a compliance statement verify through the same DSSE path — only the
10//! predicate differs.
11//!
12//! Reuses [`crate::domains::signing::service::dsse_pae`] and the
13//! [`DsseEnvelope`] wire type so there is exactly one DSSE envelope shape in the
14//! SDK.
15
16use std::sync::Arc;
17
18use auths_core::signing::{PassphraseProvider, SecureSigner, StorageSigner};
19use auths_core::storage::keychain::{KeyAlias, KeyStorage};
20use auths_crypto::CurveType;
21use auths_keri::KeriPublicKey;
22use base64::Engine;
23use base64::engine::general_purpose::STANDARD as BASE64;
24use thiserror::Error;
25
26use crate::domains::compliance::dsse::{DSSE_INTOTO_PAYLOAD_TYPE, DsseEnvelope, DsseSignature};
27use crate::domains::signing::service::dsse_pae;
28
29/// Errors from generic DSSE statement signing/verification.
30#[derive(Debug, Error)]
31pub enum DsseError {
32    /// The caller-supplied statement is not a well-formed in-toto Statement.
33    #[error("invalid statement: {0}")]
34    InvalidStatement(String),
35
36    /// Signing the DSSE PAE failed (keychain/passphrase).
37    #[error("signing failed: {0}")]
38    Signing(String),
39
40    /// The envelope or its payload could not be decoded.
41    #[error("decode error: {0}")]
42    Decode(String),
43
44    /// No signature verified against the pinned key.
45    #[error("verification failed: {0}")]
46    Verification(String),
47}
48
49/// In-band curve tag for a signature (never inferred from byte length).
50fn curve_tag(curve: CurveType) -> &'static str {
51    match curve {
52        CurveType::Ed25519 => "ed25519",
53        CurveType::P256 => "p256",
54    }
55}
56
57/// Parse a curve tag; unknown/missing defaults to P-256 (the workspace default).
58fn curve_from_tag(tag: &str) -> CurveType {
59    match tag {
60        "ed25519" => CurveType::Ed25519,
61        _ => CurveType::P256,
62    }
63}
64
65/// Confirm a JSON value is an in-toto Statement: a `_type` and a `predicateType`
66/// string are the defining fields (version-agnostic — accepts Statement v0.1/v1).
67fn validate_intoto_statement(value: &serde_json::Value) -> Result<(), DsseError> {
68    let has = |k: &str| value.get(k).and_then(|v| v.as_str()).is_some();
69    if !has("_type") || !has("predicateType") {
70        return Err(DsseError::InvalidStatement(
71            "an in-toto Statement must carry string `_type` and `predicateType` fields".into(),
72        ));
73    }
74    Ok(())
75}
76
77/// DSSE-sign an in-toto Statement with a keychain identity (e.g. an agent).
78///
79/// The payload is the caller's complete in-toto Statement JSON; its DSSE PAE is
80/// signed under `alias`, and the signature's curve travels in-band. The
81/// predicate is entirely the caller's — this is predicate-agnostic.
82///
83/// Args:
84/// * `key_storage` — Keychain holding the signing key.
85/// * `passphrase_provider` — Unlocks the key (file-backend keychains).
86/// * `keyid` — The signer's `did:keri:`, recorded as the signature `keyid`.
87/// * `alias` — Keychain alias of the signing key.
88/// * `curve` — The signing key's curve (carried in-band; resolve it, don't guess).
89/// * `statement_json` — The complete in-toto Statement to wrap and sign.
90///
91/// Usage:
92/// ```ignore
93/// let env = sign_intoto_statement(keychain, &provider, agent_did, &alias, curve, &statement)?;
94/// ```
95pub fn sign_intoto_statement(
96    key_storage: Arc<dyn KeyStorage + Send + Sync>,
97    passphrase_provider: &dyn PassphraseProvider,
98    keyid: &str,
99    alias: &KeyAlias,
100    curve: CurveType,
101    statement_json: &str,
102) -> Result<DsseEnvelope, DsseError> {
103    let value: serde_json::Value = serde_json::from_str(statement_json)
104        .map_err(|e| DsseError::InvalidStatement(format!("statement is not JSON: {e}")))?;
105    validate_intoto_statement(&value)?;
106
107    let payload = statement_json.as_bytes();
108    let to_sign = dsse_pae(DSSE_INTOTO_PAYLOAD_TYPE, payload);
109    let signer = StorageSigner::new(key_storage);
110    let sig = signer
111        .sign_with_alias(alias, passphrase_provider, &to_sign)
112        .map_err(|e| DsseError::Signing(e.to_string()))?;
113
114    Ok(DsseEnvelope {
115        payload_type: DSSE_INTOTO_PAYLOAD_TYPE.to_string(),
116        payload: BASE64.encode(payload),
117        signatures: vec![DsseSignature {
118            keyid: keyid.to_string(),
119            curve: curve_tag(curve).to_string(),
120            sig: BASE64.encode(&sig),
121        }],
122    })
123}
124
125/// DSSE-sign an in-toto Statement with a raw 32-byte seed (no keychain).
126///
127/// The same as [`sign_intoto_statement`] but for an ephemeral in-memory identity
128/// whose seed is held directly rather than in a keychain — it signs the DSSE PAE
129/// with `auths_crypto::typed_sign`. The resulting envelope is byte-compatible and
130/// verifies through the same [`verify_intoto_statement`] path.
131///
132/// Args:
133/// * `seed` — The signer's 32-byte private seed.
134/// * `curve` — The seed's curve (carried in-band on the signature).
135/// * `keyid` — The signer's `did:keri:`, recorded as the signature `keyid`.
136/// * `statement_json` — The complete in-toto Statement to wrap and sign.
137///
138/// Usage:
139/// ```ignore
140/// let env = sign_intoto_statement_with_seed(&seed, curve, agent_did, &statement)?;
141/// ```
142pub fn sign_intoto_statement_with_seed(
143    seed: &[u8; 32],
144    curve: CurveType,
145    keyid: &str,
146    statement_json: &str,
147) -> Result<DsseEnvelope, DsseError> {
148    let value: serde_json::Value = serde_json::from_str(statement_json)
149        .map_err(|e| DsseError::InvalidStatement(format!("statement is not JSON: {e}")))?;
150    validate_intoto_statement(&value)?;
151
152    let payload = statement_json.as_bytes();
153    let to_sign = dsse_pae(DSSE_INTOTO_PAYLOAD_TYPE, payload);
154    let typed_seed = auths_crypto::TypedSeed::from_curve(curve, *seed);
155    let sig = auths_crypto::typed_sign(&typed_seed, &to_sign)
156        .map_err(|e| DsseError::Signing(e.to_string()))?;
157
158    Ok(DsseEnvelope {
159        payload_type: DSSE_INTOTO_PAYLOAD_TYPE.to_string(),
160        payload: BASE64.encode(payload),
161        signatures: vec![DsseSignature {
162            keyid: keyid.to_string(),
163            curve: curve_tag(curve).to_string(),
164            sig: BASE64.encode(&sig),
165        }],
166    })
167}
168
169/// Verify a DSSE-wrapped in-toto Statement against a pinned public key, offline.
170///
171/// Recomputes the PAE over the decoded payload and checks that `pinned_public_key`
172/// signed it — the curve travels in-band on each signature, so no curve argument
173/// is needed. Returns the parsed in-toto Statement on success; a forged, absent,
174/// or wrong-key signature is an `Err`. No network, no keychain.
175///
176/// Args:
177/// * `envelope_json` — The DSSE envelope as produced by [`sign_intoto_statement`].
178/// * `pinned_public_key` — The signer's verkey bytes, pinned out of band.
179///
180/// Usage:
181/// ```ignore
182/// let statement = verify_intoto_statement(&envelope_json, &agent_pubkey)?;
183/// assert_eq!(statement["predicateType"], "https://recurve.dev/verdict/v1");
184/// ```
185pub fn verify_intoto_statement(
186    envelope_json: &str,
187    pinned_public_key: &[u8],
188) -> Result<serde_json::Value, DsseError> {
189    let envelope: DsseEnvelope = serde_json::from_str(envelope_json)
190        .map_err(|e| DsseError::Decode(format!("envelope is not JSON: {e}")))?;
191    let payload = BASE64
192        .decode(envelope.payload.as_bytes())
193        .map_err(|e| DsseError::Decode(format!("payload base64: {e}")))?;
194    let to_verify = dsse_pae(&envelope.payload_type, &payload);
195
196    let verified = envelope.signatures.iter().any(|s| {
197        let curve = curve_from_tag(&s.curve);
198        let Ok(key) = KeriPublicKey::from_verkey_bytes(pinned_public_key, curve) else {
199            return false;
200        };
201        let Ok(sig) = BASE64.decode(s.sig.as_bytes()) else {
202            return false;
203        };
204        key.verify_signature(&to_verify, &sig).is_ok()
205    });
206
207    if !verified {
208        return Err(DsseError::Verification(
209            "no DSSE signature verified against the pinned key".into(),
210        ));
211    }
212
213    let statement: serde_json::Value = serde_json::from_slice(&payload)
214        .map_err(|e| DsseError::Decode(format!("payload is not JSON: {e}")))?;
215    validate_intoto_statement(&statement)?;
216    Ok(statement)
217}
218
219#[cfg(test)]
220#[allow(clippy::unwrap_used, clippy::expect_used)]
221mod tests {
222    use super::*;
223    use auths_crypto::testing::generate_typed_signer;
224
225    fn intoto_statement(gate: &str) -> String {
226        serde_json::json!({
227            "_type": "https://in-toto.io/Statement/v1",
228            "subject": [{"name": "recurve.gate", "digest": {"sha256": "ab".repeat(32)}}],
229            "predicateType": "https://recurve.dev/verdict/v1",
230            "predicate": {"gate": gate}
231        })
232        .to_string()
233    }
234
235    fn signed_envelope(curve: CurveType, statement: &str) -> (String, Vec<u8>) {
236        let signer = generate_typed_signer(curve);
237        let payload = statement.as_bytes();
238        let pae = dsse_pae(DSSE_INTOTO_PAYLOAD_TYPE, payload);
239        let sig = signer.sign(&pae).unwrap();
240        let env = DsseEnvelope {
241            payload_type: DSSE_INTOTO_PAYLOAD_TYPE.to_string(),
242            payload: BASE64.encode(payload),
243            signatures: vec![DsseSignature {
244                keyid: "did:keri:EAgent".into(),
245                curve: curve_tag(curve).to_string(),
246                sig: BASE64.encode(&sig),
247            }],
248        };
249        (
250            serde_json::to_string(&env).unwrap(),
251            signer.public_key().to_vec(),
252        )
253    }
254
255    #[test]
256    fn verify_round_trips_ed25519() {
257        let (env, pk) = signed_envelope(CurveType::Ed25519, &intoto_statement("GREEN"));
258        let stmt = verify_intoto_statement(&env, &pk).unwrap();
259        assert_eq!(stmt["predicateType"], "https://recurve.dev/verdict/v1");
260        assert_eq!(stmt["predicate"]["gate"], "GREEN");
261    }
262
263    #[test]
264    fn verify_round_trips_p256() {
265        let (env, pk) = signed_envelope(CurveType::P256, &intoto_statement("GREEN"));
266        verify_intoto_statement(&env, &pk).unwrap();
267    }
268
269    #[test]
270    fn verify_rejects_tampered_payload() {
271        let (env, pk) = signed_envelope(CurveType::Ed25519, &intoto_statement("GREEN"));
272        let mut envelope: DsseEnvelope = serde_json::from_str(&env).unwrap();
273        // Swap in a different, well-formed statement — the signature no longer binds it.
274        envelope.payload = BASE64.encode(intoto_statement("RED").as_bytes());
275        let err =
276            verify_intoto_statement(&serde_json::to_string(&envelope).unwrap(), &pk).unwrap_err();
277        assert!(matches!(err, DsseError::Verification(_)));
278    }
279
280    #[test]
281    fn sign_with_seed_round_trips_and_binds() {
282        let signer = generate_typed_signer(CurveType::P256);
283        let seed = *signer.seed().as_bytes();
284        let env = sign_intoto_statement_with_seed(
285            &seed,
286            signer.curve(),
287            "did:keri:EAgent",
288            &intoto_statement("GREEN"),
289        )
290        .unwrap();
291        let stmt =
292            verify_intoto_statement(&serde_json::to_string(&env).unwrap(), signer.public_key())
293                .unwrap();
294        assert_eq!(stmt["predicate"]["gate"], "GREEN");
295
296        let mut envelope: DsseEnvelope =
297            serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap();
298        envelope.payload = BASE64.encode(intoto_statement("RED").as_bytes());
299        let err = verify_intoto_statement(
300            &serde_json::to_string(&envelope).unwrap(),
301            signer.public_key(),
302        )
303        .unwrap_err();
304        assert!(matches!(err, DsseError::Verification(_)));
305    }
306
307    #[test]
308    fn verify_rejects_wrong_key() {
309        let (env, _pk) = signed_envelope(CurveType::Ed25519, &intoto_statement("GREEN"));
310        let stranger = generate_typed_signer(CurveType::Ed25519)
311            .public_key()
312            .to_vec();
313        let err = verify_intoto_statement(&env, &stranger).unwrap_err();
314        assert!(matches!(err, DsseError::Verification(_)));
315    }
316
317    #[test]
318    fn validate_rejects_non_intoto_statement() {
319        // Missing predicateType — not an in-toto Statement.
320        let bad = serde_json::json!({"_type": "https://in-toto.io/Statement/v1"});
321        assert!(validate_intoto_statement(&bad).is_err());
322    }
323}