1use std::collections::HashMap;
5
6use ed25519_dalek::VerifyingKey;
7
8use crate::artifacts::types::{
9 ApprovalDecision, BudgetUpdate, CommandType, KillCommand, MandateUpdate, TerminateSession,
10 TYPE_APPROVAL_DECISION, TYPE_BUDGET_UPDATE, TYPE_KILL_COMMAND, TYPE_MANDATE_UPDATE,
11 TYPE_TERMINATE_SESSION,
12};
13use crate::attestation::{Envelope, Verifier, VerifyError};
14
15#[derive(Debug)]
17pub struct VerifyCommandResult {
18 pub command: CommandType,
20 pub artifact_id: String,
22 pub verified_key_ids: Vec<String>,
24}
25
26#[derive(Debug)]
27pub enum VerifyCommandError {
28 UnknownPayloadType(String),
30 Signature(VerifyError),
32 PayloadParse(String),
34}
35
36impl std::fmt::Display for VerifyCommandError {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 match self {
39 Self::UnknownPayloadType(t) => {
40 write!(
41 f,
42 "not a command artifact: payloadType '{}' is not a known command",
43 t
44 )
45 }
46 Self::Signature(e) => write!(f, "command signature: {}", e),
47 Self::PayloadParse(e) => write!(f, "command payload parse: {}", e),
48 }
49 }
50}
51
52impl std::error::Error for VerifyCommandError {}
53
54pub fn verify_command(
64 envelope: &Envelope,
65 authorized_keys: &HashMap<String, VerifyingKey>,
66) -> Result<VerifyCommandResult, VerifyCommandError> {
67 if !is_known_command_type(&envelope.payload_type) {
68 return Err(VerifyCommandError::UnknownPayloadType(
69 envelope.payload_type.clone(),
70 ));
71 }
72
73 let verifier = Verifier::new(authorized_keys.clone());
74 let result = verifier
75 .verify_any(envelope)
76 .map_err(VerifyCommandError::Signature)?;
77
78 let command = match envelope.payload_type.as_str() {
79 TYPE_KILL_COMMAND => CommandType::Kill(
80 envelope
81 .unmarshal_statement::<KillCommand>()
82 .map_err(|e| VerifyCommandError::PayloadParse(e.to_string()))?,
83 ),
84 TYPE_APPROVAL_DECISION => CommandType::ApprovalDecision(
85 envelope
86 .unmarshal_statement::<ApprovalDecision>()
87 .map_err(|e| VerifyCommandError::PayloadParse(e.to_string()))?,
88 ),
89 TYPE_MANDATE_UPDATE => CommandType::MandateUpdate(
90 envelope
91 .unmarshal_statement::<MandateUpdate>()
92 .map_err(|e| VerifyCommandError::PayloadParse(e.to_string()))?,
93 ),
94 TYPE_BUDGET_UPDATE => CommandType::BudgetUpdate(
95 envelope
96 .unmarshal_statement::<BudgetUpdate>()
97 .map_err(|e| VerifyCommandError::PayloadParse(e.to_string()))?,
98 ),
99 TYPE_TERMINATE_SESSION => CommandType::TerminateSession(
100 envelope
101 .unmarshal_statement::<TerminateSession>()
102 .map_err(|e| VerifyCommandError::PayloadParse(e.to_string()))?,
103 ),
104 other => return Err(VerifyCommandError::UnknownPayloadType(other.into())),
106 };
107
108 Ok(VerifyCommandResult {
109 command,
110 artifact_id: result.artifact_id,
111 verified_key_ids: result.verified_key_ids,
112 })
113}
114
115fn is_known_command_type(pt: &str) -> bool {
116 matches!(
117 pt,
118 TYPE_KILL_COMMAND
119 | TYPE_APPROVAL_DECISION
120 | TYPE_MANDATE_UPDATE
121 | TYPE_BUDGET_UPDATE
122 | TYPE_TERMINATE_SESSION
123 )
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use crate::artifacts::types::Decision;
130 use crate::attestation::{sign, Ed25519Signer, Signer};
131
132 fn signer(id: &str) -> Ed25519Signer {
133 Ed25519Signer::generate(id).unwrap()
134 }
135
136 fn keys(signers: &[&Ed25519Signer]) -> HashMap<String, VerifyingKey> {
137 let mut m = HashMap::new();
138 for s in signers {
139 m.insert(s.key_id().to_string(), s.verifying_key());
140 }
141 m
142 }
143
144 #[test]
145 fn verify_kill_command_round_trip() {
146 let s = signer("issuer_1");
147 let payload = KillCommand {
148 session_id: "ssn_abc".into(),
149 reason: "policy violation".into(),
150 issued_at: "2026-04-18T10:00:00Z".into(),
151 };
152 let signed = sign(TYPE_KILL_COMMAND, &payload, &s).unwrap();
153 let result = verify_command(&signed.envelope, &keys(&[&s])).unwrap();
154 assert_eq!(result.command.kind(), "kill");
155 match result.command {
156 CommandType::Kill(k) => assert_eq!(k, payload),
157 other => panic!("expected Kill, got {:?}", other),
158 }
159 assert_eq!(result.verified_key_ids, vec!["issuer_1"]);
160 assert!(result.artifact_id.starts_with("art_"));
161 }
162
163 #[test]
164 fn verify_approval_decision_round_trip() {
165 let s = signer("approver_1");
166 let payload = ApprovalDecision {
167 approval_artifact_id: "art_pending".into(),
168 decision: Decision::Approve,
169 reason: Some("looks safe".into()),
170 decided_at: "2026-04-18T10:01:00Z".into(),
171 };
172 let signed = sign(TYPE_APPROVAL_DECISION, &payload, &s).unwrap();
173 let result = verify_command(&signed.envelope, &keys(&[&s])).unwrap();
174 match result.command {
175 CommandType::ApprovalDecision(d) => assert_eq!(d, payload),
176 other => panic!("expected ApprovalDecision, got {:?}", other),
177 }
178 }
179
180 #[test]
181 fn verify_mandate_and_budget_and_terminate() {
182 let s = signer("issuer_1");
183 let trusted = keys(&[&s]);
184
185 let mandate = MandateUpdate {
186 ship_id: "ship_demo".into(),
187 new_bounded_actions: vec!["Bash".into()],
188 new_forbidden: vec!["DropDatabase".into()],
189 valid_until: Some("2026-12-31T00:00:00Z".into()),
190 };
191 let env = sign(TYPE_MANDATE_UPDATE, &mandate, &s).unwrap().envelope;
192 assert!(matches!(
193 verify_command(&env, &trusted).unwrap().command,
194 CommandType::MandateUpdate(_)
195 ));
196
197 let budget = BudgetUpdate {
198 ship_id: "ship_demo".into(),
199 token_limit_delta: -50_000,
200 valid_until: None,
201 };
202 let env = sign(TYPE_BUDGET_UPDATE, &budget, &s).unwrap().envelope;
203 assert!(matches!(
204 verify_command(&env, &trusted).unwrap().command,
205 CommandType::BudgetUpdate(_)
206 ));
207
208 let term = TerminateSession {
209 session_id: "ssn_abc".into(),
210 reason: "user requested".into(),
211 requested_at: "2026-04-18T11:00:00Z".into(),
212 };
213 let env = sign(TYPE_TERMINATE_SESSION, &term, &s).unwrap().envelope;
214 assert!(matches!(
215 verify_command(&env, &trusted).unwrap().command,
216 CommandType::TerminateSession(_)
217 ));
218 }
219
220 #[test]
221 fn unauthorized_signer_rejected() {
222 let issuer = signer("rogue_issuer");
224 let trusted = keys(&[&signer("real_issuer")]);
225 let payload = KillCommand {
226 session_id: "ssn_abc".into(),
227 reason: "evil".into(),
228 issued_at: "2026-04-18T10:00:00Z".into(),
229 };
230 let signed = sign(TYPE_KILL_COMMAND, &payload, &issuer).unwrap();
231 let err = verify_command(&signed.envelope, &trusted).unwrap_err();
232 assert!(
233 matches!(err, VerifyCommandError::Signature(_)),
234 "expected Signature error for unauthorized signer, got: {err}"
235 );
236 }
237
238 #[test]
239 fn non_command_payload_type_rejected() {
240 let s = signer("issuer_1");
241 let signed = sign(
243 "application/vnd.treeship.action.v1+json",
244 &KillCommand {
245 session_id: "ssn".into(),
246 reason: "x".into(),
247 issued_at: "2026-04-18T10:00:00Z".into(),
248 },
249 &s,
250 )
251 .unwrap();
252 let err = verify_command(&signed.envelope, &keys(&[&s])).unwrap_err();
253 assert!(matches!(err, VerifyCommandError::UnknownPayloadType(_)));
254 }
255
256 #[test]
257 fn tampered_command_payload_rejected() {
258 let s = signer("issuer_1");
259 let payload = KillCommand {
260 session_id: "ssn_a".into(),
261 reason: "x".into(),
262 issued_at: "2026-04-18T10:00:00Z".into(),
263 };
264 let mut signed = sign(TYPE_KILL_COMMAND, &payload, &s).unwrap();
265 let evil = KillCommand {
268 session_id: "ssn_other".into(),
269 reason: "evil".into(),
270 issued_at: "2026-04-18T10:00:00Z".into(),
271 };
272 let evil_bytes = serde_json::to_vec(&evil).unwrap();
273 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
274 signed.envelope.payload = URL_SAFE_NO_PAD.encode(evil_bytes);
275 let err = verify_command(&signed.envelope, &keys(&[&s])).unwrap_err();
276 assert!(matches!(err, VerifyCommandError::Signature(_)));
277 }
278
279 #[test]
280 fn malformed_payload_rejected_after_valid_signature() {
281 let s = signer("issuer_1");
284 #[derive(serde::Serialize)]
285 struct Garbage {
286 not_a_kill_field: u32,
287 }
288 let signed = sign(
289 TYPE_KILL_COMMAND,
290 &Garbage {
291 not_a_kill_field: 7,
292 },
293 &s,
294 )
295 .unwrap();
296 let err = verify_command(&signed.envelope, &keys(&[&s])).unwrap_err();
297 assert!(
298 matches!(err, VerifyCommandError::PayloadParse(_)),
299 "expected PayloadParse, got: {err}"
300 );
301 }
302}