1use std::path::{Path, PathBuf};
22
23#[derive(Debug, serde::Deserialize)]
25pub struct RegistryIndex {
26 #[serde(default)]
27 pub schema_version: String,
28 pub plugins: Vec<RegistryEntry>,
29}
30
31#[derive(Debug, Clone, serde::Deserialize)]
33pub struct RegistryEntry {
34 pub name: String,
35 #[serde(default)]
36 pub version: String,
37 #[serde(default)]
38 pub description: String,
39 pub artifact: String,
42 pub sha256: String,
44 #[serde(default)]
47 pub signature: Option<String>,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct InstallReport {
53 pub name: String,
54 pub version: String,
55 pub wasm_path: PathBuf,
56 pub sig_path: Option<PathBuf>,
57 pub sha256: String,
58 pub signed_by: Option<String>,
60}
61
62fn sha256_hex(bytes: &[u8]) -> String {
63 use sha2::{Digest, Sha256};
64 let digest = Sha256::digest(bytes);
65 let mut s = String::with_capacity(64);
66 for b in digest.iter() {
67 s.push_str(&format!("{:02x}", b));
68 }
69 s
70}
71
72fn verify_against_trust_root(bytes: &[u8], sig_b64: &str, trust_root: &Path) -> Result<String, String> {
78 use base64::Engine as _;
79 use ed25519_dalek::{Signature, Verifier, VerifyingKey};
80
81 let b64 = base64::engine::general_purpose::STANDARD;
82 let sig_bytes = b64
83 .decode(sig_b64.trim())
84 .map_err(|e| format!("decode signature: {e}"))?;
85 let sig_arr: [u8; 64] = sig_bytes
86 .as_slice()
87 .try_into()
88 .map_err(|_| format!("signature must be 64 bytes, got {}", sig_bytes.len()))?;
89 let sig = Signature::from_bytes(&sig_arr);
90
91 let mut any = false;
92 let entries = std::fs::read_dir(trust_root)
93 .map_err(|e| format!("read trust root {}: {e}", trust_root.display()))?;
94 for ent in entries.flatten() {
95 let p = ent.path();
96 if p.extension().and_then(|e| e.to_str()) != Some("pub") {
97 continue;
98 }
99 let raw = std::fs::read_to_string(&p).map_err(|e| format!("read {}: {e}", p.display()))?;
100 let kb = b64
101 .decode(raw.trim())
102 .map_err(|e| format!("decode {}: {e}", p.display()))?;
103 let karr: [u8; 32] = kb
104 .as_slice()
105 .try_into()
106 .map_err(|_| format!("{} must hold a 32-byte pubkey", p.display()))?;
107 let vk =
108 VerifyingKey::from_bytes(&karr).map_err(|e| format!("{} invalid pubkey: {e}", p.display()))?;
109 any = true;
110 if vk.verify(bytes, &sig).is_ok() {
111 let label = p.file_stem().and_then(|s| s.to_str()).unwrap_or("?").to_string();
112 return Ok(label);
113 }
114 }
115 if !any {
116 return Err(format!("trust root {} has no *.pub keys", trust_root.display()));
117 }
118 Err("signature does not match any trusted key".to_string())
119}
120
121pub fn load_index(index_path: &Path) -> Result<RegistryIndex, String> {
123 let raw = std::fs::read_to_string(index_path)
124 .map_err(|e| format!("read registry index {}: {}", index_path.display(), e))?;
125 serde_json::from_str(&raw).map_err(|e| format!("parse registry index: {}", e))
126}
127
128fn resolve_artifact_path(index_path: &Path, artifact: &str) -> Result<PathBuf, String> {
131 if artifact.starts_with("http://") || artifact.starts_with("https://") {
132 return Err(format!(
133 "artefact {artifact} is remote; this build installs only local/file:// artefacts"
134 ));
135 }
136 let raw = artifact.strip_prefix("file://").unwrap_or(artifact);
137 let p = Path::new(raw);
138 if p.is_absolute() {
139 Ok(p.to_path_buf())
140 } else {
141 let base = index_path.parent().unwrap_or_else(|| Path::new("."));
143 Ok(base.join(p))
144 }
145}
146
147fn http_get(url: &str) -> Result<Vec<u8>, String> {
153 use std::io::{Read, Write};
154 let rest = url
155 .strip_prefix("http://")
156 .ok_or_else(|| format!("not an http:// url: {url}"))?;
157 let (authority, path) = match rest.find('/') {
158 Some(i) => (&rest[..i], &rest[i..]),
159 None => (rest, "/"),
160 };
161 let connect_addr = if authority.contains(':') {
162 authority.to_string()
163 } else {
164 format!("{authority}:80")
165 };
166 let mut stream = std::net::TcpStream::connect(&connect_addr)
167 .map_err(|e| format!("connect {connect_addr}: {e}"))?;
168 let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(30)));
169 let req = format!(
170 "GET {path} HTTP/1.1\r\nHost: {authority}\r\nUser-Agent: helios-plugin\r\n\
171 Accept: */*\r\nConnection: close\r\n\r\n"
172 );
173 stream
174 .write_all(req.as_bytes())
175 .map_err(|e| format!("send request: {e}"))?;
176 let mut buf = Vec::new();
177 stream
178 .read_to_end(&mut buf)
179 .map_err(|e| format!("read response from {connect_addr}: {e}"))?;
180
181 let sep = buf
182 .windows(4)
183 .position(|w| w == b"\r\n\r\n")
184 .ok_or_else(|| "malformed HTTP response (no header terminator)".to_string())?;
185 let status_line_end = buf.iter().position(|&b| b == b'\r').unwrap_or(0);
186 let status_line = std::str::from_utf8(&buf[..status_line_end]).unwrap_or("");
187 let code = status_line.split_whitespace().nth(1).unwrap_or("");
188 if code != "200" {
189 return Err(format!("HTTP {} fetching {url}", status_line.trim()));
190 }
191 Ok(buf[sep + 4..].to_vec())
192}
193
194pub fn find_entry<'a>(
196 index: &'a RegistryIndex,
197 name: &str,
198 version: Option<&str>,
199) -> Result<&'a RegistryEntry, String> {
200 let mut matches = index.plugins.iter().filter(|e| e.name == name);
201 match version {
202 Some(v) => matches
203 .find(|e| e.version == v)
204 .ok_or_else(|| format!("no plugin '{name}' at version '{v}' in registry")),
205 None => matches.next().ok_or_else(|| format!("no plugin '{name}' in registry")),
206 }
207}
208
209pub fn install(
215 index_path: &Path,
216 name: &str,
217 version: Option<&str>,
218 dest_dir: &Path,
219 trust_root: Option<&Path>,
220) -> Result<InstallReport, String> {
221 let index = load_index(index_path)?;
222 let entry = find_entry(&index, name, version)?.clone();
223
224 let bytes = if entry.artifact.starts_with("http://") {
229 http_get(&entry.artifact)?
230 } else if entry.artifact.starts_with("https://") {
231 return Err(format!(
232 "https:// artefact fetch is not supported in this build ({}); use an \
233 http:// URL (e.g. a localhost TLS-terminating mirror) or a file:// / local \
234 artefact — the index sha256 verifies the bytes regardless of transport",
235 entry.artifact
236 ));
237 } else {
238 let artifact_path = resolve_artifact_path(index_path, &entry.artifact)?;
239 std::fs::read(&artifact_path)
240 .map_err(|e| format!("read artefact {}: {}", artifact_path.display(), e))?
241 };
242
243 let actual = sha256_hex(&bytes);
245 if !actual.eq_ignore_ascii_case(&entry.sha256) {
246 return Err(format!(
247 "sha256 mismatch for '{name}': index={} actual={actual}",
248 entry.sha256
249 ));
250 }
251
252 let mut signed_by = None;
255 if let Some(root) = trust_root {
256 let sig = entry.signature.as_deref().ok_or_else(|| {
257 format!("'{name}' has no signature but a trust root was supplied")
258 })?;
259 let label = verify_against_trust_root(&bytes, sig, root)
260 .map_err(|e| format!("signature verification failed for '{name}': {e}"))?;
261 signed_by = Some(label);
262 }
263
264 std::fs::create_dir_all(dest_dir)
265 .map_err(|e| format!("create dest dir {}: {}", dest_dir.display(), e))?;
266 let wasm_path = dest_dir.join(format!("{name}.wasm"));
267 std::fs::write(&wasm_path, &bytes)
268 .map_err(|e| format!("write {}: {}", wasm_path.display(), e))?;
269
270 let sig_path = if let Some(sig) = entry.signature.as_deref() {
271 let p = dest_dir.join(format!("{name}.sig"));
272 std::fs::write(&p, sig).map_err(|e| format!("write {}: {}", p.display(), e))?;
273 Some(p)
274 } else {
275 None
276 };
277
278 Ok(InstallReport {
279 name: entry.name,
280 version: entry.version,
281 wasm_path,
282 sig_path,
283 sha256: actual,
284 signed_by,
285 })
286}
287
288#[derive(Debug, Clone, PartialEq, Eq)]
290pub struct VerifyReport {
291 pub sha256: String,
292 pub signed_by: Option<String>,
295}
296
297pub fn verify(
303 wasm_path: &Path,
304 trust_root: Option<&Path>,
305 sig_path: Option<&Path>,
306) -> Result<VerifyReport, String> {
307 let bytes = std::fs::read(wasm_path)
308 .map_err(|e| format!("read {}: {}", wasm_path.display(), e))?;
309 let sha256 = sha256_hex(&bytes);
310
311 let mut signed_by = None;
312 if let Some(root) = trust_root {
313 let sig_file = match sig_path {
314 Some(p) => p.to_path_buf(),
315 None => wasm_path.with_extension("sig"),
316 };
317 let sig = std::fs::read_to_string(&sig_file)
318 .map_err(|e| format!("read signature {}: {}", sig_file.display(), e))?;
319 let label = verify_against_trust_root(&bytes, sig.trim(), root)
320 .map_err(|e| format!("signature verification failed: {e}"))?;
321 signed_by = Some(label);
322 }
323 Ok(VerifyReport { sha256, signed_by })
324}
325
326pub fn scaffold(name: &str, dir: &Path) -> Result<PathBuf, String> {
331 if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
332 return Err(format!("invalid plugin name '{name}' (use [A-Za-z0-9_-])"));
333 }
334 let root = dir.join(name);
335 if root.exists() {
336 return Err(format!("{} already exists", root.display()));
337 }
338 std::fs::create_dir_all(root.join("src"))
339 .map_err(|e| format!("create {}: {}", root.display(), e))?;
340
341 let manifest = format!(
342 "name: {name}\nversion: 0.1.0\ndescription: A HeliosProxy plugin\nlicense: Apache-2.0\nhooks:\n - pre_query\npermissions: []\n"
343 );
344 std::fs::write(root.join("plugin.yaml"), manifest)
345 .map_err(|e| format!("write plugin.yaml: {e}"))?;
346
347 let lib_rs = "// Minimal HeliosProxy WASM plugin stub.\n// Build to wasm32-unknown-unknown, then `helios-plugin` pack + sign.\n//\n// Export the hooks named in plugin.yaml; the host calls pre_query(ptr,len)\n// before forwarding a query. Return 0 to allow, non-zero to block.\n#[no_mangle]\npub extern \"C\" fn pre_query(_ptr: i32, _len: i32) -> i32 {\n 0 // allow\n}\n";
348 std::fs::write(root.join("src/lib.rs"), lib_rs)
349 .map_err(|e| format!("write src/lib.rs: {e}"))?;
350
351 let readme = format!(
352 "# {name}\n\nA HeliosProxy WASM plugin.\n\n## Build\n\n```\ncargo build --release --target wasm32-unknown-unknown\n```\n\nThen pack + sign the resulting `.wasm` and add it to your registry index so\n`helios-plugin install {name}` can deploy it.\n"
353 );
354 std::fs::write(root.join("README.md"), readme)
355 .map_err(|e| format!("write README.md: {e}"))?;
356
357 Ok(root)
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use base64::Engine as _;
364 use ed25519_dalek::{Signer, SigningKey};
365
366 const WASM: &[u8] = b"\x00asm\x01\x00\x00\x00pretend-real-plugin-wasm";
367
368 fn b64(bytes: &[u8]) -> String {
369 base64::engine::general_purpose::STANDARD.encode(bytes)
370 }
371
372 fn make_registry(dir: &Path, signature: Option<&str>) -> PathBuf {
374 std::fs::write(dir.join("colmask.wasm"), WASM).unwrap();
375 let sig_field = signature
376 .map(|s| format!(",\n \"signature\": \"{s}\""))
377 .unwrap_or_default();
378 let index = format!(
379 "{{\n \"schema_version\": \"1\",\n \"plugins\": [\n {{\n \"name\": \"colmask\",\n \"version\": \"0.1.0\",\n \"artifact\": \"colmask.wasm\",\n \"sha256\": \"{}\"{sig_field}\n }}\n ]\n}}",
380 sha256_hex(WASM)
381 );
382 let index_path = dir.join("index.json");
383 std::fs::write(&index_path, index).unwrap();
384 index_path
385 }
386
387 #[test]
388 fn install_unsigned_lands_wasm() {
389 let src = tempfile::tempdir().unwrap();
390 let dst = tempfile::tempdir().unwrap();
391 let index = make_registry(src.path(), None);
392
393 let r = install(&index, "colmask", None, dst.path(), None).unwrap();
394 assert_eq!(r.name, "colmask");
395 assert!(r.wasm_path.exists());
396 assert!(r.sig_path.is_none());
397 assert!(r.signed_by.is_none());
398 assert_eq!(std::fs::read(&r.wasm_path).unwrap(), WASM);
399 }
400
401 #[test]
402 fn install_rejects_sha256_mismatch() {
403 let src = tempfile::tempdir().unwrap();
404 let dst = tempfile::tempdir().unwrap();
405 let index = make_registry(src.path(), None);
407 std::fs::write(src.path().join("colmask.wasm"), b"tampered").unwrap();
408
409 let err = install(&index, "colmask", None, dst.path(), None).unwrap_err();
410 assert!(err.contains("sha256 mismatch"), "{err}");
411 }
412
413 #[test]
414 fn install_verifies_signature_against_trust_root() {
415 let src = tempfile::tempdir().unwrap();
416 let dst = tempfile::tempdir().unwrap();
417 let trust = tempfile::tempdir().unwrap();
418
419 let key = SigningKey::from_bytes(&[7u8; 32]);
421 std::fs::write(trust.path().join("official.pub"), b64(&key.verifying_key().to_bytes()))
422 .unwrap();
423 let sig = b64(&key.sign(WASM).to_bytes());
424 let index = make_registry(src.path(), Some(&sig));
425
426 let r = install(&index, "colmask", None, dst.path(), Some(trust.path())).unwrap();
427 assert_eq!(r.signed_by.as_deref(), Some("official"));
428 assert!(r.sig_path.as_ref().unwrap().exists());
429 }
430
431 #[test]
432 fn install_rejects_untrusted_signature() {
433 let src = tempfile::tempdir().unwrap();
434 let dst = tempfile::tempdir().unwrap();
435 let trust = tempfile::tempdir().unwrap();
436
437 let official = SigningKey::from_bytes(&[7u8; 32]);
439 std::fs::write(trust.path().join("official.pub"), b64(&official.verifying_key().to_bytes()))
440 .unwrap();
441 let attacker = SigningKey::from_bytes(&[0xABu8; 32]);
442 let sig = b64(&attacker.sign(WASM).to_bytes());
443 let index = make_registry(src.path(), Some(&sig));
444
445 let err = install(&index, "colmask", None, dst.path(), Some(trust.path())).unwrap_err();
446 assert!(err.contains("signature verification failed"), "{err}");
447 }
448
449 #[test]
450 fn install_requires_signature_when_trust_root_set() {
451 let src = tempfile::tempdir().unwrap();
452 let dst = tempfile::tempdir().unwrap();
453 let trust = tempfile::tempdir().unwrap();
454 let key = SigningKey::from_bytes(&[7u8; 32]);
455 std::fs::write(trust.path().join("official.pub"), b64(&key.verifying_key().to_bytes()))
456 .unwrap();
457 let index = make_registry(src.path(), None); let err = install(&index, "colmask", None, dst.path(), Some(trust.path())).unwrap_err();
460 assert!(err.contains("no signature"), "{err}");
461 }
462
463 #[test]
464 fn rejects_remote_artifact_offline() {
465 let p = resolve_artifact_path(Path::new("/tmp/index.json"), "https://example/colmask.wasm");
466 assert!(p.unwrap_err().contains("remote"));
467 }
468
469 #[test]
470 fn install_rejects_https_artifact() {
471 let src = tempfile::tempdir().unwrap();
472 let dst = tempfile::tempdir().unwrap();
473 let index = src.path().join("idx.json");
474 std::fs::write(
475 &index,
476 r#"{"plugins":[{"name":"x","artifact":"https://example/x.wasm","sha256":"00"}]}"#,
477 )
478 .unwrap();
479 let err = install(&index, "x", None, dst.path(), None).unwrap_err();
480 assert!(err.contains("https://"), "{err}");
481 }
482
483 #[test]
484 fn verify_digest_only_without_trust_root() {
485 let dir = tempfile::tempdir().unwrap();
486 let wasm = dir.path().join("p.wasm");
487 std::fs::write(&wasm, WASM).unwrap();
488 let r = verify(&wasm, None, None).unwrap();
489 assert_eq!(r.sha256, sha256_hex(WASM));
490 assert!(r.signed_by.is_none());
491 }
492
493 #[test]
494 fn verify_signature_via_sidecar() {
495 let dir = tempfile::tempdir().unwrap();
496 let trust = tempfile::tempdir().unwrap();
497 let key = SigningKey::from_bytes(&[7u8; 32]);
498 std::fs::write(trust.path().join("official.pub"), b64(&key.verifying_key().to_bytes()))
499 .unwrap();
500 let wasm = dir.path().join("p.wasm");
501 std::fs::write(&wasm, WASM).unwrap();
502 std::fs::write(dir.path().join("p.sig"), b64(&key.sign(WASM).to_bytes())).unwrap();
504
505 let r = verify(&wasm, Some(trust.path()), None).unwrap();
506 assert_eq!(r.signed_by.as_deref(), Some("official"));
507 }
508
509 #[test]
510 fn verify_rejects_tampered_artifact() {
511 let dir = tempfile::tempdir().unwrap();
512 let trust = tempfile::tempdir().unwrap();
513 let key = SigningKey::from_bytes(&[7u8; 32]);
514 std::fs::write(trust.path().join("official.pub"), b64(&key.verifying_key().to_bytes()))
515 .unwrap();
516 let wasm = dir.path().join("p.wasm");
517 std::fs::write(dir.path().join("p.sig"), b64(&key.sign(WASM).to_bytes())).unwrap();
519 std::fs::write(&wasm, b"tampered-wasm").unwrap();
520
521 let err = verify(&wasm, Some(trust.path()), None).unwrap_err();
522 assert!(err.contains("signature verification failed"), "{err}");
523 }
524
525 #[test]
526 fn scaffold_creates_skeleton() {
527 let dir = tempfile::tempdir().unwrap();
528 let root = scaffold("my-plugin", dir.path()).unwrap();
529 assert!(root.join("plugin.yaml").exists());
530 assert!(root.join("src/lib.rs").exists());
531 assert!(root.join("README.md").exists());
532 assert!(scaffold("bad name", dir.path()).is_err());
534 assert!(scaffold("my-plugin", dir.path()).is_err());
535 }
536}