Skip to main content

weft_licence_wire/
request.rs

1//! Steward to `weft-licence` request signing: the one definition both the
2//! steward's client (kernel) and the Seed service (`weft-licence`) use.
3//!
4//! The signed string is one field per line, the COG-011 bridge layout under
5//! its own domain plus the audience:
6//!
7//! ```text
8//! weft-licence-v1/request \n METHOD \n target \n node \n seed_device_id \n ts_ms \n nonce \n sha256(body)
9//! ```
10//!
11//! `target` is the path and query. `seed_device_id` is the audience (the
12//! bound binding's `device_id`): a request signed for one Seed fails at
13//! another. The timestamp is unix MILLISECONDS; the nonce is 16 to 64 ASCII
14//! alphanumerics. Header transport and replay memory stay with each side.
15
16use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
17use sha2::{Digest, Sha256};
18
19use crate::{hex_decode_exact, hex_encode};
20
21/// Domain tag of steward requests.
22pub const REQUEST_DOMAIN: &str = "weft-licence-v1/request";
23/// Latest accepted timestamp (year 2100), in ms, as the bridge.
24pub const MAX_TS_MS: u64 = 4_102_444_800_000;
25/// The COG-011 bridge's clock floor (`CLOCK_FLOOR_MS` / 1000), unix seconds
26/// (2026-05-28 UTC). A clock below it is not set: nothing is signed or issued.
27pub const CLOCK_FLOOR_SECS: u64 = 1_780_000_000;
28/// Default replay window, each side of the verifier's clock.
29pub const REQUEST_WINDOW_MS: u64 = 120_000;
30/// Most nonces a verifier remembers inside the window.
31pub const MAX_NONCES: usize = 4096;
32
33/// The bytes the steward key signs.
34pub fn signing_string(
35    method: &str,
36    target: &str,
37    node: &str,
38    audience: &str,
39    ts_ms: u64,
40    nonce: &str,
41    body: &[u8],
42) -> String {
43    format!(
44        "{REQUEST_DOMAIN}\n{method}\n{target}\n{node}\n{audience}\n{ts_ms}\n{nonce}\n{}",
45        hex_encode(&Sha256::digest(body))
46    )
47}
48
49/// A nonce the server accepts: 16 to 64 ASCII alphanumerics.
50pub fn valid_nonce(n: &str) -> bool {
51    (16..=64).contains(&n.len()) && n.bytes().all(|b| b.is_ascii_alphanumeric())
52}
53
54/// A timestamp inside the accepted range.
55pub fn valid_ts(ts_ms: u64) -> bool {
56    ts_ms <= MAX_TS_MS
57}
58
59/// Whether `ts_ms` is within `window_ms` of `now_ms`.
60pub fn within_window(ts_ms: u64, now_ms: u64, window_ms: u64) -> bool {
61    ts_ms.abs_diff(now_ms) <= window_ms
62}
63
64/// Sign a request: the signature as 128 lower-case hex chars.
65#[allow(clippy::too_many_arguments)]
66pub fn sign(
67    key: &SigningKey,
68    node: &str,
69    audience: &str,
70    method: &str,
71    target: &str,
72    body: &[u8],
73    ts_ms: u64,
74    nonce: &str,
75) -> String {
76    let s = signing_string(method, target, node, audience, ts_ms, nonce, body);
77    hex_encode(&key.sign(s.as_bytes()).to_bytes())
78}
79
80/// Check a request's signature (strictly) against the bound steward key.
81/// `sig_hex` must be 128 lower-case hex chars; false for anything else.
82#[allow(clippy::too_many_arguments)]
83pub fn verify_signature(
84    steward_pubkey: &[u8; 32],
85    node: &str,
86    audience: &str,
87    method: &str,
88    target: &str,
89    body: &[u8],
90    ts_ms: u64,
91    nonce: &str,
92    sig_hex: &str,
93) -> bool {
94    let (Some(sig), Ok(vk)) =
95        (hex_decode_exact::<64>(sig_hex), VerifyingKey::from_bytes(steward_pubkey))
96    else {
97        return false;
98    };
99    let s = signing_string(method, target, node, audience, ts_ms, nonce, body);
100    vk.verify_strict(s.as_bytes(), &Signature::from_bytes(&sig)).is_ok()
101}