Skip to main content

treeship_core/artifacts/
verify.rs

1//! Verify a command artifact: signature against the provided authorized key
2//! set, then payload-type discriminated parse.
3
4use 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/// Returned on successful command verification.
16#[derive(Debug)]
17pub struct VerifyCommandResult {
18    /// The discriminated, deserialized command payload.
19    pub command: CommandType,
20    /// Content-addressed artifact ID re-derived during verification.
21    pub artifact_id: String,
22    /// Key IDs that signed this command (subset of the authorized set).
23    pub verified_key_ids: Vec<String>,
24}
25
26#[derive(Debug)]
27pub enum VerifyCommandError {
28    /// payloadType on the envelope is not a known command type.
29    UnknownPayloadType(String),
30    /// Signature verification against the authorized key set failed.
31    Signature(VerifyError),
32    /// Payload bytes did not deserialize into the expected struct.
33    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
54/// Verify a command artifact envelope.
55///
56/// `authorized_keys` is a (key_id → VerifyingKey) map of issuers permitted to
57/// send commands to this ship. Any signature on the envelope from a key
58/// outside this set is ignored; if no in-set key produced a valid signature,
59/// verification fails. This mirrors the semantics of `Verifier::verify_any`.
60///
61/// On success returns the deserialized command, its content-addressed
62/// artifact ID, and the key IDs that produced valid signatures.
63pub 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        // Filtered out by is_known_command_type above.
105        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        // Issuer not in the trusted key set.
223        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        // Sign with the action payload type instead of a command type.
242        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        // Replace the payload bytes with a different command. PAE was over
266        // the original payload so signature verification must fail.
267        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        // Sign garbage bytes under the kill payload type. Signature check
282        // passes, payload parse fails.
283        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}