Skip to main content

weft_licence_wire/
lib.rs

1//! Wire types of the ADR-106 Seed licence proxy: the checkout grant, the signed
2//! envelope, the mesh id and the shared error type.
3//!
4//! This crate is small and portable on purpose. Both the member side
5//! (`clawft-kernel::licence`, which re-exports everything here) and the Seed
6//! side (`weft-licence`, built for armv7 and aarch64) use it, so the grant
7//! wire format is defined exactly once.
8//!
9//! Every signature is Ed25519, checked with `verify_strict`, over
10//! `domain tag || "\n" || canonical JSON payload`.
11
12use std::fmt;
13
14use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
15use serde::de::DeserializeOwned;
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18
19mod binding;
20mod grant;
21pub mod request;
22pub use binding::{
23    BindState, BindingRecord, SignedBinding, sign_binding, verify_binding_signature,
24};
25pub use grant::{
26    CheckoutGrant, GrantArtifact, LicenceRef, MAX_GRANT_ARTIFACTS, SignedGrant, sign_grant,
27    verify_grant, verify_grant_signature,
28};
29
30/// Lower-case hex encoding.
31pub fn hex_encode(bytes: &[u8]) -> String {
32    const DIGITS: &[u8; 16] = b"0123456789abcdef";
33    let mut s = String::with_capacity(bytes.len() * 2);
34    for b in bytes {
35        s.push(DIGITS[(b >> 4) as usize] as char);
36        s.push(DIGITS[(b & 0x0f) as usize] as char);
37    }
38    s
39}
40
41/// Decode lower-case hex of exactly `N` bytes. Upper-case is refused so each
42/// value has one spelling.
43pub fn hex_decode_exact<const N: usize>(s: &str) -> Option<[u8; N]> {
44    if s.len() != N * 2 {
45        return None;
46    }
47    let mut out = [0u8; N];
48    let bytes = s.as_bytes();
49    for (i, slot) in out.iter_mut().enumerate() {
50        let hi = hex_val(bytes[2 * i])?;
51        let lo = hex_val(bytes[2 * i + 1])?;
52        *slot = (hi << 4) | lo;
53    }
54    Some(out)
55}
56
57fn hex_val(b: u8) -> Option<u8> {
58    match b {
59        b'0'..=b'9' => Some(b - b'0'),
60        b'a'..=b'f' => Some(b - b'a' + 10),
61        _ => None,
62    }
63}
64
65/// Domain tag of the mesh id hash.
66pub const MESH_ID_DOMAIN: &str = "weft-licence-v1/mesh-id";
67/// Domain tag of binding records.
68pub const BINDING_DOMAIN: &str = "weft-licence-v1/binding";
69/// Domain tag of checkout grants.
70pub const GRANT_DOMAIN: &str = "weft-licence-v1/grant";
71/// Domain tag of operator hash approvals.
72pub const APPROVAL_DOMAIN: &str = "weft-licence-v1/approval";
73
74/// Largest signed payload accepted, in bytes.
75pub const MAX_PAYLOAD_BYTES: usize = 16 * 1024;
76/// Largest persisted store file read back, in bytes.
77pub const MAX_STORE_BYTES: u64 = 8 * 1024 * 1024;
78/// A grant issued more than this far ahead of the local clock is deferred.
79pub const GRANT_SKEW_SECS: u64 = 300;
80/// Longest grant lifetime (`expires_at - issued_at`): 7 days.
81pub const MAX_GRANT_TTL_SECS: u64 = 7 * 24 * 3600;
82/// The clock high-water mark grows at most this far past the newest accepted `issued_at`.
83pub const FAR_FUTURE_CLAMP_SECS: u64 = 30 * 24 * 3600;
84/// Latest time accepted in any record or floor (2100-01-01), unix seconds.
85pub const MAX_UNIX_TIME: u64 = 4_102_444_800;
86/// Largest artifact size a grant may list: 1 GiB.
87pub const MAX_ARTIFACT_BYTES: u64 = 1 << 30;
88/// Most floor entries a store file may hold (one per grant key, in practice one).
89pub const MAX_FLOORS: usize = 8;
90/// Most (cog, version) grant slots held.
91pub const MAX_GRANT_SLOTS: usize = 512;
92/// Most approvals held.
93pub const MAX_APPROVALS: usize = 4096;
94
95/// Why a licence record or store operation was refused.
96#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
97pub enum LicenceError {
98    /// Malformed, non-canonical or invalid content.
99    #[error("malformed licence record: {0}")]
100    Malformed(String),
101    /// Payload or file over the size cap.
102    #[error("licence record too large")]
103    TooLarge,
104    /// The signature does not verify.
105    #[error("licence signature does not verify")]
106    BadSignature,
107    /// Signed by a key that is not the pinned operator key (binding,
108    /// approval) or not the bound grant key (grant).
109    #[error("licence signer is not trusted for this record")]
110    UntrustedKey,
111    /// The grant key has been revoked (`SignerKey`).
112    #[error("grant key is revoked")]
113    KeyRevoked,
114    /// The record names another mesh.
115    #[error("record is for another mesh")]
116    WrongMesh,
117    /// This node has no local mesh id (no mesh nonce configured).
118    #[error("no local mesh id")]
119    NoLocalMesh,
120    /// No binding is accepted.
121    #[error("no binding")]
122    NoBinding,
123    /// The binding is `unbound`.
124    #[error("binding is unbound")]
125    Unbound,
126    /// The stored binding is for a mesh id this node no longer computes.
127    #[error("binding is orphaned")]
128    Orphaned,
129    /// A different record at an equal `seq` (both are refused).
130    #[error("conflicting records at seq {0}")]
131    Conflict(u64),
132    /// A newer grant leaves out an arch the current one carries.
133    #[error("newer grant drops arch {0}")]
134    DropsArch(String),
135    /// A newer grant changes the hashes of an arch it already carried.
136    #[error("newer grant changes artifact {0}")]
137    ChangesArtifact(String),
138    /// Issued ahead of the local clock: retry at the next sync.
139    #[error("grant is not yet valid")]
140    NotYetValid,
141    /// `expires_at - issued_at` is over the maximum.
142    #[error("grant lifetime exceeds the maximum")]
143    TtlTooLong,
144    /// The checkout part of the policy refuses bindings.
145    #[error("binding refused: {0}")]
146    BindingRefused(&'static str),
147    /// A steward-profile check failed.
148    #[error("binding check failed: {0}")]
149    CheckFailed(String),
150    /// The store file was unreadable at load; writes are refused.
151    #[error("licence store is poisoned: {0}")]
152    Poisoned(String),
153    /// Persisting to disk failed.
154    #[error("licence store persist failed: {0}")]
155    Persist(String),
156    /// A store capacity limit was reached.
157    #[error("licence store is full")]
158    Full,
159}
160
161/// The identity of one mesh:
162/// `sha256("weft-licence-v1/mesh-id\n" || genesis_pin || mesh_nonce)`.
163#[derive(Clone, Copy, PartialEq, Eq, Hash)]
164pub struct MeshId([u8; 32]);
165
166impl MeshId {
167    /// Derive the id from the genesis pin and the operator's `mesh_nonce`.
168    pub fn derive(genesis_pin: &[u8; 32], mesh_nonce: &[u8; 32]) -> Self {
169        let mut h = Sha256::new();
170        h.update(MESH_ID_DOMAIN.as_bytes());
171        h.update(b"\n");
172        h.update(genesis_pin);
173        h.update(mesh_nonce);
174        Self(h.finalize().into())
175    }
176
177    /// Parse the lower-case hex form.
178    pub fn from_hex(s: &str) -> Option<Self> {
179        hex_decode_exact::<32>(s).map(Self)
180    }
181
182    /// Lower-case hex form.
183    pub fn to_hex(&self) -> String {
184        hex_encode(&self.0)
185    }
186}
187
188impl fmt::Debug for MeshId {
189    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190        write!(f, "MeshId({})", &self.to_hex()[..12])
191    }
192}
193
194/// A signed payload as it travels: the exact signed JSON, key and signature
195/// (hex). One shape for every record kind; the domain tag keeps kinds apart.
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(deny_unknown_fields)]
198pub struct SignedEnvelope {
199    /// Canonical JSON of the record, exactly as signed.
200    pub payload: String,
201    /// Signer's Ed25519 public key, 64 hex chars.
202    pub public_key: String,
203    /// Ed25519 signature, 128 hex chars.
204    pub signature: String,
205}
206
207pub fn signed_bytes(domain: &str, payload: &str) -> Vec<u8> {
208    let mut v = Vec::with_capacity(domain.len() + 1 + payload.len());
209    v.extend_from_slice(domain.as_bytes());
210    v.push(b'\n');
211    v.extend_from_slice(payload.as_bytes());
212    v
213}
214
215/// Serialize `record` (struct field order is the canonical order) and sign it.
216pub fn sign_envelope<T: Serialize>(
217    domain: &str,
218    record: &T,
219    key: &SigningKey,
220) -> Result<SignedEnvelope, LicenceError> {
221    let payload =
222        serde_json::to_string(record).map_err(|e| LicenceError::Malformed(e.to_string()))?;
223    let sig = key.sign(&signed_bytes(domain, &payload));
224    Ok(SignedEnvelope {
225        payload,
226        public_key: hex_encode(&key.verifying_key().to_bytes()),
227        signature: hex_encode(&sig.to_bytes()),
228    })
229}
230
231/// The signer key of `env`, after the size check. Cheap: no signature work.
232pub fn envelope_key(env: &SignedEnvelope) -> Result<[u8; 32], LicenceError> {
233    if env.payload.len() > MAX_PAYLOAD_BYTES {
234        return Err(LicenceError::TooLarge);
235    }
236    hex_decode_exact::<32>(&env.public_key)
237        .ok_or_else(|| LicenceError::Malformed("public key".into()))
238}
239
240/// Strictly verify `env` under `domain` against `pk`.
241pub fn verify_envelope(
242    domain: &str,
243    env: &SignedEnvelope,
244    pk: &[u8; 32],
245) -> Result<(), LicenceError> {
246    let sig = hex_decode_exact::<64>(&env.signature)
247        .ok_or_else(|| LicenceError::Malformed("signature".into()))?;
248    let vk = VerifyingKey::from_bytes(pk).map_err(|_| LicenceError::BadSignature)?;
249    vk.verify_strict(
250        &signed_bytes(domain, &env.payload),
251        &Signature::from_bytes(&sig),
252    )
253    .map_err(|_| LicenceError::BadSignature)
254}
255
256/// Parse `payload` and require that re-serializing gives the same bytes, so
257/// each record has exactly one signed spelling.
258pub fn parse_canonical<T: Serialize + DeserializeOwned>(
259    payload: &str,
260) -> Result<T, LicenceError> {
261    let v: T =
262        serde_json::from_str(payload).map_err(|e| LicenceError::Malformed(e.to_string()))?;
263    let again = serde_json::to_string(&v).map_err(|e| LicenceError::Malformed(e.to_string()))?;
264    if again != payload {
265        return Err(LicenceError::Malformed("payload is not canonical".into()));
266    }
267    Ok(v)
268}
269
270/// `sha256` of `bytes`, lower-case hex.
271pub fn sha256_hex(bytes: &[u8]) -> String {
272    hex_encode(&Sha256::digest(bytes))
273}
274
275/// `"ed25519:"` plus 16 hex chars of `sha256(public key)`: the grant key id
276/// the operator compares at `init` time.
277pub fn key_id(pk: &[u8; 32]) -> String {
278    format!("ed25519:{}", &sha256_hex(pk)[..16])
279}
280
281/// A short token: 1 to 128 chars of `[A-Za-z0-9._-+@/]`.
282pub fn valid_token(s: &str) -> bool {
283    !s.is_empty()
284        && s.len() <= 128
285        && s.chars()
286            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '+' | '@' | '/'))
287}
288
289/// 64 lower-case hex chars.
290pub fn valid_hex32(s: &str) -> bool {
291    hex_decode_exact::<32>(s).is_some()
292}