use std::collections::HashMap;
use ed25519_dalek::VerifyingKey;
use crate::artifacts::types::{
ApprovalDecision, BudgetUpdate, CommandType, KillCommand, MandateUpdate, TerminateSession,
TYPE_APPROVAL_DECISION, TYPE_BUDGET_UPDATE, TYPE_KILL_COMMAND, TYPE_MANDATE_UPDATE,
TYPE_TERMINATE_SESSION,
};
use crate::attestation::{Envelope, VerifyError, Verifier};
#[derive(Debug)]
pub struct VerifyCommandResult {
pub command: CommandType,
pub artifact_id: String,
pub verified_key_ids: Vec<String>,
}
#[derive(Debug)]
pub enum VerifyCommandError {
UnknownPayloadType(String),
Signature(VerifyError),
PayloadParse(String),
}
impl std::fmt::Display for VerifyCommandError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownPayloadType(t) => {
write!(f, "not a command artifact: payloadType '{}' is not a known command", t)
}
Self::Signature(e) => write!(f, "command signature: {}", e),
Self::PayloadParse(e) => write!(f, "command payload parse: {}", e),
}
}
}
impl std::error::Error for VerifyCommandError {}
pub fn verify_command(
envelope: &Envelope,
authorized_keys: &HashMap<String, VerifyingKey>,
) -> Result<VerifyCommandResult, VerifyCommandError> {
if !is_known_command_type(&envelope.payload_type) {
return Err(VerifyCommandError::UnknownPayloadType(
envelope.payload_type.clone(),
));
}
let verifier = Verifier::new(authorized_keys.clone());
let result = verifier
.verify_any(envelope)
.map_err(VerifyCommandError::Signature)?;
let command = match envelope.payload_type.as_str() {
TYPE_KILL_COMMAND => CommandType::Kill(
envelope
.unmarshal_statement::<KillCommand>()
.map_err(|e| VerifyCommandError::PayloadParse(e.to_string()))?,
),
TYPE_APPROVAL_DECISION => CommandType::ApprovalDecision(
envelope
.unmarshal_statement::<ApprovalDecision>()
.map_err(|e| VerifyCommandError::PayloadParse(e.to_string()))?,
),
TYPE_MANDATE_UPDATE => CommandType::MandateUpdate(
envelope
.unmarshal_statement::<MandateUpdate>()
.map_err(|e| VerifyCommandError::PayloadParse(e.to_string()))?,
),
TYPE_BUDGET_UPDATE => CommandType::BudgetUpdate(
envelope
.unmarshal_statement::<BudgetUpdate>()
.map_err(|e| VerifyCommandError::PayloadParse(e.to_string()))?,
),
TYPE_TERMINATE_SESSION => CommandType::TerminateSession(
envelope
.unmarshal_statement::<TerminateSession>()
.map_err(|e| VerifyCommandError::PayloadParse(e.to_string()))?,
),
other => return Err(VerifyCommandError::UnknownPayloadType(other.into())),
};
Ok(VerifyCommandResult {
command,
artifact_id: result.artifact_id,
verified_key_ids: result.verified_key_ids,
})
}
fn is_known_command_type(pt: &str) -> bool {
matches!(
pt,
TYPE_KILL_COMMAND
| TYPE_APPROVAL_DECISION
| TYPE_MANDATE_UPDATE
| TYPE_BUDGET_UPDATE
| TYPE_TERMINATE_SESSION
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::artifacts::types::Decision;
use crate::attestation::{sign, Ed25519Signer, Signer};
fn signer(id: &str) -> Ed25519Signer {
Ed25519Signer::generate(id).unwrap()
}
fn keys(signers: &[&Ed25519Signer]) -> HashMap<String, VerifyingKey> {
let mut m = HashMap::new();
for s in signers {
m.insert(s.key_id().to_string(), s.verifying_key());
}
m
}
#[test]
fn verify_kill_command_round_trip() {
let s = signer("issuer_1");
let payload = KillCommand {
session_id: "ssn_abc".into(),
reason: "policy violation".into(),
issued_at: "2026-04-18T10:00:00Z".into(),
};
let signed = sign(TYPE_KILL_COMMAND, &payload, &s).unwrap();
let result = verify_command(&signed.envelope, &keys(&[&s])).unwrap();
assert_eq!(result.command.kind(), "kill");
match result.command {
CommandType::Kill(k) => assert_eq!(k, payload),
other => panic!("expected Kill, got {:?}", other),
}
assert_eq!(result.verified_key_ids, vec!["issuer_1"]);
assert!(result.artifact_id.starts_with("art_"));
}
#[test]
fn verify_approval_decision_round_trip() {
let s = signer("approver_1");
let payload = ApprovalDecision {
approval_artifact_id: "art_pending".into(),
decision: Decision::Approve,
reason: Some("looks safe".into()),
decided_at: "2026-04-18T10:01:00Z".into(),
};
let signed = sign(TYPE_APPROVAL_DECISION, &payload, &s).unwrap();
let result = verify_command(&signed.envelope, &keys(&[&s])).unwrap();
match result.command {
CommandType::ApprovalDecision(d) => assert_eq!(d, payload),
other => panic!("expected ApprovalDecision, got {:?}", other),
}
}
#[test]
fn verify_mandate_and_budget_and_terminate() {
let s = signer("issuer_1");
let trusted = keys(&[&s]);
let mandate = MandateUpdate {
ship_id: "ship_demo".into(),
new_bounded_actions: vec!["Bash".into()],
new_forbidden: vec!["DropDatabase".into()],
valid_until: Some("2026-12-31T00:00:00Z".into()),
};
let env = sign(TYPE_MANDATE_UPDATE, &mandate, &s).unwrap().envelope;
assert!(matches!(verify_command(&env, &trusted).unwrap().command, CommandType::MandateUpdate(_)));
let budget = BudgetUpdate {
ship_id: "ship_demo".into(),
token_limit_delta: -50_000,
valid_until: None,
};
let env = sign(TYPE_BUDGET_UPDATE, &budget, &s).unwrap().envelope;
assert!(matches!(verify_command(&env, &trusted).unwrap().command, CommandType::BudgetUpdate(_)));
let term = TerminateSession {
session_id: "ssn_abc".into(),
reason: "user requested".into(),
requested_at: "2026-04-18T11:00:00Z".into(),
};
let env = sign(TYPE_TERMINATE_SESSION, &term, &s).unwrap().envelope;
assert!(matches!(verify_command(&env, &trusted).unwrap().command, CommandType::TerminateSession(_)));
}
#[test]
fn unauthorized_signer_rejected() {
let issuer = signer("rogue_issuer");
let trusted = keys(&[&signer("real_issuer")]);
let payload = KillCommand {
session_id: "ssn_abc".into(),
reason: "evil".into(),
issued_at: "2026-04-18T10:00:00Z".into(),
};
let signed = sign(TYPE_KILL_COMMAND, &payload, &issuer).unwrap();
let err = verify_command(&signed.envelope, &trusted).unwrap_err();
assert!(matches!(err, VerifyCommandError::Signature(_)),
"expected Signature error for unauthorized signer, got: {err}");
}
#[test]
fn non_command_payload_type_rejected() {
let s = signer("issuer_1");
let signed = sign(
"application/vnd.treeship.action.v1+json",
&KillCommand {
session_id: "ssn".into(),
reason: "x".into(),
issued_at: "2026-04-18T10:00:00Z".into(),
},
&s,
)
.unwrap();
let err = verify_command(&signed.envelope, &keys(&[&s])).unwrap_err();
assert!(matches!(err, VerifyCommandError::UnknownPayloadType(_)));
}
#[test]
fn tampered_command_payload_rejected() {
let s = signer("issuer_1");
let payload = KillCommand {
session_id: "ssn_a".into(),
reason: "x".into(),
issued_at: "2026-04-18T10:00:00Z".into(),
};
let mut signed = sign(TYPE_KILL_COMMAND, &payload, &s).unwrap();
let evil = KillCommand {
session_id: "ssn_other".into(),
reason: "evil".into(),
issued_at: "2026-04-18T10:00:00Z".into(),
};
let evil_bytes = serde_json::to_vec(&evil).unwrap();
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
signed.envelope.payload = URL_SAFE_NO_PAD.encode(evil_bytes);
let err = verify_command(&signed.envelope, &keys(&[&s])).unwrap_err();
assert!(matches!(err, VerifyCommandError::Signature(_)));
}
#[test]
fn malformed_payload_rejected_after_valid_signature() {
let s = signer("issuer_1");
#[derive(serde::Serialize)]
struct Garbage { not_a_kill_field: u32 }
let signed = sign(TYPE_KILL_COMMAND, &Garbage { not_a_kill_field: 7 }, &s).unwrap();
let err = verify_command(&signed.envelope, &keys(&[&s])).unwrap_err();
assert!(matches!(err, VerifyCommandError::PayloadParse(_)),
"expected PayloadParse, got: {err}");
}
}