1use std::collections::HashSet;
24
25use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
26use ed25519_dalek::{Signature, VerifyingKey};
27use hatchling_core::CapabilityName;
28
29use crate::{Error, Manifest, SignedManifest};
30
31#[derive(Debug, Clone)]
38pub struct Admission {
39 manifest: SignedManifest,
40 granted: HashSet<CapabilityName>,
41 signer: [u8; 32],
42}
43
44impl Admission {
45 #[must_use]
47 pub const fn manifest(&self) -> &Manifest {
48 &self.manifest.manifest
49 }
50
51 #[must_use]
54 pub const fn signer(&self) -> &[u8; 32] {
55 &self.signer
56 }
57
58 #[must_use]
62 pub fn signer_fingerprint(&self) -> String {
63 use sha2::Digest as _;
64 use std::fmt::Write as _;
65 sha2::Sha256::digest(self.signer)
66 .iter()
67 .fold(String::with_capacity(64), |mut out, byte| {
68 let _ = write!(out, "{byte:02x}");
69 out
70 })
71 }
72
73 #[must_use]
75 pub const fn capabilities(&self) -> &HashSet<CapabilityName> {
76 &self.granted
77 }
78
79 pub fn check_call(&self, capability: &CapabilityName) -> Result<(), Error> {
87 if self.granted.contains(capability) {
88 Ok(())
89 } else {
90 Err(Error::UndeclaredCapability {
91 name: self.manifest.manifest.name.clone(),
92 capability: capability.clone(),
93 })
94 }
95 }
96}
97
98pub fn check_trust_root(root: &[u8; 32]) -> Result<(), Error> {
105 let key = VerifyingKey::from_bytes(root)
106 .map_err(|e| Error::InvalidTrustRoot(format!("malformed Ed25519 public key: {e}")))?;
107 if key.is_weak() {
108 return Err(Error::InvalidTrustRoot(String::from(
109 "weak (small-order) key: any signature could verify under it",
110 )));
111 }
112 Ok(())
113}
114
115pub fn verify_admission<S: std::hash::BuildHasher>(
131 signed: &SignedManifest,
132 trust_root: &[u8; 32],
133 user_grants: &HashSet<CapabilityName, S>,
134) -> Result<Admission, Error> {
135 let manifest = &signed.manifest;
136
137 let sig_b64 = signed.signature.as_ref().ok_or_else(|| Error::Unsigned {
139 name: manifest.name.clone(),
140 })?;
141
142 let verifying_key = VerifyingKey::from_bytes(trust_root)
146 .map_err(|e| Error::InvalidTrustRoot(format!("malformed Ed25519 public key: {e}")))?;
147 let sig_bytes = BASE64.decode(sig_b64).map_err(|_| Error::BadSignature {
148 name: manifest.name.clone(),
149 })?;
150 let signature = Signature::from_slice(&sig_bytes).map_err(|_| Error::BadSignature {
151 name: manifest.name.clone(),
152 })?;
153 let message = manifest
154 .canonical_bytes()
155 .map_err(|_| Error::BadSignature {
156 name: manifest.name.clone(),
157 })?;
158 verifying_key
159 .verify_strict(&message, &signature)
160 .map_err(|_| Error::BadSignature {
161 name: manifest.name.clone(),
162 })?;
163
164 manifest.validate()?;
166
167 let excess: Vec<CapabilityName> = manifest
169 .capabilities
170 .iter()
171 .filter(|cap| !user_grants.contains(*cap))
172 .cloned()
173 .collect();
174 if !excess.is_empty() {
175 return Err(Error::OverPrivileged {
176 name: manifest.name.clone(),
177 excess,
178 });
179 }
180
181 let declared: HashSet<CapabilityName> = manifest.capabilities.iter().cloned().collect();
182 Ok(Admission {
183 manifest: signed.clone(),
184 granted: declared,
185 signer: *trust_root,
186 })
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use ed25519_dalek::{Signer, SigningKey};
193
194 fn test_keypair() -> (SigningKey, [u8; 32]) {
195 let secret = [
197 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
198 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
199 0x1d, 0x1e, 0x1f, 0x20,
200 ];
201 let signing = SigningKey::from_bytes(&secret);
202 let verifying = signing.verifying_key().to_bytes();
203 (signing, verifying)
204 }
205
206 fn sign_manifest(manifest: &Manifest, signing: &SigningKey) -> SignedManifest {
207 let msg = manifest.canonical_bytes().unwrap();
208 let sig = signing.sign(&msg);
209 let sig_b64 = BASE64.encode(sig.to_bytes());
210 SignedManifest {
211 manifest: manifest.clone(),
212 signature: Some(sig_b64),
213 }
214 }
215
216 fn sample_manifest() -> Manifest {
217 Manifest {
218 name: String::from("test-skill"),
219 version: String::from("1.0.0"),
220 capabilities: vec![cap("net.egress")],
221 entry_point: String::from("main.py"),
222 }
223 }
224
225 fn cap(raw: &str) -> CapabilityName {
226 raw.parse().unwrap()
227 }
228
229 fn net_grants() -> HashSet<CapabilityName> {
230 HashSet::from([cap("net.egress")])
231 }
232
233 #[test]
234 fn valid_signature_admits() {
235 let (signing, verifying) = test_keypair();
236 let signed = sign_manifest(&sample_manifest(), &signing);
237 let admission = verify_admission(&signed, &verifying, &net_grants()).unwrap();
238 assert_eq!(admission.manifest().name, "test-skill");
239 assert_eq!(admission.capabilities(), &net_grants());
240 }
241
242 #[test]
243 fn an_admission_records_the_key_that_signed_it() {
244 use sha2::Digest as _;
245 let (signing, verifying) = test_keypair();
248 let signed = sign_manifest(&sample_manifest(), &signing);
249 let admission = verify_admission(&signed, &verifying, &net_grants()).unwrap();
250 assert_eq!(admission.signer(), &verifying);
251 let fingerprint = admission.signer_fingerprint();
252 assert_eq!(fingerprint.len(), 64);
253 assert!(
254 fingerprint
255 .chars()
256 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
257 );
258 let digest = sha2::Sha256::digest(verifying);
259 let expected: Vec<String> = digest.iter().map(|byte| format!("{byte:02x}")).collect();
260 let expected = expected.concat();
261 assert_eq!(fingerprint, expected);
262 let other = SigningKey::from_bytes(&[0x42; 32]);
264 let other_root = other.verifying_key().to_bytes();
265 let impostor = verify_admission(
266 &sign_manifest(&sample_manifest(), &other),
267 &other_root,
268 &net_grants(),
269 )
270 .unwrap();
271 assert_eq!(impostor.manifest().name, admission.manifest().name);
272 assert_ne!(impostor.signer_fingerprint(), fingerprint);
273 assert_eq!(impostor.signer(), &other_root);
274 }
275
276 #[test]
277 fn unsigned_is_refused() {
278 let (_, verifying) = test_keypair();
279 let signed = SignedManifest {
280 manifest: sample_manifest(),
281 signature: None,
282 };
283 let err = verify_admission(&signed, &verifying, &net_grants()).unwrap_err();
284 assert!(matches!(err, Error::Unsigned { .. }));
285 }
286
287 #[test]
288 fn tampered_is_refused() {
289 let (signing, verifying) = test_keypair();
290 let mut signed = sign_manifest(&sample_manifest(), &signing);
291 signed.manifest.version = String::from("9.9.9");
293 let err = verify_admission(&signed, &verifying, &net_grants()).unwrap_err();
294 assert!(matches!(err, Error::BadSignature { .. }));
295 }
296
297 #[test]
298 fn wrong_key_is_refused() {
299 let (signing, _) = test_keypair();
300 let other_secret = [
302 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae,
303 0xaf, 0xb0, 0xb1, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xbb, 0xbc,
304 0xbd, 0xbe, 0xbf, 0xc0,
305 ];
306 let other_signing = SigningKey::from_bytes(&other_secret);
307 let other_verifying = other_signing.verifying_key().to_bytes();
308 let signed = sign_manifest(&sample_manifest(), &signing);
309 let err = verify_admission(&signed, &other_verifying, &net_grants()).unwrap_err();
310 assert!(matches!(err, Error::BadSignature { .. }));
311 }
312
313 #[test]
314 fn signature_without_the_domain_tag_is_refused() {
315 let (signing, verifying) = test_keypair();
318 let manifest = sample_manifest();
319 let bare = serde_json::to_vec(&manifest).unwrap();
320 let signed = SignedManifest {
321 manifest,
322 signature: Some(BASE64.encode(signing.sign(&bare).to_bytes())),
323 };
324 let err = verify_admission(&signed, &verifying, &net_grants()).unwrap_err();
325 assert!(matches!(err, Error::BadSignature { .. }));
326 }
327
328 #[test]
329 fn trust_roots_are_checked_for_shape_and_strength() {
330 let (_, verifying) = test_keypair();
331 assert!(check_trust_root(&verifying).is_ok());
332 let mut identity = [0u8; 32];
333 identity[0] = 1;
334 assert!(matches!(
335 check_trust_root(&identity),
336 Err(Error::InvalidTrustRoot(reason)) if reason.contains("weak")
337 ));
338 }
339
340 #[test]
341 fn weak_trust_root_is_refused() {
342 let mut identity = [0u8; 32];
346 identity[0] = 1;
347 let mut sig = [0u8; 64];
348 sig[0] = 1;
349 let signed = SignedManifest {
350 manifest: sample_manifest(),
351 signature: Some(BASE64.encode(sig)),
352 };
353 let result = verify_admission(&signed, &identity, &net_grants());
354 assert!(
355 matches!(
356 result,
357 Err(Error::BadSignature { .. } | Error::InvalidTrustRoot(_))
358 ),
359 "a weak trust root must never admit: {result:?}"
360 );
361 }
362
363 #[test]
364 fn signed_but_invalid_manifest_is_refused() {
365 let (signing, verifying) = test_keypair();
367 let mut manifest = sample_manifest();
368 manifest.entry_point = String::from("../evil.py");
369 let signed = sign_manifest(&manifest, &signing);
370 let err = verify_admission(&signed, &verifying, &net_grants()).unwrap_err();
371 assert!(matches!(err, Error::InvalidManifest(_)), "{err:?}");
372 }
373
374 #[test]
375 fn over_privileged_is_refused_at_admission() {
376 let (signing, verifying) = test_keypair();
377 let mut manifest = sample_manifest();
378 manifest.capabilities = vec![cap("net.egress"), cap("fs.write")];
379 let signed = sign_manifest(&manifest, &signing);
380 let err = verify_admission(&signed, &verifying, &net_grants()).unwrap_err();
382 assert!(
383 matches!(err, Error::OverPrivileged { .. }),
384 "expected OverPrivileged, got {err:?}"
385 );
386 if let Error::OverPrivileged { name, excess } = err {
387 assert_eq!(name, "test-skill");
388 assert_eq!(excess, vec![cap("fs.write")]);
389 }
390 }
391
392 #[test]
393 fn undeclared_capability_denied_at_call_time() {
394 let (signing, verifying) = test_keypair();
395 let signed = sign_manifest(&sample_manifest(), &signing);
396 let admission = verify_admission(&signed, &verifying, &net_grants()).unwrap();
397 assert!(admission.check_call(&cap("net.egress")).is_ok());
399 let err = admission.check_call(&cap("fs.read")).unwrap_err();
401 assert!(
402 matches!(&err, Error::UndeclaredCapability { capability, .. } if *capability == cap("fs.read")),
403 "{err:?}"
404 );
405 }
406}