1use serde::{Deserialize, Serialize};
16
17pub 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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct CapabilityClaim {
61 pub node_id: String,
63 pub capabilities: Vec<String>,
65 pub issued_at: u64,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub expires_at: Option<u64>,
70}
71
72impl CapabilityClaim {
73 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct SignedCapabilityAdvertisement {
110 pub claim: CapabilityClaim,
112 pub public_key: Vec<u8>,
114 pub signature: Vec<u8>,
116}
117
118pub 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
127pub 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#[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#[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#[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 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}