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, VerifyError, Verifier};
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!(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
50/// Verify a command artifact envelope.
51///
52/// `authorized_keys` is a (key_id → VerifyingKey) map of issuers permitted to
53/// send commands to this ship. Any signature on the envelope from a key
54/// outside this set is ignored; if no in-set key produced a valid signature,
55/// verification fails. This mirrors the semantics of `Verifier::verify_any`.
56///
57/// On success returns the deserialized command, its content-addressed
58/// artifact ID, and the key IDs that produced valid signatures.
59pub 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        // Filtered out by is_known_command_type above.
101        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        // Issuer not in the trusted key set.
210        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        // Sign with the action payload type instead of a command type.
227        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        // Replace the payload bytes with a different command. PAE was over
251        // the original payload so signature verification must fail.
252        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        // Sign garbage bytes under the kill payload type. Signature check
267        // passes, payload parse fails.
268        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}