use std::collections::HashMap;
use std::sync::Mutex;
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::node_registry::node_id_from_pubkey;
use crate::workload_pkg::codec::{hex_decode_exact, hex_encode};
pub const WORKLOAD_HOST_SERVICE: &str = "workload-host";
pub const CTL_VERSION: u32 = 1;
pub const REQUEST_DOMAIN: &[u8] = b"weftos.workload_ctl.request.v1\0";
pub const RESPONSE_DOMAIN: &[u8] = b"weftos.workload_ctl.response.v1\0";
pub const MAX_TTL_MS: u64 = 300_000;
pub const MAX_SKEW_MS: u64 = 30_000;
pub const MAX_CTL_BYTES: usize = 256 * 1024;
pub const MAX_NONCES: usize = 8192;
pub const ANY_TARGET: &str = "*";
pub mod method {
pub const DESCRIBE: &str = "workload.describe";
pub const PLACE: &str = "workload.place";
pub const LOAD: &str = "workload.load";
pub const START: &str = "workload.start";
pub const STOP: &str = "workload.stop";
pub const UNLOAD: &str = "workload.unload";
pub const STATUS: &str = "workload.status";
pub const LOGS: &str = "workload.logs";
pub const ALL: &[&str] = &[DESCRIBE, PLACE, LOAD, START, STOP, UNLOAD, STATUS, LOGS];
pub const DASHBOARD_STATUS: &str = "dashboard.status";
pub const DASHBOARD_ROTATE: &str = "dashboard.token.rotate";
pub const NODE_ADMIN: &[&str] = &[DASHBOARD_STATUS, DASHBOARD_ROTATE];
pub fn is_node_admin(m: &str) -> bool {
NODE_ADMIN.contains(&m)
}
pub fn is_teardown(m: &str) -> bool {
matches!(m, STOP | UNLOAD)
}
pub fn mutates(m: &str) -> bool {
matches!(m, PLACE | LOAD | START | STOP | UNLOAD | DASHBOARD_ROTATE)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CtlRequest {
pub version: u32,
pub method: String,
pub requester: String,
pub target: String,
pub nonce: String,
pub issued_at_ms: u64,
pub expires_at_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub decision_id: Option<String>,
#[serde(default)]
pub body: Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SignedCtl {
pub payload: String,
pub public_key: String,
pub signature: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RefusalCode {
Signature,
Expired,
Replay,
NotForMe,
Unauthorized,
UnknownMethod,
InvalidRequest,
Governance,
Admission,
Fetch,
Verify,
Runtime,
UnknownInstance,
}
impl RefusalCode {
pub fn as_str(self) -> &'static str {
match self {
Self::Signature => "signature",
Self::Expired => "expired",
Self::Replay => "replay",
Self::NotForMe => "not_for_me",
Self::Unauthorized => "unauthorized",
Self::UnknownMethod => "unknown_method",
Self::InvalidRequest => "invalid_request",
Self::Governance => "governance",
Self::Admission => "admission",
Self::Fetch => "fetch",
Self::Verify => "verify",
Self::Runtime => "runtime",
Self::UnknownInstance => "unknown_instance",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[error("{} refused: {reason}", code.as_str())]
pub struct Refusal {
pub code: RefusalCode,
pub reason: String,
}
impl Refusal {
pub fn new(code: RefusalCode, reason: impl Into<String>) -> Self {
Self {
code,
reason: reason.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum CtlOutcome {
Ok {
result: Value,
},
Refused {
refusal: Refusal,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CtlResponse {
pub version: u32,
pub method: String,
pub responder: String,
pub request_nonce: String,
pub outcome: CtlOutcome,
}
pub(crate) fn signed_bytes(domain: &[u8], payload: &str) -> Vec<u8> {
let mut v = Vec::with_capacity(domain.len() + payload.len());
v.extend_from_slice(domain);
v.extend_from_slice(payload.as_bytes());
v
}
pub(crate) fn sign(domain: &[u8], payload: String, key: &SigningKey) -> SignedCtl {
let sig = key.sign(&signed_bytes(domain, &payload));
SignedCtl {
payload,
public_key: hex_encode(&key.verifying_key().to_bytes()),
signature: hex_encode(&sig.to_bytes()),
}
}
pub(crate) fn open(domain: &[u8], s: &SignedCtl) -> Result<[u8; 32], Refusal> {
let bad = |m: &str| Refusal::new(RefusalCode::Signature, m);
if s.payload.len() > MAX_CTL_BYTES {
return Err(bad("payload too large"));
}
let pk = hex_decode_exact::<32>(&s.public_key).ok_or_else(|| bad("bad public key"))?;
let sig = hex_decode_exact::<64>(&s.signature).ok_or_else(|| bad("bad signature encoding"))?;
let vk = VerifyingKey::from_bytes(&pk).map_err(|_| bad("bad public key"))?;
vk.verify(
&signed_bytes(domain, &s.payload),
&Signature::from_bytes(&sig),
)
.map_err(|_| bad("signature does not verify"))?;
Ok(pk)
}
pub fn fresh_nonce() -> String {
let b: [u8; 16] = rand::random();
hex_encode(&b)
}
impl CtlRequest {
pub fn new(
key: &SigningKey,
method: &str,
target: &str,
now_ms: u64,
ttl_ms: u64,
decision_id: Option<String>,
body: Value,
) -> Self {
Self {
version: CTL_VERSION,
method: method.to_string(),
requester: node_id_from_pubkey(&key.verifying_key().to_bytes()),
target: target.to_string(),
nonce: fresh_nonce(),
issued_at_ms: now_ms,
expires_at_ms: now_ms.saturating_add(ttl_ms),
decision_id,
body,
}
}
pub fn sign(&self, key: &SigningKey) -> Result<SignedCtl, Refusal> {
let payload = serde_json::to_string(self)
.map_err(|e| Refusal::new(RefusalCode::InvalidRequest, e.to_string()))?;
Ok(sign(REQUEST_DOMAIN, payload, key))
}
}
impl CtlResponse {
pub fn sign(&self, key: &SigningKey) -> SignedCtl {
let payload = serde_json::to_string(self).unwrap_or_else(|_| "{}".into());
sign(RESPONSE_DOMAIN, payload, key)
}
}
#[derive(Debug, Default)]
pub struct NonceGuard {
seen: Mutex<HashMap<String, u64>>,
}
impl NonceGuard {
pub fn new() -> Self {
Self::default()
}
pub fn admit(&self, nonce: &str, expires_ms: u64, now_ms: u64) -> bool {
let Ok(mut seen) = self.seen.lock() else {
return false;
};
if seen.contains_key(nonce) {
return false;
}
if seen.len() >= MAX_NONCES {
seen.retain(|_, exp| *exp > now_ms);
if seen.len() >= MAX_NONCES {
return false;
}
}
seen.insert(nonce.to_string(), expires_ms);
true
}
}
pub trait ControllerPolicy: Send + Sync {
fn allows(&self, public_key: &[u8; 32]) -> bool;
}
impl ControllerPolicy for Vec<[u8; 32]> {
fn allows(&self, public_key: &[u8; 32]) -> bool {
self.contains(public_key)
}
}
pub fn verify_request(
s: &SignedCtl,
me: &str,
now_ms: u64,
controllers: &dyn ControllerPolicy,
nonces: &NonceGuard,
) -> Result<CtlRequest, Refusal> {
let pk = open(REQUEST_DOMAIN, s)?;
let req: CtlRequest = serde_json::from_str(&s.payload)
.map_err(|e| Refusal::new(RefusalCode::Signature, format!("malformed request: {e}")))?;
if req.version != CTL_VERSION {
return Err(Refusal::new(
RefusalCode::InvalidRequest,
"unsupported version",
));
}
let derived = node_id_from_pubkey(&pk);
if req.requester != derived {
return Err(Refusal::new(
RefusalCode::Signature,
format!("requester {} is not the signing key's node", req.requester),
));
}
let any_ok = req.target == ANY_TARGET && req.method == method::DESCRIBE;
if req.target != me && !any_ok {
return Err(Refusal::new(
RefusalCode::NotForMe,
format!("addressed to {}", req.target),
));
}
if req.expires_at_ms <= now_ms {
return Err(Refusal::new(RefusalCode::Expired, "request expired"));
}
if req.issued_at_ms > now_ms.saturating_add(MAX_SKEW_MS) {
return Err(Refusal::new(
RefusalCode::Expired,
"request issued in the future",
));
}
if req.expires_at_ms.saturating_sub(req.issued_at_ms) > MAX_TTL_MS
|| req.expires_at_ms < req.issued_at_ms
{
return Err(Refusal::new(
RefusalCode::Expired,
"request lifetime out of bounds",
));
}
if !crate::workload_pkg::codec::is_lower_hex(&req.nonce, 32) {
return Err(Refusal::new(
RefusalCode::InvalidRequest,
"nonce must be 32 hex",
));
}
if !controllers.allows(&pk) {
return Err(Refusal::new(
RefusalCode::Unauthorized,
format!("{derived} is not an authorised controller of this node"),
));
}
if !nonces.admit(&req.nonce, req.expires_at_ms, now_ms) {
return Err(Refusal::new(RefusalCode::Replay, "nonce already used"));
}
Ok(req)
}
pub fn verify_response(
s: &SignedCtl,
request: &CtlRequest,
expected: Option<&[u8; 32]>,
) -> Result<(CtlResponse, [u8; 32]), Refusal> {
let pk = open(RESPONSE_DOMAIN, s)?;
if expected.is_some_and(|e| e != &pk) {
return Err(Refusal::new(
RefusalCode::Signature,
"response signed by another key",
));
}
let resp: CtlResponse = serde_json::from_str(&s.payload)
.map_err(|e| Refusal::new(RefusalCode::Signature, format!("malformed response: {e}")))?;
if resp.responder != node_id_from_pubkey(&pk) {
return Err(Refusal::new(
RefusalCode::Signature,
"responder is not the signing key",
));
}
if resp.request_nonce != request.nonce || resp.method != request.method {
return Err(Refusal::new(
RefusalCode::Replay,
"response answers another request",
));
}
if request.target != ANY_TARGET && resp.responder != request.target {
return Err(Refusal::new(
RefusalCode::NotForMe,
"response from another node",
));
}
Ok((resp, pk))
}
#[cfg(test)]
#[path = "tests_msg.rs"]
mod tests;