lean_ctx/core/context_package/
signing.rs1use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
2
3use super::content::PackageContent;
4use super::manifest::{PackageManifest, PackageSignature};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum SignatureCoverage {
9 Manifest,
12 IdentityOnly,
16}
17
18pub 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
47pub 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
61pub(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
71pub(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
121fn manifest_message(manifest: &serde_json::Value) -> String {
123 crate::core::context_package::integrity::manifest_signing_message(manifest)
124}
125
126fn 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 #[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 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}