1use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct ActionEnvelope {
36 pub version: String,
38 #[serde(rename = "type")]
40 pub action_type: String,
41 pub identity: String,
43 pub payload: Value,
45 pub timestamp: String,
47 pub signature: String,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub attestation_chain: Option<Value>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub environment: Option<Value>,
55}
56
57#[derive(Debug, Serialize)]
61pub struct ActionSigningData<'a> {
62 pub version: &'a str,
64 #[serde(rename = "type")]
66 pub action_type: &'a str,
67 pub identity: &'a str,
69 pub payload: &'a Value,
71 pub timestamp: &'a str,
73}
74
75impl ActionEnvelope {
76 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 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 #[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]; let ts = TypedSeed::Ed25519(seed);
110 let pk = typed_public_key(&ts).expect("derive Ed25519 public key from seed");
111
112 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 let wire = serde_json::to_string(&env).expect("serialize envelope");
129 let parsed: ActionEnvelope = serde_json::from_str(&wire).expect("reparse envelope");
130
131 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}