1use std::collections::HashMap;
16use std::sync::Mutex;
17
18use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21
22use crate::node_registry::node_id_from_pubkey;
23use crate::workload_pkg::codec::{hex_decode_exact, hex_encode};
24
25pub const WORKLOAD_HOST_SERVICE: &str = "workload-host";
27pub const CTL_VERSION: u32 = 1;
29pub const REQUEST_DOMAIN: &[u8] = b"weftos.workload_ctl.request.v1\0";
31pub const RESPONSE_DOMAIN: &[u8] = b"weftos.workload_ctl.response.v1\0";
33pub const MAX_TTL_MS: u64 = 300_000;
35pub const MAX_SKEW_MS: u64 = 30_000;
37pub const MAX_CTL_BYTES: usize = 256 * 1024;
39pub const MAX_NONCES: usize = 8192;
41pub const ANY_TARGET: &str = "*";
43
44pub mod method {
46 pub const DESCRIBE: &str = "workload.describe";
48 pub const PLACE: &str = "workload.place";
50 pub const LOAD: &str = "workload.load";
52 pub const START: &str = "workload.start";
54 pub const STOP: &str = "workload.stop";
56 pub const UNLOAD: &str = "workload.unload";
58 pub const STATUS: &str = "workload.status";
60 pub const LOGS: &str = "workload.logs";
62 pub const ALL: &[&str] = &[DESCRIBE, PLACE, LOAD, START, STOP, UNLOAD, STATUS, LOGS];
64
65 pub const DASHBOARD_STATUS: &str = "dashboard.status";
69 pub const DASHBOARD_ROTATE: &str = "dashboard.token.rotate";
72 pub const NODE_ADMIN: &[&str] = &[DASHBOARD_STATUS, DASHBOARD_ROTATE];
75
76 pub fn is_node_admin(m: &str) -> bool {
78 NODE_ADMIN.contains(&m)
79 }
80
81 pub fn is_teardown(m: &str) -> bool {
85 matches!(m, STOP | UNLOAD)
86 }
87
88 pub fn mutates(m: &str) -> bool {
90 matches!(m, PLACE | LOAD | START | STOP | UNLOAD | DASHBOARD_ROTATE)
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct CtlRequest {
98 pub version: u32,
100 pub method: String,
102 pub requester: String,
104 pub target: String,
106 pub nonce: String,
108 pub issued_at_ms: u64,
110 pub expires_at_ms: u64,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub decision_id: Option<String>,
115 #[serde(default)]
117 pub body: Value,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct SignedCtl {
125 pub payload: String,
127 pub public_key: String,
129 pub signature: String,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136pub enum RefusalCode {
137 Signature,
139 Expired,
141 Replay,
143 NotForMe,
145 Unauthorized,
147 UnknownMethod,
149 InvalidRequest,
151 Governance,
153 Admission,
155 Fetch,
157 Verify,
159 Runtime,
161 UnknownInstance,
163}
164
165impl RefusalCode {
166 pub fn as_str(self) -> &'static str {
168 match self {
169 Self::Signature => "signature",
170 Self::Expired => "expired",
171 Self::Replay => "replay",
172 Self::NotForMe => "not_for_me",
173 Self::Unauthorized => "unauthorized",
174 Self::UnknownMethod => "unknown_method",
175 Self::InvalidRequest => "invalid_request",
176 Self::Governance => "governance",
177 Self::Admission => "admission",
178 Self::Fetch => "fetch",
179 Self::Verify => "verify",
180 Self::Runtime => "runtime",
181 Self::UnknownInstance => "unknown_instance",
182 }
183 }
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
188#[error("{} refused: {reason}", code.as_str())]
189pub struct Refusal {
190 pub code: RefusalCode,
192 pub reason: String,
194}
195
196impl Refusal {
197 pub fn new(code: RefusalCode, reason: impl Into<String>) -> Self {
199 Self {
200 code,
201 reason: reason.into(),
202 }
203 }
204}
205
206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
208#[serde(tag = "outcome", rename_all = "snake_case")]
209pub enum CtlOutcome {
210 Ok {
212 result: Value,
214 },
215 Refused {
217 refusal: Refusal,
219 },
220}
221
222#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
224#[serde(deny_unknown_fields)]
225pub struct CtlResponse {
226 pub version: u32,
228 pub method: String,
230 pub responder: String,
232 pub request_nonce: String,
234 pub outcome: CtlOutcome,
236}
237
238pub(crate) fn signed_bytes(domain: &[u8], payload: &str) -> Vec<u8> {
239 let mut v = Vec::with_capacity(domain.len() + payload.len());
240 v.extend_from_slice(domain);
241 v.extend_from_slice(payload.as_bytes());
242 v
243}
244
245pub(crate) fn sign(domain: &[u8], payload: String, key: &SigningKey) -> SignedCtl {
246 let sig = key.sign(&signed_bytes(domain, &payload));
247 SignedCtl {
248 payload,
249 public_key: hex_encode(&key.verifying_key().to_bytes()),
250 signature: hex_encode(&sig.to_bytes()),
251 }
252}
253
254pub(crate) fn open(domain: &[u8], s: &SignedCtl) -> Result<[u8; 32], Refusal> {
256 let bad = |m: &str| Refusal::new(RefusalCode::Signature, m);
257 if s.payload.len() > MAX_CTL_BYTES {
258 return Err(bad("payload too large"));
259 }
260 let pk = hex_decode_exact::<32>(&s.public_key).ok_or_else(|| bad("bad public key"))?;
261 let sig = hex_decode_exact::<64>(&s.signature).ok_or_else(|| bad("bad signature encoding"))?;
262 let vk = VerifyingKey::from_bytes(&pk).map_err(|_| bad("bad public key"))?;
263 vk.verify(
264 &signed_bytes(domain, &s.payload),
265 &Signature::from_bytes(&sig),
266 )
267 .map_err(|_| bad("signature does not verify"))?;
268 Ok(pk)
269}
270
271pub fn fresh_nonce() -> String {
273 let b: [u8; 16] = rand::random();
274 hex_encode(&b)
275}
276
277impl CtlRequest {
278 pub fn new(
280 key: &SigningKey,
281 method: &str,
282 target: &str,
283 now_ms: u64,
284 ttl_ms: u64,
285 decision_id: Option<String>,
286 body: Value,
287 ) -> Self {
288 Self {
289 version: CTL_VERSION,
290 method: method.to_string(),
291 requester: node_id_from_pubkey(&key.verifying_key().to_bytes()),
292 target: target.to_string(),
293 nonce: fresh_nonce(),
294 issued_at_ms: now_ms,
295 expires_at_ms: now_ms.saturating_add(ttl_ms),
296 decision_id,
297 body,
298 }
299 }
300
301 pub fn sign(&self, key: &SigningKey) -> Result<SignedCtl, Refusal> {
303 let payload = serde_json::to_string(self)
304 .map_err(|e| Refusal::new(RefusalCode::InvalidRequest, e.to_string()))?;
305 Ok(sign(REQUEST_DOMAIN, payload, key))
306 }
307}
308
309impl CtlResponse {
310 pub fn sign(&self, key: &SigningKey) -> SignedCtl {
312 let payload = serde_json::to_string(self).unwrap_or_else(|_| "{}".into());
313 sign(RESPONSE_DOMAIN, payload, key)
314 }
315}
316
317#[derive(Debug, Default)]
319pub struct NonceGuard {
320 seen: Mutex<HashMap<String, u64>>,
321}
322
323impl NonceGuard {
324 pub fn new() -> Self {
326 Self::default()
327 }
328
329 pub fn admit(&self, nonce: &str, expires_ms: u64, now_ms: u64) -> bool {
332 let Ok(mut seen) = self.seen.lock() else {
333 return false;
334 };
335 if seen.contains_key(nonce) {
336 return false;
337 }
338 if seen.len() >= MAX_NONCES {
339 seen.retain(|_, exp| *exp > now_ms);
340 if seen.len() >= MAX_NONCES {
341 return false;
342 }
343 }
344 seen.insert(nonce.to_string(), expires_ms);
345 true
346 }
347}
348
349pub trait ControllerPolicy: Send + Sync {
351 fn allows(&self, public_key: &[u8; 32]) -> bool;
353}
354
355impl ControllerPolicy for Vec<[u8; 32]> {
356 fn allows(&self, public_key: &[u8; 32]) -> bool {
357 self.contains(public_key)
358 }
359}
360
361pub fn verify_request(
364 s: &SignedCtl,
365 me: &str,
366 now_ms: u64,
367 controllers: &dyn ControllerPolicy,
368 nonces: &NonceGuard,
369) -> Result<CtlRequest, Refusal> {
370 let pk = open(REQUEST_DOMAIN, s)?;
371 let req: CtlRequest = serde_json::from_str(&s.payload)
372 .map_err(|e| Refusal::new(RefusalCode::Signature, format!("malformed request: {e}")))?;
373 if req.version != CTL_VERSION {
374 return Err(Refusal::new(
375 RefusalCode::InvalidRequest,
376 "unsupported version",
377 ));
378 }
379 let derived = node_id_from_pubkey(&pk);
380 if req.requester != derived {
381 return Err(Refusal::new(
382 RefusalCode::Signature,
383 format!("requester {} is not the signing key's node", req.requester),
384 ));
385 }
386 let any_ok = req.target == ANY_TARGET && req.method == method::DESCRIBE;
387 if req.target != me && !any_ok {
388 return Err(Refusal::new(
389 RefusalCode::NotForMe,
390 format!("addressed to {}", req.target),
391 ));
392 }
393 if req.expires_at_ms <= now_ms {
394 return Err(Refusal::new(RefusalCode::Expired, "request expired"));
395 }
396 if req.issued_at_ms > now_ms.saturating_add(MAX_SKEW_MS) {
397 return Err(Refusal::new(
398 RefusalCode::Expired,
399 "request issued in the future",
400 ));
401 }
402 if req.expires_at_ms.saturating_sub(req.issued_at_ms) > MAX_TTL_MS
403 || req.expires_at_ms < req.issued_at_ms
404 {
405 return Err(Refusal::new(
406 RefusalCode::Expired,
407 "request lifetime out of bounds",
408 ));
409 }
410 if !crate::workload_pkg::codec::is_lower_hex(&req.nonce, 32) {
411 return Err(Refusal::new(
412 RefusalCode::InvalidRequest,
413 "nonce must be 32 hex",
414 ));
415 }
416 if !controllers.allows(&pk) {
417 return Err(Refusal::new(
418 RefusalCode::Unauthorized,
419 format!("{derived} is not an authorised controller of this node"),
420 ));
421 }
422 if !nonces.admit(&req.nonce, req.expires_at_ms, now_ms) {
423 return Err(Refusal::new(RefusalCode::Replay, "nonce already used"));
424 }
425 Ok(req)
426}
427
428pub fn verify_response(
432 s: &SignedCtl,
433 request: &CtlRequest,
434 expected: Option<&[u8; 32]>,
435) -> Result<(CtlResponse, [u8; 32]), Refusal> {
436 let pk = open(RESPONSE_DOMAIN, s)?;
437 if expected.is_some_and(|e| e != &pk) {
438 return Err(Refusal::new(
439 RefusalCode::Signature,
440 "response signed by another key",
441 ));
442 }
443 let resp: CtlResponse = serde_json::from_str(&s.payload)
444 .map_err(|e| Refusal::new(RefusalCode::Signature, format!("malformed response: {e}")))?;
445 if resp.responder != node_id_from_pubkey(&pk) {
446 return Err(Refusal::new(
447 RefusalCode::Signature,
448 "responder is not the signing key",
449 ));
450 }
451 if resp.request_nonce != request.nonce || resp.method != request.method {
452 return Err(Refusal::new(
453 RefusalCode::Replay,
454 "response answers another request",
455 ));
456 }
457 if request.target != ANY_TARGET && resp.responder != request.target {
458 return Err(Refusal::new(
459 RefusalCode::NotForMe,
460 "response from another node",
461 ));
462 }
463 Ok((resp, pk))
464}
465
466#[cfg(test)]
467#[path = "tests_msg.rs"]
468mod tests;