Skip to main content

lean_ctx/core/context_package/
signing.rs

1use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
2
3use super::content::PackageContent;
4use super::manifest::{PackageManifest, PackageSignature};
5
6/// What a valid signature vouches for.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum SignatureCoverage {
9    /// Every manifest field (kind, visibility, dependencies, scope,
10    /// provenance, licence, compatibility, …) besides the signature.
11    Manifest,
12    /// Name, version and package digest only (packages signed before
13    /// manifest signatures existed). The other manifest fields can have been
14    /// changed after signing.
15    IdentityOnly,
16}
17
18/// Sign `manifest` twice with one key: `value` over name, version and package
19/// digest — what every existing verifier and registry checks — and
20/// `manifest_value` over the whole manifest without its signature.
21pub fn sign_package(
22    manifest: &mut PackageManifest,
23    _content: &PackageContent,
24    signing_key: &SigningKey,
25) {
26    manifest.signature = None;
27    let unsigned = serde_json::to_value(&*manifest).expect("a manifest serializes");
28    let whole = signing_key.sign(manifest_message(&unsigned).as_bytes());
29    let identity = signing_key.sign(
30        identity_message(
31            &manifest.name,
32            &manifest.version,
33            &manifest.integrity.sha256,
34        )
35        .as_bytes(),
36    );
37    let public_key = VerifyingKey::from(signing_key);
38
39    manifest.signature = Some(PackageSignature {
40        algorithm: "ed25519".into(),
41        public_key: to_hex(public_key.as_bytes()),
42        value: to_hex(&identity.to_bytes()),
43        manifest_value: Some(to_hex(&whole.to_bytes())),
44    });
45}
46
47/// K02: what a package with signature `coverage` lacks — nothing when the
48/// signature covers the whole manifest. Local installs accept such a package
49/// and show this as a warning.
50pub fn signature_gap(coverage: Option<SignatureCoverage>) -> Option<&'static str> {
51    match coverage {
52        Some(SignatureCoverage::Manifest) => None,
53        Some(SignatureCoverage::IdentityOnly) => Some(
54            "identity-only signature (v1: covers name, version and package hash, not the \
55             manifest). Re-sign with `lean-ctx pack export <name> --sign`",
56        ),
57        None => Some("unsigned. Sign with `lean-ctx pack export <name> --sign`"),
58    }
59}
60
61/// [`signature_coverage_of`] for a manifest this engine parsed. Prefer the raw
62/// form where it exists: a field a newer writer added and this engine does
63/// not know is covered by the manifest signature but lost in the parsed form.
64pub(crate) fn signature_coverage(
65    manifest: &PackageManifest,
66) -> Result<Option<SignatureCoverage>, String> {
67    let raw = serde_json::to_value(manifest).map_err(|e| e.to_string())?;
68    signature_coverage_of(&raw)
69}
70
71/// What the signature of the manifest `raw` (as it was written) covers,
72/// `None` when it is unsigned or a signature it carries does not verify.
73/// Both signatures must hold when the manifest signature is present; removing
74/// it leaves an identity-only signature, reported as such and never as
75/// covering the manifest.
76pub(crate) fn signature_coverage_of(
77    raw: &serde_json::Value,
78) -> Result<Option<SignatureCoverage>, String> {
79    let Some(sig) = raw.get("signature").filter(|sig| !sig.is_null()) else {
80        return Ok(None);
81    };
82    let sig: PackageSignature =
83        serde_json::from_value(sig.clone()).map_err(|e| format!("invalid signature: {e}"))?;
84    if sig.algorithm != "ed25519" {
85        return Err(format!(
86            "unsupported signature algorithm: {}",
87            sig.algorithm
88        ));
89    }
90    let text = |pointer: &str| raw.pointer(pointer).and_then(serde_json::Value::as_str);
91    let (Some(name), Some(version), Some(digest)) =
92        (text("/name"), text("/version"), text("/integrity/sha256"))
93    else {
94        return Err("signed manifest lacks name, version or integrity.sha256".into());
95    };
96
97    let pk_bytes = from_hex(&sig.public_key).map_err(|e| format!("invalid public_key hex: {e}"))?;
98    let pk_array: [u8; 32] = pk_bytes
99        .try_into()
100        .map_err(|_| "public_key must be 32 bytes".to_string())?;
101    let verifying_key =
102        VerifyingKey::from_bytes(&pk_array).map_err(|e| format!("invalid public key: {e}"))?;
103    let holds = |value: &str, message: &str| -> Result<bool, String> {
104        let sig_bytes = from_hex(value).map_err(|e| format!("invalid signature hex: {e}"))?;
105        let sig_array: [u8; 64] = sig_bytes
106            .try_into()
107            .map_err(|_| "signature must be 64 bytes".to_string())?;
108        let signature = ed25519_dalek::Signature::from_bytes(&sig_array);
109        Ok(verifying_key.verify(message.as_bytes(), &signature).is_ok())
110    };
111
112    if !holds(&sig.value, &identity_message(name, version, digest))? {
113        return Ok(None);
114    }
115    let Some(manifest_value) = sig.manifest_value.as_deref() else {
116        return Ok(Some(SignatureCoverage::IdentityOnly));
117    };
118    Ok(holds(manifest_value, &manifest_message(raw))?.then_some(SignatureCoverage::Manifest))
119}
120
121/// The manifest signature message (`super::integrity`).
122fn manifest_message(manifest: &serde_json::Value) -> String {
123    crate::core::context_package::integrity::manifest_signing_message(manifest)
124}
125
126/// The identity signature message: name, version and package digest.
127fn identity_message(name: &str, version: &str, digest: &str) -> String {
128    crate::core::context_package::integrity::identity_signing_message(name, version, digest)
129}
130
131fn to_hex(bytes: &[u8]) -> String {
132    use std::fmt::Write;
133    let mut s = String::with_capacity(bytes.len() * 2);
134    for b in bytes {
135        let _ = write!(s, "{b:02x}");
136    }
137    s
138}
139
140fn from_hex(s: &str) -> Result<Vec<u8>, String> {
141    if !s.len().is_multiple_of(2) {
142        return Err("odd-length hex string".into());
143    }
144    if !super::manifest::is_lower_hex(s, s.len()) {
145        return Err("hex must be lowercase 0-9a-f (spec §9)".into());
146    }
147    (0..s.len())
148        .step_by(2)
149        .map(|i| {
150            u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| format!("hex decode at {i}: {e}"))
151        })
152        .collect()
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::core::context_package::content::PackageContent;
159    use crate::core::context_package::manifest::{
160        PackageIntegrity, PackageLayer, PackageProvenance,
161    };
162    use chrono::Utc;
163
164    fn test_manifest() -> PackageManifest {
165        PackageManifest {
166            schema_version: 1,
167            conformance_level: None,
168            kind: crate::core::context_package::manifest::PackageKind::default(),
169            name: "test-pkg".into(),
170            version: "1.0.0".into(),
171            description: "test".into(),
172            author: None,
173            scope: None,
174            created_at: Utc::now(),
175            updated_at: None,
176            layers: vec![PackageLayer::Knowledge],
177            dependencies: vec![],
178            tags: vec![],
179            visibility: None,
180            integrity: PackageIntegrity {
181                sha256: "a".repeat(64),
182                content_hash: "b".repeat(64),
183                byte_size: 100,
184            },
185            provenance: PackageProvenance {
186                tool: "test".into(),
187                tool_version: "0.0.1".into(),
188                project_hash: None,
189                source_session_id: None,
190            },
191            compatibility: crate::core::context_package::manifest::CompatibilitySpec::default(),
192            stats: crate::core::context_package::manifest::PackageStats::default(),
193            signature: None,
194            graph_summary: None,
195            marketplace: None,
196            redistributable: None,
197        }
198    }
199
200    #[test]
201    fn sign_and_verify_roundtrip() {
202        let signing_key = SigningKey::from_bytes(&[42u8; 32]);
203        let content = PackageContent::default();
204        let mut manifest = test_manifest();
205
206        sign_package(&mut manifest, &content, &signing_key);
207
208        assert!(manifest.signature.is_some());
209        let sig = manifest.signature.as_ref().unwrap();
210        assert_eq!(sig.algorithm, "ed25519");
211        assert_eq!(sig.public_key.len(), 64);
212        assert_eq!(sig.value.len(), 128);
213
214        let result = signature_coverage(&manifest).unwrap().is_some();
215        assert!(result);
216    }
217
218    #[test]
219    fn unsigned_returns_false() {
220        let manifest = test_manifest();
221        let result = signature_coverage(&manifest).unwrap().is_some();
222        assert!(!result);
223    }
224
225    #[test]
226    fn tampered_name_fails_verification() {
227        let signing_key = SigningKey::from_bytes(&[42u8; 32]);
228        let content = PackageContent::default();
229        let mut manifest = test_manifest();
230
231        sign_package(&mut manifest, &content, &signing_key);
232        manifest.name = "tampered".into();
233
234        let result = signature_coverage(&manifest).unwrap().is_some();
235        assert!(!result);
236    }
237
238    /// Regression (AP13 K-P0-1): the signature covered only name, version and
239    /// digest, so visibility, kind or dependencies could be changed after
240    /// signing. The manifest signature covers them; without it a package
241    /// verifies as identity only, never as covering the manifest.
242    #[test]
243    fn signature_covers_the_whole_manifest_and_v1_reports_its_narrow_scope() {
244        let signing_key = SigningKey::from_bytes(&[42u8; 32]);
245        let mut manifest = test_manifest();
246        sign_package(&mut manifest, &PackageContent::default(), &signing_key);
247        assert_eq!(
248            signature_coverage(&manifest).unwrap(),
249            Some(SignatureCoverage::Manifest)
250        );
251        let mut tampered = manifest.clone();
252        tampered.visibility = Some("public".into());
253        assert_eq!(signature_coverage(&tampered).unwrap(), None);
254        // Removing the manifest signature leaves identity coverage only, as
255        // for a package signed before manifest signatures existed.
256        let mut stripped = tampered;
257        stripped.signature.as_mut().unwrap().manifest_value = None;
258        assert_eq!(
259            signature_coverage(&stripped).unwrap(),
260            Some(SignatureCoverage::IdentityOnly)
261        );
262    }
263
264    #[test]
265    fn hex_roundtrip() {
266        let bytes = vec![0x01, 0xab, 0xff, 0x00];
267        let hex_str = to_hex(&bytes);
268        assert_eq!(hex_str, "01abff00");
269        let decoded = from_hex(&hex_str).unwrap();
270        assert_eq!(decoded, bytes);
271    }
272}