Skip to main content

lean_ctx/core/addons/
signing.rs

1//! Detached Ed25519 signing for the user-override registry (#865).
2//!
3//! The bundled registry is trusted by construction — it is compiled into the
4//! binary. The risk surface is the **user override**
5//! (`<data_dir>/addon_registry.json`): a local file that can *shadow* trusted
6//! addon names with attacker-controlled wiring.
7//!
8//! When `addons.require_signature` is on, an override is honoured only if a
9//! sidecar `addon_registry.json.sig` carries a valid signature **by a trusted
10//! org key** — the same pinned-key trust anchor as the signed org-policy floor
11//! ([`crate::core::policy::org::trust`]). This reuses the engine's Ed25519
12//! primitives (`crate::core::agent_identity`); the signature covers the exact
13//! file bytes, so any tampering invalidates it.
14
15use ed25519_dalek::SigningKey;
16use serde::{Deserialize, Serialize};
17
18use crate::core::agent_identity::{hex_decode, hex_encode, sign_bytes_with, verify_signature};
19
20/// A detached signature sidecar, written next to the registry file.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct RegistrySignature {
23    /// Ed25519 signature over the registry file's exact bytes (hex, 128 chars).
24    pub signature: String,
25    /// The verifying key of the signer (hex, 64 chars) — so the artifact is
26    /// self-describing; trust of this key is a *separate* check.
27    pub signer_public_key: String,
28}
29
30impl RegistrySignature {
31    /// Parse a `.sig` sidecar.
32    pub fn from_json(text: &str) -> Result<Self, String> {
33        serde_json::from_str(text).map_err(|e| format!("not a valid registry signature: {e}"))
34    }
35
36    /// Serialize to the pretty JSON sidecar.
37    pub fn to_json(&self) -> Result<String, String> {
38        serde_json::to_string_pretty(self).map_err(|e| format!("serialize signature: {e}"))
39    }
40}
41
42/// Sidecar path for a registry file (`<registry>.sig`).
43#[must_use]
44pub fn sidecar_path(registry_path: &std::path::Path) -> std::path::PathBuf {
45    let mut s = registry_path.as_os_str().to_os_string();
46    s.push(".sig");
47    std::path::PathBuf::from(s)
48}
49
50/// Sign `content` with `key`, embedding the public key. For maintainers / the
51/// `addon registry sign` path. Pure.
52#[must_use]
53pub fn sign_detached(content: &str, key: &SigningKey) -> RegistrySignature {
54    let sig = sign_bytes_with(key, content.as_bytes());
55    RegistrySignature {
56        signature: hex_encode(&sig),
57        signer_public_key: hex_encode(&key.verifying_key().to_bytes()),
58    }
59}
60
61/// Whether `sig` is a cryptographically valid signature of `content` by the
62/// embedded key. Says nothing about whether that key is *trusted*. Pure.
63#[must_use]
64pub fn signature_valid(content: &str, sig: &RegistrySignature) -> bool {
65    let (Ok(pk), Ok(s)) = (
66        hex_decode(&sig.signer_public_key),
67        hex_decode(&sig.signature),
68    ) else {
69        return false;
70    };
71    verify_signature(&pk, content.as_bytes(), &s)
72}
73
74/// Outcome of gating an override file against the signature policy.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum OverrideVerdict {
77    /// Honour the override (signatures not required, or signed + trusted).
78    Accept,
79    /// Ignore the override; carries a human-readable reason for the warning.
80    Reject(String),
81}
82
83/// Decide whether to honour an override given the file `content`, its optional
84/// sidecar `sig`, and whether signatures are required. `is_trusted` resolves a
85/// hex public key to trust (inject [`crate::core::policy::org::trust::is_trusted`]
86/// in production; a closure in tests). Pure.
87#[must_use]
88pub fn gate_override(
89    content: &str,
90    sig: Option<&RegistrySignature>,
91    require_signature: bool,
92    is_trusted: impl Fn(&str) -> bool,
93) -> OverrideVerdict {
94    if !require_signature {
95        return OverrideVerdict::Accept;
96    }
97    let Some(sig) = sig else {
98        return OverrideVerdict::Reject(
99            "addons.require_signature is on but the override registry has no .sig sidecar"
100                .to_string(),
101        );
102    };
103    if !signature_valid(content, sig) {
104        return OverrideVerdict::Reject(
105            "override registry signature is invalid (tampered or wrong key)".to_string(),
106        );
107    }
108    if !is_trusted(&sig.signer_public_key) {
109        return OverrideVerdict::Reject(format!(
110            "override registry signed by an untrusted key ({}…) — pin it with `policy org trust`",
111            sig.signer_public_key.chars().take(12).collect::<String>()
112        ));
113    }
114    OverrideVerdict::Accept
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    fn key() -> SigningKey {
122        SigningKey::from_bytes(&[7u8; 32])
123    }
124
125    #[test]
126    fn sign_then_verify_roundtrips() {
127        let k = key();
128        let content = "{\"addons\":[]}";
129        let sig = sign_detached(content, &k);
130        assert!(signature_valid(content, &sig));
131    }
132
133    #[test]
134    fn tampered_content_fails() {
135        let sig = sign_detached("original", &key());
136        assert!(!signature_valid("tampered", &sig));
137    }
138
139    #[test]
140    fn sidecar_path_appends_sig() {
141        let p = sidecar_path(std::path::Path::new("/x/addon_registry.json"));
142        assert_eq!(p, std::path::PathBuf::from("/x/addon_registry.json.sig"));
143    }
144
145    #[test]
146    fn gate_accepts_when_not_required() {
147        assert_eq!(
148            gate_override("anything", None, false, |_| false),
149            OverrideVerdict::Accept
150        );
151    }
152
153    #[test]
154    fn gate_rejects_missing_sidecar() {
155        assert!(matches!(
156            gate_override("x", None, true, |_| true),
157            OverrideVerdict::Reject(_)
158        ));
159    }
160
161    #[test]
162    fn gate_rejects_invalid_signature() {
163        let bad = RegistrySignature {
164            signature: "00".repeat(64),
165            signer_public_key: hex_encode(&key().verifying_key().to_bytes()),
166        };
167        assert!(matches!(
168            gate_override("content", Some(&bad), true, |_| true),
169            OverrideVerdict::Reject(_)
170        ));
171    }
172
173    #[test]
174    fn gate_rejects_untrusted_signer() {
175        let content = "content";
176        let sig = sign_detached(content, &key());
177        assert!(matches!(
178            gate_override(content, Some(&sig), true, |_| false),
179            OverrideVerdict::Reject(_)
180        ));
181    }
182
183    #[test]
184    fn gate_accepts_signed_and_trusted() {
185        let content = "content";
186        let sig = sign_detached(content, &key());
187        let trusted_pk = sig.signer_public_key.clone();
188        assert_eq!(
189            gate_override(content, Some(&sig), true, |pk| pk == trusted_pk),
190            OverrideVerdict::Accept
191        );
192    }
193}