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