Skip to main content

hatchling_skill/
verify.rs

1//! Broker-side verification and admission.
2//!
3//! The broker verifies, the cell executes. Signature checks and
4//! capability enforcement live on the privileged side; the cell
5//! never self-attests.
6//!
7//! Admission gates, in order:
8//! 1. **Signature present**: unsigned → refused.
9//! 2. **Signature verifies** (`verify_strict`) over the domain-tagged v1
10//!    payload, under the trust root: bad or foreign signature, or a weak
11//!    trust root → refused.
12//! 3. **Manifest valid**: the signed fields a loader acts on are sane
13//!    (see [`Manifest::validate`]) → otherwise refused, signed or not.
14//! 4. **Grant check**: the declared capabilities must be a subset of the
15//!    user's grants. Over-privileged → refused at admission.
16//!
17//! Call-time enforcement is separate: every skill tool call re-checks
18//! the capability against the admitted manifest's declarations.
19//! A skill calling an undeclared capability is denied at call time,
20//! not just at admission. (The 380-class lesson: a check that exists
21//! at admission but not at use is a missing check.)
22
23use 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/// A successfully admitted skill: the verified manifest plus the
32/// capabilities the broker will enforce at call time.
33///
34/// Only [`verify_admission`] makes one. The fields are private, so
35/// holding an `Admission` proves the manifest passed every gate, and its
36/// capability set cannot be widened afterwards.
37#[derive(Debug, Clone)]
38pub struct Admission {
39    manifest: SignedManifest,
40    granted: HashSet<CapabilityName>,
41    signer: [u8; 32],
42}
43
44impl Admission {
45    /// The verified manifest.
46    #[must_use]
47    pub const fn manifest(&self) -> &Manifest {
48        &self.manifest.manifest
49    }
50
51    /// The trust root whose key verified the manifest's signature: the
52    /// skill's signing identity, as distinct from its name.
53    #[must_use]
54    pub const fn signer(&self) -> &[u8; 32] {
55        &self.signer
56    }
57
58    /// The signing identity as a stable display form: the SHA-256 of the
59    /// verifying key, lowercase hex. Two skills share it exactly when the
60    /// same key signed both, whatever their names.
61    #[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    /// The declared capabilities that call-time checks enforce.
74    #[must_use]
75    pub const fn capabilities(&self) -> &HashSet<CapabilityName> {
76        &self.granted
77    }
78
79    /// Check a capability at call time. Returns `Ok(())` if the
80    /// capability was declared in the manifest; otherwise a typed
81    /// denial.
82    ///
83    /// # Errors
84    /// Returns [`Error::UndeclaredCapability`] if the capability was
85    /// not declared in the manifest.
86    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
98/// Checks that `root` can serve as a trust root: a valid Ed25519 point,
99/// and not a weak (small-order) key, under which a signature can verify
100/// for any message.
101///
102/// # Errors
103/// Returns [`Error::InvalidTrustRoot`] saying which.
104pub 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
115/// Verify a signed manifest and admit it against the user's grants.
116///
117/// # Arguments
118/// * `signed` — the parsed signed manifest
119/// * `trust_root` — the Ed25519 verifying key (32 bytes) that must
120///   have signed the manifest
121/// * `user_grants` — the capabilities the user has granted; the
122///   manifest's declarations must be a subset
123///
124/// # Errors
125/// * `Unsigned` — no signature block
126/// * `BadSignature` — signature present but invalid (or a weak key)
127/// * `InvalidTrustRoot` — the trust root is not an Ed25519 point
128/// * `InvalidManifest` — a signed field fails [`Manifest::validate`]
129/// * `OverPrivileged` — declarations exceed user grants
130pub 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    // Gate 1: signature must be present.
138    let sig_b64 = signed.signature.as_ref().ok_or_else(|| Error::Unsigned {
139        name: manifest.name.clone(),
140    })?;
141
142    // Gate 2: signature must verify, strictly: `verify_strict` also
143    // refuses weak (small-order) keys and signature points, under which
144    // plain `verify` can accept a signature for any message.
145    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    // Gate 3: the signed fields a loader acts on must be sane.
165    manifest.validate()?;
166
167    // Gate 4: declarations must be a subset of user grants.
168    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        // Fixed test key: 32 bytes, deterministic for tests.
196        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        // the signing identity survives admission, so a
246        // builder's provenance can name it rather than its display name.
247        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        // Same name, another key: another identity.
263        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        // Flip one byte in the manifest after signing.
292        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        // Different fixed key.
301        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        // A signature over the bare JSON (no v1 domain tag) must not
316        // verify: the tag is part of what is signed.
317        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        // The identity point as the trust root, with R = identity and
343        // S = 0: the verification equation holds for every message, so
344        // only a strict verifier refuses it.
345        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        // Signing does not make an unsafe entry point safe.
366        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        // User only granted net.egress, not fs.write.
381        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        // Declared capability passes.
398        assert!(admission.check_call(&cap("net.egress")).is_ok());
399        // Undeclared capability is denied.
400        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}