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    #[test]
101    fn roundtrip_serialization() {
102        let envelope = ActionEnvelope {
103            version: "1.0".into(),
104            action_type: "sign_commit".into(),
105            identity: "did:keri:Eabc123".into(),
106            payload: serde_json::json!({"hash": "abc123"}),
107            timestamp: "2024-01-01T00:00:00Z".into(),
108            signature: "deadbeef".into(),
109            attestation_chain: None,
110            environment: None,
111        };
112
113        let json = serde_json::to_string(&envelope).unwrap();
114        let parsed: ActionEnvelope = serde_json::from_str(&json).unwrap();
115        assert_eq!(envelope, parsed);
116    }
117
118    #[test]
119    fn type_field_renamed_in_json() {
120        let envelope = ActionEnvelope {
121            version: "1.0".into(),
122            action_type: "sign_commit".into(),
123            identity: "did:keri:Eabc123".into(),
124            payload: serde_json::json!({}),
125            timestamp: "2024-01-01T00:00:00Z".into(),
126            signature: "deadbeef".into(),
127            attestation_chain: None,
128            environment: None,
129        };
130
131        let json = serde_json::to_string(&envelope).unwrap();
132        assert!(json.contains("\"type\":"));
133        assert!(!json.contains("\"action_type\":"));
134    }
135
136    #[test]
137    fn optional_fields_omitted_when_none() {
138        let envelope = ActionEnvelope {
139            version: "1.0".into(),
140            action_type: "sign_commit".into(),
141            identity: "did:keri:Eabc123".into(),
142            payload: serde_json::json!({}),
143            timestamp: "2024-01-01T00:00:00Z".into(),
144            signature: "deadbeef".into(),
145            attestation_chain: None,
146            environment: None,
147        };
148
149        let json = serde_json::to_string(&envelope).unwrap();
150        assert!(!json.contains("attestation_chain"));
151        assert!(!json.contains("environment"));
152    }
153
154    #[test]
155    fn wire_compat_with_python_sdk_format() {
156        let python_wire = serde_json::json!({
157            "version": "1.0",
158            "type": "sign_commit",
159            "identity": "did:keri:Eabc123",
160            "payload": {"hash": "abc123"},
161            "timestamp": "2024-01-01T00:00:00Z",
162            "signature": "deadbeef"
163        });
164
165        let envelope: ActionEnvelope = serde_json::from_value(python_wire.clone()).unwrap();
166        assert_eq!(envelope.version, "1.0");
167        assert_eq!(envelope.action_type, "sign_commit");
168
169        let reserialized: serde_json::Value =
170            serde_json::from_str(&serde_json::to_string(&envelope).unwrap()).unwrap();
171        assert_eq!(python_wire, reserialized);
172    }
173
174    #[test]
175    fn canonical_bytes_excludes_signature() {
176        let envelope = ActionEnvelope {
177            version: "1.0".into(),
178            action_type: "sign_commit".into(),
179            identity: "did:keri:Eabc123".into(),
180            payload: serde_json::json!({"hash": "abc123"}),
181            timestamp: "2024-01-01T00:00:00Z".into(),
182            signature: "different_sig".into(),
183            attestation_chain: Some(serde_json::json!([])),
184            environment: Some(serde_json::json!({"region": "us-east-1"})),
185        };
186
187        let canonical = String::from_utf8(envelope.canonical_bytes().unwrap()).unwrap();
188        assert!(!canonical.contains("signature"));
189        assert!(!canonical.contains("attestation_chain"));
190        assert!(!canonical.contains("environment"));
191        assert!(canonical.contains("\"version\""));
192        assert!(canonical.contains("\"type\""));
193    }
194}