Skip to main content

clawft_kernel/
capability_claim.rs

1//! Cross-node capability-claim verification (WEFT-147).
2//!
3//! Peer nodes previously advertised free-form capability strings on
4//! [`crate::cluster::PeerNode`] with no authenticity check. This module
5//! provides:
6//!
7//! 1. An **allow-list** of known capability tokens.
8//! 2. A canonical claim payload + Ed25519 signed advertisement.
9//! 3. Verify helpers that reject unknown tokens and tampered signatures.
10//!
11//! Wire format: JSON body + 64-byte Ed25519 signature over the canonical
12//! CBOR-ish UTF-8 payload bytes (stable field order via
13//! [`CapabilityClaim::signing_bytes`]).
14
15use serde::{Deserialize, Serialize};
16
17/// Allow-listed capability tokens a node may advertise (WEFT-147).
18///
19/// Free-form strings outside this list are rejected at verification.
20pub const CAPABILITY_ALLOWLIST: &[&str] = &[
21    "mesh",
22    "code",
23    "review",
24    "test",
25    "ipc",
26    "spawn",
27    "ecc",
28    "hnsw",
29    "causal",
30    "splat",
31    "world_model",
32    "llm",
33    "voice",
34    "storage",
35    "governance",
36];
37
38/// Errors from capability-claim validation / crypto.
39#[non_exhaustive]
40#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
41pub enum CapabilityClaimError {
42    #[error("capability not on allow-list: {0}")]
43    UnknownCapability(String),
44    #[error("empty capabilities list")]
45    EmptyCapabilities,
46    #[error("node_id must not be empty")]
47    EmptyNodeId,
48    #[error("invalid public key length (want 32, got {0})")]
49    BadPublicKeyLen(usize),
50    #[error("invalid signature length (want 64, got {0})")]
51    BadSignatureLen(usize),
52    #[error("signature verification failed")]
53    BadSignature,
54    #[error("serde error: {0}")]
55    Serde(String),
56}
57
58/// Unsigned capability claim body.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct CapabilityClaim {
61    /// Advertising node id (DID / mesh node id string).
62    pub node_id: String,
63    /// Allow-listed capability tokens (sorted for stable signing).
64    pub capabilities: Vec<String>,
65    /// Unix epoch seconds when the claim was issued.
66    pub issued_at: u64,
67    /// Optional expiry (unix epoch seconds). `None` = no expiry checked here.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub expires_at: Option<u64>,
70}
71
72impl CapabilityClaim {
73    /// Build a claim after validating the allow-list and sorting tokens.
74    pub fn new(
75        node_id: impl Into<String>,
76        capabilities: Vec<String>,
77        issued_at: u64,
78    ) -> Result<Self, CapabilityClaimError> {
79        let node_id = node_id.into();
80        if node_id.is_empty() {
81            return Err(CapabilityClaimError::EmptyNodeId);
82        }
83        if capabilities.is_empty() {
84            return Err(CapabilityClaimError::EmptyCapabilities);
85        }
86        let mut caps = capabilities;
87        for c in &caps {
88            validate_capability(c)?;
89        }
90        caps.sort();
91        caps.dedup();
92        Ok(Self {
93            node_id,
94            capabilities: caps,
95            issued_at,
96            expires_at: None,
97        })
98    }
99
100    /// Canonical bytes signed / verified (stable JSON, sorted keys via serde
101    /// field order on this struct).
102    pub fn signing_bytes(&self) -> Result<Vec<u8>, CapabilityClaimError> {
103        serde_json::to_vec(self).map_err(|e| CapabilityClaimError::Serde(e.to_string()))
104    }
105}
106
107/// Signed capability advertisement for mesh exchange.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct SignedCapabilityAdvertisement {
110    /// Claim body.
111    pub claim: CapabilityClaim,
112    /// Ed25519 public key (32 bytes) of the advertising node.
113    pub public_key: Vec<u8>,
114    /// Ed25519 signature (64 bytes) over [`CapabilityClaim::signing_bytes`].
115    pub signature: Vec<u8>,
116}
117
118/// Return `Ok(())` if `cap` is on the allow-list.
119pub fn validate_capability(cap: &str) -> Result<(), CapabilityClaimError> {
120    if CAPABILITY_ALLOWLIST.contains(&cap) {
121        Ok(())
122    } else {
123        Err(CapabilityClaimError::UnknownCapability(cap.to_string()))
124    }
125}
126
127/// Validate every capability string in a free-form peer list.
128pub fn validate_capabilities(caps: &[String]) -> Result<(), CapabilityClaimError> {
129    if caps.is_empty() {
130        return Err(CapabilityClaimError::EmptyCapabilities);
131    }
132    for c in caps {
133        validate_capability(c)?;
134    }
135    Ok(())
136}
137
138/// Sign a claim with an Ed25519 signing key (mesh / exochain feature).
139#[cfg(any(feature = "mesh", feature = "exochain"))]
140pub fn sign_claim(
141    claim: CapabilityClaim,
142    signing_key: &ed25519_dalek::SigningKey,
143) -> Result<SignedCapabilityAdvertisement, CapabilityClaimError> {
144    use ed25519_dalek::Signer;
145    let bytes = claim.signing_bytes()?;
146    let sig = signing_key.sign(&bytes);
147    Ok(SignedCapabilityAdvertisement {
148        claim,
149        public_key: signing_key.verifying_key().to_bytes().to_vec(),
150        signature: sig.to_bytes().to_vec(),
151    })
152}
153
154/// Verify signature + allow-list. Does not check `expires_at` clock skew
155/// (caller may enforce).
156#[cfg(any(feature = "mesh", feature = "exochain"))]
157pub fn verify_signed_advertisement(
158    advert: &SignedCapabilityAdvertisement,
159) -> Result<(), CapabilityClaimError> {
160    use ed25519_dalek::{Signature, Verifier, VerifyingKey};
161
162    if advert.public_key.len() != 32 {
163        return Err(CapabilityClaimError::BadPublicKeyLen(advert.public_key.len()));
164    }
165    if advert.signature.len() != 64 {
166        return Err(CapabilityClaimError::BadSignatureLen(advert.signature.len()));
167    }
168    validate_capabilities(&advert.claim.capabilities)?;
169
170    let vk = VerifyingKey::from_bytes(
171        advert
172            .public_key
173            .as_slice()
174            .try_into()
175            .map_err(|_| CapabilityClaimError::BadPublicKeyLen(advert.public_key.len()))?,
176    )
177    .map_err(|_| CapabilityClaimError::BadPublicKeyLen(advert.public_key.len()))?;
178
179    let sig = Signature::from_bytes(
180        advert
181            .signature
182            .as_slice()
183            .try_into()
184            .map_err(|_| CapabilityClaimError::BadSignatureLen(advert.signature.len()))?,
185    );
186
187    let bytes = advert.claim.signing_bytes()?;
188    vk.verify(&bytes, &sig)
189        .map_err(|_| CapabilityClaimError::BadSignature)?;
190    Ok(())
191}
192
193/// Apply a verified advertisement onto a [`crate::cluster::PeerNode`].
194///
195/// Returns `Err` without mutating when verification fails.
196#[cfg(any(feature = "mesh", feature = "exochain"))]
197pub fn apply_verified_capabilities(
198    peer: &mut crate::cluster::PeerNode,
199    advert: &SignedCapabilityAdvertisement,
200) -> Result<(), CapabilityClaimError> {
201    verify_signed_advertisement(advert)?;
202    if peer.id != advert.claim.node_id {
203        // Soft mismatch: still reject — node_id in claim must match peer.
204        return Err(CapabilityClaimError::Serde(format!(
205            "claim node_id {} != peer.id {}",
206            advert.claim.node_id, peer.id
207        )));
208    }
209    peer.capabilities = advert.claim.capabilities.clone();
210    Ok(())
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn allowlist_rejects_unknown() {
219        assert!(validate_capability("code").is_ok());
220        assert!(matches!(
221            validate_capability("pwn-root"),
222            Err(CapabilityClaimError::UnknownCapability(_))
223        ));
224    }
225
226    #[test]
227    fn claim_sorts_and_dedups() {
228        let c = CapabilityClaim::new(
229            "node-a",
230            vec!["review".into(), "code".into(), "code".into()],
231            1,
232        )
233        .unwrap();
234        assert_eq!(c.capabilities, vec!["code".to_string(), "review".to_string()]);
235    }
236
237    #[test]
238    fn claim_rejects_unknown_cap() {
239        let err = CapabilityClaim::new("n", vec!["not-a-real-cap".into()], 0).unwrap_err();
240        assert!(matches!(err, CapabilityClaimError::UnknownCapability(_)));
241    }
242
243    #[cfg(any(feature = "mesh", feature = "exochain"))]
244    #[test]
245    fn sign_and_verify_ok() {
246        use ed25519_dalek::SigningKey;
247        use rand::rngs::OsRng;
248        let key = SigningKey::generate(&mut OsRng);
249        let claim = CapabilityClaim::new("did:weft:node-a", vec!["mesh".into(), "ecc".into()], 100)
250            .unwrap();
251        let advert = sign_claim(claim, &key).unwrap();
252        assert!(verify_signed_advertisement(&advert).is_ok());
253    }
254
255    #[cfg(any(feature = "mesh", feature = "exochain"))]
256    #[test]
257    fn tampered_claim_fails() {
258        use ed25519_dalek::SigningKey;
259        use rand::rngs::OsRng;
260        let key = SigningKey::generate(&mut OsRng);
261        let claim = CapabilityClaim::new("node-a", vec!["code".into()], 1).unwrap();
262        let mut advert = sign_claim(claim, &key).unwrap();
263        advert.claim.capabilities.push("review".into());
264        assert_eq!(
265            verify_signed_advertisement(&advert),
266            Err(CapabilityClaimError::BadSignature)
267        );
268    }
269
270    #[cfg(any(feature = "mesh", feature = "exochain"))]
271    #[test]
272    fn wrong_key_fails() {
273        use ed25519_dalek::SigningKey;
274        use rand::rngs::OsRng;
275        let key = SigningKey::generate(&mut OsRng);
276        let other = SigningKey::generate(&mut OsRng);
277        let claim = CapabilityClaim::new("node-a", vec!["ipc".into()], 1).unwrap();
278        let mut advert = sign_claim(claim, &key).unwrap();
279        advert.public_key = other.verifying_key().to_bytes().to_vec();
280        assert_eq!(
281            verify_signed_advertisement(&advert),
282            Err(CapabilityClaimError::BadSignature)
283        );
284    }
285
286    #[cfg(any(feature = "mesh", feature = "exochain"))]
287    #[test]
288    fn serde_roundtrip_advert() {
289        use ed25519_dalek::SigningKey;
290        use rand::rngs::OsRng;
291        let key = SigningKey::generate(&mut OsRng);
292        let claim = CapabilityClaim::new("n1", vec!["storage".into()], 42).unwrap();
293        let advert = sign_claim(claim, &key).unwrap();
294        let json = serde_json::to_string(&advert).unwrap();
295        let restored: SignedCapabilityAdvertisement = serde_json::from_str(&json).unwrap();
296        assert!(verify_signed_advertisement(&restored).is_ok());
297    }
298}