Skip to main content

clawft_kernel/licence/
client.rs

1//! The steward's client of `weft-licence` (ADR-106 sections 4 and 7).
2//!
3//! [`LicenceClient`] is what the checkout relay needs. [`SignedLicenceClient`]
4//! implements it over a [`LicenceTransport`] (one signed request in, one
5//! status and body out): the real transport is plain HTTP and arrives with
6//! `weft-licence` in phase 2. Tests run it against a stub that implements the
7//! same request and grant contract.
8
9use std::sync::Arc;
10
11use async_trait::async_trait;
12use ed25519_dalek::SigningKey;
13use serde::{Deserialize, Serialize};
14
15use super::request::{CLOCK_FLOOR_SECS, LicenceRequest, MAX_REQUEST_BODY, sign_request};
16use super::{SignedGrant, valid_token};
17
18/// Source of unix milliseconds. Injected so tests control the clock.
19pub type ClockMs = Arc<dyn Fn() -> u64 + Send + Sync>;
20
21/// The wall clock in milliseconds.
22pub fn system_clock_ms() -> ClockMs {
23    Arc::new(|| {
24        std::time::SystemTime::now()
25            .duration_since(std::time::UNIX_EPOCH)
26            .map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
27            .unwrap_or(0)
28    })
29}
30
31/// Path of the checkout endpoint.
32pub const CHECKOUT_PATH: &str = "/licence/v1/checkout";
33/// Path prefix of the artifact endpoint (BLAKE3 hex follows).
34pub const ARTIFACT_PATH: &str = "/licence/v1/artifact/";
35/// Path of the renewal listing (`?since=<seq>`).
36pub const GRANTS_PATH: &str = "/licence/v1/grants";
37/// Path of the renewal call (signs a new grant for every active checkout).
38pub const RENEW_PATH: &str = "/licence/v1/renew";
39
40/// One page of `GET /licence/v1/grants?since=<ctr>`.
41#[derive(Debug, Clone, PartialEq, Eq, Default)]
42pub struct GrantsPage {
43    /// The latest grant per checkout issued after the cursor.
44    pub grants: Vec<SignedGrant>,
45    /// The cursor for the next page (the Seed's issue counter).
46    pub next: u64,
47    /// More pages follow.
48    pub more: bool,
49}
50/// Largest response body the client reads, other than artifact bytes.
51pub const MAX_RESPONSE_BODY: usize = 256 * 1024;
52
53/// A request for a cog version, as sent to `weft-licence` and by members to
54/// the steward. Every string is a short token; `version` may be `latest`.
55#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct CheckoutWire {
58    /// Correlates a reply with this request.
59    pub request_id: String,
60    /// Cog id.
61    pub cog_id: String,
62    /// Exact version, or `latest`.
63    pub version: String,
64    /// Target architecture.
65    pub arch: String,
66}
67
68impl CheckoutWire {
69    /// Boundary validation of a request from the network.
70    pub fn validate(&self) -> Result<(), String> {
71        for (what, v) in [
72            ("request_id", &self.request_id),
73            ("cog_id", &self.cog_id),
74            ("version", &self.version),
75            ("arch", &self.arch),
76        ] {
77            if !valid_token(v) {
78                return Err(format!("{what} is not a valid token"));
79            }
80        }
81        Ok(())
82    }
83}
84
85/// What `weft-licence` answered.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct LicenceResponse {
88    /// HTTP status.
89    pub status: u16,
90    /// Body bytes.
91    pub body: Vec<u8>,
92}
93
94/// Why a licence call failed.
95#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
96pub enum LicenceClientError {
97    /// The link to `weft-licence` failed.
98    #[error("licence link: {0}")]
99    Transport(String),
100    /// `weft-licence` refused with a stable code (`cog_unlicensed`,
101    /// `licence_expired`, `clock_not_set`, `seed_bound_elsewhere`, ...).
102    #[error("licence refused ({status}): {code}")]
103    Refused {
104        /// HTTP status.
105        status: u16,
106        /// The stable error code.
107        code: String,
108    },
109    /// The answer did not have the expected shape.
110    #[error("bad licence response: {0}")]
111    BadResponse(String),
112}
113
114/// One signed round trip to `weft-licence`.
115#[async_trait]
116pub trait LicenceTransport: Send + Sync + 'static {
117    /// Send `req` and return the status and body.
118    async fn call(&self, req: LicenceRequest) -> Result<LicenceResponse, LicenceClientError>;
119}
120
121/// What the checkout relay needs from `weft-licence`.
122#[async_trait]
123pub trait LicenceClient: Send + Sync + 'static {
124    /// `POST /licence/v1/checkout`: the signed grant, with `seq` persisted
125    /// by the Seed before release.
126    async fn checkout(&self, req: &CheckoutWire) -> Result<SignedGrant, LicenceClientError>;
127    /// `GET /licence/v1/artifact/<blake3>`: the cached binary. At most
128    /// `max_len` bytes are accepted.
129    async fn artifact(&self, blake3_hex: &str, max_len: u64) -> Result<Vec<u8>, LicenceClientError>;
130    /// `GET /licence/v1/grants?since=<seq>`: renewal batch.
131    async fn grants_since(&self, since: u64) -> Result<Vec<SignedGrant>, LicenceClientError>;
132    /// `POST /licence/v1/renew`: a new grant (or a withdrawal) for every
133    /// active checkout, at most one batch. `next` is the Seed's issue counter
134    /// after the renewal (the bound for the catch-up cursor).
135    async fn renew(&self) -> Result<GrantsPage, LicenceClientError> {
136        Err(LicenceClientError::BadResponse("renew is not supported by this client".into()))
137    }
138    /// [`Self::grants_since`] with the paging cursor.
139    async fn grants_page(&self, since: u64) -> Result<GrantsPage, LicenceClientError> {
140        let grants = self.grants_since(since).await?;
141        Ok(GrantsPage { grants, next: since, more: false })
142    }
143    /// `POST /licence/v1/renew` with `{"release": [{cog_id, version}]}`: the
144    /// Seed withdraws that checkout (a renewal whose `expires_at <=
145    /// issued_at`) and, as for any renewal, renews every other active one.
146    async fn release(&self, _cog_id: &str, _version: &str) -> Result<GrantsPage, LicenceClientError> {
147        Err(LicenceClientError::BadResponse("release is not supported by this client".into()))
148    }
149}
150
151/// The body of a release (ADR-106 renewal endpoint; weft-licence `RenewReq`).
152#[derive(Serialize)]
153struct ReleaseBody<'a> {
154    release: [ReleaseRef<'a>; 1],
155}
156
157#[derive(Serialize)]
158struct ReleaseRef<'a> {
159    cog_id: &'a str,
160    version: &'a str,
161}
162
163#[derive(Deserialize)]
164struct GrantBody {
165    grant: SignedGrant,
166}
167
168#[derive(Deserialize)]
169struct GrantsBody {
170    grants: Vec<SignedGrant>,
171    #[serde(default)]
172    next: u64,
173    #[serde(default)]
174    more: bool,
175}
176
177#[derive(Deserialize)]
178struct ErrorBody {
179    error: String,
180}
181
182/// [`LicenceClient`] that signs every request with the steward key.
183pub struct SignedLicenceClient<T: LicenceTransport> {
184    key: SigningKey,
185    node: String,
186    /// The bound Seed's device id: the audience every request is signed for.
187    seed_device_id: String,
188    transport: T,
189    clock: ClockMs,
190}
191
192impl<T: LicenceTransport> SignedLicenceClient<T> {
193    /// A client for the steward `node` (its node id) holding `key`, talking
194    /// to the Seed `seed_device_id` (from the binding).
195    pub fn new(
196        key: SigningKey,
197        node: impl Into<String>,
198        seed_device_id: impl Into<String>,
199        transport: T,
200        clock: ClockMs,
201    ) -> Arc<Self> {
202        Arc::new(Self { key, node: node.into(), seed_device_id: seed_device_id.into(), transport, clock })
203    }
204
205    /// A client for the bound Seed: the audience is the binding's `device_id`
206    /// and the node is its `steward_node_id`.
207    pub fn for_binding(
208        key: SigningKey,
209        binding: &super::BindingRecord,
210        transport: T,
211        clock: ClockMs,
212    ) -> Arc<Self> {
213        Self::new(key, binding.steward_node_id.clone(), binding.device_id.clone(), transport, clock)
214    }
215
216    async fn signed(
217        &self,
218        method: &str,
219        path: &str,
220        body: Vec<u8>,
221        max_body: usize,
222    ) -> Result<Vec<u8>, LicenceClientError> {
223        if body.len() > MAX_REQUEST_BODY {
224            return Err(LicenceClientError::BadResponse("request body too large".into()));
225        }
226        let ts_ms = (self.clock)();
227        // A clock that was never set signs nothing (COG-011 floor).
228        if ts_ms / 1000 < CLOCK_FLOOR_SECS {
229            return Err(LicenceClientError::Refused { status: 0, code: "clock_not_set".into() });
230        }
231        let nonce = request_nonce();
232        let req = sign_request(&self.key, &self.node, &self.seed_device_id, method, path, body, ts_ms, &nonce);
233        let resp = self.transport.call(req).await?;
234        if resp.status != 200 {
235            let code = serde_json::from_slice::<ErrorBody>(&resp.body)
236                .map(|e| e.error)
237                .unwrap_or_else(|_| "unknown_error".into());
238            return Err(LicenceClientError::Refused { status: resp.status, code });
239        }
240        if resp.body.len() > max_body {
241            return Err(LicenceClientError::BadResponse("response body too large".into()));
242        }
243        Ok(resp.body)
244    }
245}
246
247fn bad(e: serde_json::Error) -> LicenceClientError {
248    LicenceClientError::BadResponse(e.to_string())
249}
250
251#[async_trait]
252impl<T: LicenceTransport> LicenceClient for SignedLicenceClient<T> {
253    async fn checkout(&self, req: &CheckoutWire) -> Result<SignedGrant, LicenceClientError> {
254        let body = serde_json::to_vec(req).map_err(bad)?;
255        let out = self.signed("POST", CHECKOUT_PATH, body, MAX_RESPONSE_BODY).await?;
256        Ok(serde_json::from_slice::<GrantBody>(&out).map_err(bad)?.grant)
257    }
258
259    async fn artifact(&self, blake3_hex: &str, max_len: u64) -> Result<Vec<u8>, LicenceClientError> {
260        if !super::valid_hex32(blake3_hex) {
261            return Err(LicenceClientError::BadResponse("artifact id is not a blake3".into()));
262        }
263        let path = format!("{ARTIFACT_PATH}{blake3_hex}");
264        self.signed("GET", &path, Vec::new(), usize::try_from(max_len).unwrap_or(usize::MAX)).await
265    }
266
267    async fn grants_since(&self, since: u64) -> Result<Vec<SignedGrant>, LicenceClientError> {
268        Ok(self.grants_page(since).await?.grants)
269    }
270
271    async fn grants_page(&self, since: u64) -> Result<GrantsPage, LicenceClientError> {
272        let path = format!("{GRANTS_PATH}?since={since}");
273        let out = self.signed("GET", &path, Vec::new(), MAX_RESPONSE_BODY).await?;
274        let b = serde_json::from_slice::<GrantsBody>(&out).map_err(bad)?;
275        Ok(GrantsPage { grants: b.grants, next: b.next, more: b.more })
276    }
277
278    async fn renew(&self) -> Result<GrantsPage, LicenceClientError> {
279        let out = self.signed("POST", RENEW_PATH, Vec::new(), MAX_RESPONSE_BODY).await?;
280        let b = serde_json::from_slice::<GrantsBody>(&out).map_err(bad)?;
281        Ok(GrantsPage { grants: b.grants, next: b.next, more: false })
282    }
283
284    async fn release(&self, cog_id: &str, version: &str) -> Result<GrantsPage, LicenceClientError> {
285        if !valid_token(cog_id) || !valid_token(version) || version == "latest" {
286            return Err(LicenceClientError::BadResponse("release needs a cog id and an exact version".into()));
287        }
288        let body = serde_json::to_vec(&ReleaseBody { release: [ReleaseRef { cog_id, version }] }).map_err(bad)?;
289        let out = self.signed("POST", RENEW_PATH, body, MAX_RESPONSE_BODY).await?;
290        let b = serde_json::from_slice::<GrantsBody>(&out).map_err(bad)?;
291        Ok(GrantsPage { grants: b.grants, next: b.next, more: false })
292    }
293}
294
295/// A fresh request nonce: 32 lower-case hex chars (inside the server's 16 to 64 alphanumerics).
296#[cfg(test)]
297pub(super) fn request_nonce_for_tests() -> String {
298    request_nonce()
299}
300
301fn request_nonce() -> String {
302    use rand::RngCore;
303    let mut b = [0u8; 16];
304    rand::rngs::OsRng.fill_bytes(&mut b);
305    crate::workload_pkg::codec::hex_encode(&b)
306}