Skip to main content

auths_verifier/
action.rs

1//! Typed action envelope for signed actions.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6/// Typed action envelope for signed actions.
7///
8/// Compatible with the existing Python SDK wire format (version "1.0").
9/// The `signature` field is excluded from the canonical signing data.
10///
11/// Args:
12/// * `version`: Protocol version string (currently "1.0").
13/// * `action_type`: The type of action being performed.
14/// * `identity`: DID of the signing identity.
15/// * `payload`: Arbitrary JSON payload.
16/// * `timestamp`: RFC3339 timestamp string.
17/// * `signature`: Hex-encoded Ed25519 signature over the canonical signing data.
18/// * `attestation_chain`: Optional chain of attestations for verification.
19/// * `environment`: Optional environment claim for gateway verification.
20///
21/// Usage:
22/// ```ignore
23/// let envelope = ActionEnvelope {
24///     version: "1.0".into(),
25///     action_type: "sign_commit".into(),
26///     identity: "did:keri:Eabc123".into(),
27///     payload: serde_json::json!({"hash": "abc123"}),
28///     timestamp: "2024-01-01T00:00:00Z".into(),
29///     signature: "deadbeef...".into(),
30///     attestation_chain: None,
31///     environment: None,
32/// };
33/// ```
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct ActionEnvelope {
36    /// Protocol version string.
37    pub version: String,
38    /// The type of action being performed.
39    #[serde(rename = "type")]
40    pub action_type: String,
41    /// DID of the signing identity.
42    pub identity: String,
43    /// Arbitrary JSON payload.
44    pub payload: Value,
45    /// RFC3339 timestamp string.
46    pub timestamp: String,
47    /// Hex-encoded Ed25519 signature over the canonical signing data.
48    pub signature: String,
49    /// Optional chain of attestations for verification.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub attestation_chain: Option<Value>,
52    /// Optional environment claim for gateway verification.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub environment: Option<Value>,
55}
56
57/// The subset of `ActionEnvelope` fields that are signed.
58///
59/// Excludes `signature`, `attestation_chain`, and `environment`.
60#[derive(Debug, Serialize)]
61pub struct ActionSigningData<'a> {
62    /// Protocol version string.
63    pub version: &'a str,
64    /// The type of action being performed.
65    #[serde(rename = "type")]
66    pub action_type: &'a str,
67    /// DID of the signing identity.
68    pub identity: &'a str,
69    /// Arbitrary JSON payload.
70    pub payload: &'a Value,
71    /// RFC3339 timestamp string.
72    pub timestamp: &'a str,
73}
74
75impl ActionEnvelope {
76    /// Extracts the signing data from this envelope.
77    pub fn signing_data(&self) -> ActionSigningData<'_> {
78        ActionSigningData {
79            version: &self.version,
80            action_type: &self.action_type,
81            identity: &self.identity,
82            payload: &self.payload,
83            timestamp: &self.timestamp,
84        }
85    }
86
87    /// Produces the canonical JSON bytes for signature verification.
88    pub fn canonical_bytes(&self) -> Result<Vec<u8>, String> {
89        let data = self.signing_data();
90        json_canon::to_string(&data)
91            .map(|s| s.into_bytes())
92            .map_err(|e| format!("canonicalization failed: {e}"))
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    /// The raw-seed `sign_action` → `verify_action_envelope` roundtrip the
101    /// Python bindings wrap, reproduced at the crate level: sign canonical bytes
102    /// with a raw Ed25519 seed and verify with the seed's RFC-8032 public key
103    /// after a JSON wire round-trip.
104    #[test]
105    fn raw_seed_action_sign_verify_roundtrip() {
106        use auths_crypto::{RingCryptoProvider, TypedSeed, typed_public_key, typed_sign};
107
108        let seed = [0xaau8; 32]; // Python test's TEST_SEED_HEX = "a" * 64
109        let ts = TypedSeed::Ed25519(seed);
110        let pk = typed_public_key(&ts).expect("derive Ed25519 public key from seed");
111
112        // sign_action
113        let mut env = ActionEnvelope {
114            version: "1.0".into(),
115            action_type: "tool_call".into(),
116            identity: "did:keri:ETest123".into(),
117            payload: serde_json::json!({"tool": "read_file", "path": "/etc/config.json"}),
118            timestamp: "2026-07-04T00:00:00Z".into(),
119            signature: String::new(),
120            attestation_chain: None,
121            environment: None,
122        };
123        let canonical = env.canonical_bytes().expect("canonical bytes at sign time");
124        let sig = typed_sign(&ts, &canonical).expect("sign canonical bytes");
125        env.signature = hex::encode(&sig);
126
127        // wire round-trip (serialize → reparse), as the envelope crosses the boundary
128        let wire = serde_json::to_string(&env).expect("serialize envelope");
129        let parsed: ActionEnvelope = serde_json::from_str(&wire).expect("reparse envelope");
130
131        // verify_action_envelope
132        let canonical2 = parsed
133            .canonical_bytes()
134            .expect("canonical bytes at verify time");
135        assert_eq!(
136            canonical, canonical2,
137            "canonical signing bytes changed across the JSON wire round-trip"
138        );
139        let sig2 = hex::decode(&parsed.signature).expect("decode signature hex");
140        RingCryptoProvider::ed25519_verify(&pk, &canonical2, &sig2)
141            .expect("raw-seed sign_action -> verify_action_envelope must round-trip");
142    }
143
144    #[test]
145    fn roundtrip_serialization() {
146        let envelope = ActionEnvelope {
147            version: "1.0".into(),
148            action_type: "sign_commit".into(),
149            identity: "did:keri:Eabc123".into(),
150            payload: serde_json::json!({"hash": "abc123"}),
151            timestamp: "2024-01-01T00:00:00Z".into(),
152            signature: "deadbeef".into(),
153            attestation_chain: None,
154            environment: None,
155        };
156
157        let json = serde_json::to_string(&envelope).unwrap();
158        let parsed: ActionEnvelope = serde_json::from_str(&json).unwrap();
159        assert_eq!(envelope, parsed);
160    }
161
162    #[test]
163    fn type_field_renamed_in_json() {
164        let envelope = ActionEnvelope {
165            version: "1.0".into(),
166            action_type: "sign_commit".into(),
167            identity: "did:keri:Eabc123".into(),
168            payload: serde_json::json!({}),
169            timestamp: "2024-01-01T00:00:00Z".into(),
170            signature: "deadbeef".into(),
171            attestation_chain: None,
172            environment: None,
173        };
174
175        let json = serde_json::to_string(&envelope).unwrap();
176        assert!(json.contains("\"type\":"));
177        assert!(!json.contains("\"action_type\":"));
178    }
179
180    #[test]
181    fn optional_fields_omitted_when_none() {
182        let envelope = ActionEnvelope {
183            version: "1.0".into(),
184            action_type: "sign_commit".into(),
185            identity: "did:keri:Eabc123".into(),
186            payload: serde_json::json!({}),
187            timestamp: "2024-01-01T00:00:00Z".into(),
188            signature: "deadbeef".into(),
189            attestation_chain: None,
190            environment: None,
191        };
192
193        let json = serde_json::to_string(&envelope).unwrap();
194        assert!(!json.contains("attestation_chain"));
195        assert!(!json.contains("environment"));
196    }
197
198    #[test]
199    fn wire_compat_with_python_sdk_format() {
200        let python_wire = serde_json::json!({
201            "version": "1.0",
202            "type": "sign_commit",
203            "identity": "did:keri:Eabc123",
204            "payload": {"hash": "abc123"},
205            "timestamp": "2024-01-01T00:00:00Z",
206            "signature": "deadbeef"
207        });
208
209        let envelope: ActionEnvelope = serde_json::from_value(python_wire.clone()).unwrap();
210        assert_eq!(envelope.version, "1.0");
211        assert_eq!(envelope.action_type, "sign_commit");
212
213        let reserialized: serde_json::Value =
214            serde_json::from_str(&serde_json::to_string(&envelope).unwrap()).unwrap();
215        assert_eq!(python_wire, reserialized);
216    }
217
218    #[test]
219    fn canonical_bytes_excludes_signature() {
220        let envelope = ActionEnvelope {
221            version: "1.0".into(),
222            action_type: "sign_commit".into(),
223            identity: "did:keri:Eabc123".into(),
224            payload: serde_json::json!({"hash": "abc123"}),
225            timestamp: "2024-01-01T00:00:00Z".into(),
226            signature: "different_sig".into(),
227            attestation_chain: Some(serde_json::json!([])),
228            environment: Some(serde_json::json!({"region": "us-east-1"})),
229        };
230
231        let canonical = String::from_utf8(envelope.canonical_bytes().unwrap()).unwrap();
232        assert!(!canonical.contains("signature"));
233        assert!(!canonical.contains("attestation_chain"));
234        assert!(!canonical.contains("environment"));
235        assert!(canonical.contains("\"version\""));
236        assert!(canonical.contains("\"type\""));
237    }
238}