Skip to main content

heliosdb_proxy/
plugin_registry.rs

1//! Plugin registry resolution + one-command install (Batch H, item 78).
2//!
3//! The plugin runtime already has the whole trust pipeline — Ed25519 trust
4//! roots, SHA-256 manifests, a hot-reloading plugin directory. The only missing
5//! verb was *distribution*: getting a signed artefact from a catalog onto disk
6//! where hot-reload picks it up. This module is that verb.
7//!
8//! A registry is a JSON index listing `{name, version, artifact, sha256,
9//! signature?}`. `install` resolves an entry, fetches the artefact, checks its
10//! SHA-256 against the index, optionally verifies an Ed25519 signature against a
11//! trust root (reusing [`SignatureVerifier`]), and drops `<name>.wasm` (plus a
12//! `<name>.sig` sidecar when signed) into the destination plugins directory.
13//!
14//! Artefacts are resolved from **local / `file://` paths** (a private registry
15//! on a shared filesystem or an air-gapped mirror) or fetched over **`http://`**
16//! (a mirror, or a localhost TLS-terminating proxy). Because the index is a
17//! local trusted file, its `sha256` makes a plain-HTTP fetch integrity-safe — so
18//! no TLS stack is pulled in. `https://` artefacts return a clear error pointing
19//! at those options (a direct TLS client is the remaining follow-on).
20
21use std::path::{Path, PathBuf};
22
23/// A registry index file: a flat list of installable plugin artefacts.
24#[derive(Debug, serde::Deserialize)]
25pub struct RegistryIndex {
26    #[serde(default)]
27    pub schema_version: String,
28    pub plugins: Vec<RegistryEntry>,
29}
30
31/// One installable artefact in a [`RegistryIndex`].
32#[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    /// Artefact location: a path relative to the index file, an absolute path,
40    /// or a `file://` URL. (`https://` is rejected in this offline slice.)
41    pub artifact: String,
42    /// Lowercase hex SHA-256 of the artefact bytes.
43    pub sha256: String,
44    /// Base64 of the raw 64-byte Ed25519 signature over the artefact bytes.
45    /// Required when installing with a trust root.
46    #[serde(default)]
47    pub signature: Option<String>,
48}
49
50/// What an [`install`] produced.
51#[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    /// Trust-root key label that verified the signature, when verified.
59    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
72/// Verify an Ed25519 signature (base64 of the raw 64-byte signature) over
73/// `bytes` against any `*.pub` key in `trust_root` (each a base64 of a raw
74/// 32-byte Ed25519 public key — the same trust-root format the plugin loader
75/// uses). Returns the matching key's label. Self-contained so `install` needs
76/// no WASM runtime / `wasm-plugins` feature.
77fn 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
121/// Parse a registry index from disk.
122pub 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
128/// Resolve an entry's `artifact` field to a local path. Local/relative/`file://`
129/// only — `https://` is an explicit error in the offline slice.
130fn 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        // Relative paths resolve against the index file's directory.
142        let base = index_path.parent().unwrap_or_else(|| Path::new("."));
143        Ok(base.join(p))
144    }
145}
146
147/// Minimal blocking HTTP/1.1 GET for `http://host[:port]/path`, returning the
148/// response body. Dependency-free (std::net only) and intentionally tiny — the
149/// artefact's integrity comes from the index `sha256`, not the transport, so
150/// this needs no TLS and no redirect/keep-alive handling. `Connection: close`
151/// lets us read to EOF instead of parsing Content-Length / chunked encoding.
152fn 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
194/// Find an entry by name (optionally pinned to an exact version).
195pub 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
209/// Install a plugin from the registry into `dest_dir`.
210///
211/// Verifies the SHA-256 against the index; when `trust_root` is given the entry
212/// MUST carry a signature and it is verified against the trust root. On success
213/// writes `<name>.wasm` (+ `<name>.sig` when signed) into `dest_dir`.
214pub 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    // Fetch the artefact bytes. Local / file:// are read from disk; http:// is
225    // fetched over the network. Because the index is a *local, trusted* file
226    // (a `--registry` path), the `sha256` it pins makes a plain-HTTP fetch
227    // integrity-safe — a tampered download fails the hash check below.
228    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    // Integrity: the bytes must match the SHA-256 the index advertises.
244    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    // Authenticity: when a trust root is configured, require + verify a
253    // signature against it.
254    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/// What a [`verify`] produced.
289#[derive(Debug, Clone, PartialEq, Eq)]
290pub struct VerifyReport {
291    pub sha256: String,
292    /// Trust-root key label that verified the signature, when a trust root was
293    /// supplied (otherwise `None` — only the digest was computed).
294    pub signed_by: Option<String>,
295}
296
297/// Verify a local plugin artefact already on disk (a pre-deploy / audit check,
298/// distinct from `install`): compute its SHA-256 and, when a trust root is
299/// given, check its Ed25519 signature. The signature is read from `sig_path`,
300/// or a `<name>.sig` sidecar next to the artefact (the convention the loader
301/// uses: `path.with_extension("sig")`).
302pub 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
326/// Scaffold a new plugin source skeleton under `dir/<name>/`.
327///
328/// Writes a `plugin.yaml` manifest, a minimal Rust `src/lib.rs` hook stub, and a
329/// README so `helios-plugin new <name>` gives a buildable starting point.
330pub 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    /// Write an artefact + a registry index referencing it (relative path).
373    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        // Index claims the right hash, then we corrupt the artefact on disk.
406        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        // Trusted publisher signs the artefact.
420        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        // Trust root holds the official key; an attacker signs with another.
438        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); // unsigned entry
458
459        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        // `p.wasm` -> `p.sig` sidecar (with_extension), matching the loader.
503        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        // Sign the real bytes, but write tampered bytes to disk.
518        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        // Reject invalid names + double-create.
533        assert!(scaffold("bad name", dir.path()).is_err());
534        assert!(scaffold("my-plugin", dir.path()).is_err());
535    }
536}