Skip to main content

clawft_kernel/licence/
request.rs

1//! Steward to `weft-licence` request signing (ADR-106 sections 4 and 7).
2//!
3//! The signed string, its constants and the field rules are defined once, in
4//! `weft-licence-wire::request`, which `weft-licence` (the Seed service) uses
5//! too: domain `weft-licence-v1/request`, then method, target, node,
6//! `seed_device_id` (the audience, from the binding's `device_id`), a
7//! millisecond timestamp, a 16 to 64 alphanumeric nonce and the body sha256,
8//! one per line. This module is the kernel's request shape ([`LicenceRequest`]),
9//! its signing and verification over those shared pieces, and the verifier's
10//! replay memory. The tests verify what the client signs under
11//! `weft-licence`'s own verifier.
12
13use std::collections::HashMap;
14
15use ed25519_dalek::SigningKey;
16
17use crate::workload_pkg::codec::hex_decode_exact;
18
19pub use weft_licence_wire::request::{
20    CLOCK_FLOOR_SECS, REQUEST_DOMAIN, REQUEST_WINDOW_MS, signing_string, valid_nonce,
21};
22
23/// Largest request body the signer will produce or a verifier accept.
24pub const MAX_REQUEST_BODY: usize = 16 * 1024;
25/// Replay memory of one verifier.
26const MAX_NONCES: usize = 4096;
27
28/// The signature headers of one request.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct RequestAuth {
31    /// The steward's node id (`x-licence-node`).
32    pub node: String,
33    /// Signing time, unix milliseconds (`x-licence-ts`).
34    pub timestamp_ms: u64,
35    /// 16 to 64 alphanumerics (`x-licence-nonce`).
36    pub nonce: String,
37    /// Ed25519 signature over [`signing_string`], 128 hex (`x-licence-sig`).
38    pub signature: String,
39}
40
41/// One request in HTTP shape.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct LicenceRequest {
44    /// `GET` or `POST`.
45    pub method: String,
46    /// Path and query, for example `/licence/v1/checkout`.
47    pub path: String,
48    /// `None` only for the unauthenticated identity endpoint.
49    pub auth: Option<RequestAuth>,
50    /// Body bytes (empty for `GET`).
51    pub body: Vec<u8>,
52}
53
54/// Sign a request with the steward key. `nonce` must satisfy [`valid_nonce`].
55#[allow(clippy::too_many_arguments)]
56pub fn sign_request(
57    key: &SigningKey,
58    node: &str,
59    audience: &str,
60    method: &str,
61    path: &str,
62    body: Vec<u8>,
63    ts_ms: u64,
64    nonce: &str,
65) -> LicenceRequest {
66    let sig = weft_licence_wire::request::sign(key, node, audience, method, path, &body, ts_ms, nonce);
67    LicenceRequest {
68        method: method.to_owned(),
69        path: path.to_owned(),
70        auth: Some(RequestAuth {
71            node: node.to_owned(),
72            timestamp_ms: ts_ms,
73            nonce: nonce.to_owned(),
74            signature: sig,
75        }),
76        body,
77    }
78}
79
80/// Why a verifier refused a request. All are refused before any work is done.
81#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
82pub enum RequestRefused {
83    /// No signature headers.
84    #[error("request is not signed")]
85    Unsigned,
86    /// Body over [`MAX_REQUEST_BODY`].
87    #[error("request body too large")]
88    TooLarge,
89    /// A node id other than the bound steward's.
90    #[error("request names a node that is not the bound steward")]
91    WrongNode,
92    /// Timestamp outside the window.
93    #[error("request timestamp is outside the window")]
94    Stale,
95    /// A malformed header value.
96    #[error("malformed request header")]
97    Malformed,
98    /// The signature does not verify.
99    #[error("request signature does not verify")]
100    BadSignature,
101    /// This nonce was already used inside the window.
102    #[error("request nonce was already used")]
103    Replay,
104}
105
106/// Verifier-side replay memory: nonce to its signed timestamp (ms). Entries
107/// older than the window are evicted by timestamp; when the memory is full of
108/// live entries a new request is refused, never the whole set forgotten.
109#[derive(Debug, Default)]
110pub struct ReplayGuard {
111    seen: HashMap<String, u64>,
112}
113
114impl ReplayGuard {
115    /// Record `nonce` signed at `ts_ms`; false when already seen or when the
116    /// memory is full of entries still inside the window.
117    pub fn first_use(&mut self, nonce: &str, ts_ms: u64, now_ms: u64) -> bool {
118        self.seen.retain(|_, t| t.saturating_add(REQUEST_WINDOW_MS) >= now_ms);
119        if self.seen.contains_key(nonce) || self.seen.len() >= MAX_NONCES {
120            return false;
121        }
122        self.seen.insert(nonce.to_owned(), ts_ms);
123        true
124    }
125}
126
127/// Verify `req` against the bound steward. Cheap checks first; the signature
128/// last; only a request whose signature verified is charged a nonce.
129pub fn verify_request(
130    req: &LicenceRequest,
131    steward_pubkey: &[u8; 32],
132    steward_node: &str,
133    audience: &str,
134    now_ms: u64,
135    replay: &mut ReplayGuard,
136) -> Result<(), RequestRefused> {
137    let auth = req.auth.as_ref().ok_or(RequestRefused::Unsigned)?;
138    if req.body.len() > MAX_REQUEST_BODY {
139        return Err(RequestRefused::TooLarge);
140    }
141    if !weft_licence_wire::request::valid_ts(auth.timestamp_ms) || !valid_nonce(&auth.nonce) {
142        return Err(RequestRefused::Malformed);
143    }
144    if hex_decode_exact::<64>(&auth.signature).is_none() {
145        return Err(RequestRefused::Malformed);
146    }
147    if auth.node != steward_node {
148        return Err(RequestRefused::WrongNode);
149    }
150    if !weft_licence_wire::request::within_window(auth.timestamp_ms, now_ms, REQUEST_WINDOW_MS) {
151        return Err(RequestRefused::Stale);
152    }
153    if !weft_licence_wire::request::verify_signature(
154        steward_pubkey,
155        &auth.node,
156        audience,
157        &req.method,
158        &req.path,
159        &req.body,
160        auth.timestamp_ms,
161        &auth.nonce,
162        &auth.signature,
163    ) {
164        return Err(RequestRefused::BadSignature);
165    }
166    if !replay.first_use(&auth.nonce, auth.timestamp_ms, now_ms) {
167        return Err(RequestRefused::Replay);
168    }
169    Ok(())
170}