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(
78    bytes: &[u8],
79    sig_b64: &str,
80    trust_root: &Path,
81) -> Result<String, String> {
82    use base64::Engine as _;
83    use ed25519_dalek::{Signature, Verifier, VerifyingKey};
84
85    let b64 = base64::engine::general_purpose::STANDARD;
86    let sig_bytes = b64
87        .decode(sig_b64.trim())
88        .map_err(|e| format!("decode signature: {e}"))?;
89    let sig_arr: [u8; 64] = sig_bytes
90        .as_slice()
91        .try_into()
92        .map_err(|_| format!("signature must be 64 bytes, got {}", sig_bytes.len()))?;
93    let sig = Signature::from_bytes(&sig_arr);
94
95    let mut any = false;
96    let entries = std::fs::read_dir(trust_root)
97        .map_err(|e| format!("read trust root {}: {e}", trust_root.display()))?;
98    for ent in entries.flatten() {
99        let p = ent.path();
100        if p.extension().and_then(|e| e.to_str()) != Some("pub") {
101            continue;
102        }
103        let raw = std::fs::read_to_string(&p).map_err(|e| format!("read {}: {e}", p.display()))?;
104        let kb = b64
105            .decode(raw.trim())
106            .map_err(|e| format!("decode {}: {e}", p.display()))?;
107        let karr: [u8; 32] = kb
108            .as_slice()
109            .try_into()
110            .map_err(|_| format!("{} must hold a 32-byte pubkey", p.display()))?;
111        let vk = VerifyingKey::from_bytes(&karr)
112            .map_err(|e| format!("{} invalid pubkey: {e}", p.display()))?;
113        any = true;
114        if vk.verify(bytes, &sig).is_ok() {
115            let label = p
116                .file_stem()
117                .and_then(|s| s.to_str())
118                .unwrap_or("?")
119                .to_string();
120            return Ok(label);
121        }
122    }
123    if !any {
124        return Err(format!(
125            "trust root {} has no *.pub keys",
126            trust_root.display()
127        ));
128    }
129    Err("signature does not match any trusted key".to_string())
130}
131
132/// Parse a registry index from disk.
133pub fn load_index(index_path: &Path) -> Result<RegistryIndex, String> {
134    let raw = std::fs::read_to_string(index_path)
135        .map_err(|e| format!("read registry index {}: {}", index_path.display(), e))?;
136    serde_json::from_str(&raw).map_err(|e| format!("parse registry index: {}", e))
137}
138
139/// Resolve an entry's `artifact` field to a local path. Local/relative/`file://`
140/// only — `https://` is an explicit error in the offline slice.
141fn resolve_artifact_path(index_path: &Path, artifact: &str) -> Result<PathBuf, String> {
142    if artifact.starts_with("http://") || artifact.starts_with("https://") {
143        return Err(format!(
144            "artefact {artifact} is remote; this build installs only local/file:// artefacts"
145        ));
146    }
147    let raw = artifact.strip_prefix("file://").unwrap_or(artifact);
148    let p = Path::new(raw);
149    if p.is_absolute() {
150        Ok(p.to_path_buf())
151    } else {
152        // Relative paths resolve against the index file's directory.
153        let base = index_path.parent().unwrap_or_else(|| Path::new("."));
154        Ok(base.join(p))
155    }
156}
157
158/// Minimal blocking HTTP/1.1 GET for `http://host[:port]/path`, returning the
159/// response body. Dependency-free (std::net only) and intentionally tiny — the
160/// artefact's integrity comes from the index `sha256`, not the transport, so
161/// this needs no TLS and no redirect/keep-alive handling. `Connection: close`
162/// lets us read to EOF instead of parsing Content-Length / chunked encoding.
163fn http_get(url: &str) -> Result<Vec<u8>, String> {
164    use std::io::{Read, Write};
165    let rest = url
166        .strip_prefix("http://")
167        .ok_or_else(|| format!("not an http:// url: {url}"))?;
168    let (authority, path) = match rest.find('/') {
169        Some(i) => (&rest[..i], &rest[i..]),
170        None => (rest, "/"),
171    };
172    let connect_addr = if authority.contains(':') {
173        authority.to_string()
174    } else {
175        format!("{authority}:80")
176    };
177    let mut stream = std::net::TcpStream::connect(&connect_addr)
178        .map_err(|e| format!("connect {connect_addr}: {e}"))?;
179    let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(30)));
180    let req = format!(
181        "GET {path} HTTP/1.1\r\nHost: {authority}\r\nUser-Agent: helios-plugin\r\n\
182         Accept: */*\r\nConnection: close\r\n\r\n"
183    );
184    stream
185        .write_all(req.as_bytes())
186        .map_err(|e| format!("send request: {e}"))?;
187    let mut buf = Vec::new();
188    stream
189        .read_to_end(&mut buf)
190        .map_err(|e| format!("read response from {connect_addr}: {e}"))?;
191
192    let sep = buf
193        .windows(4)
194        .position(|w| w == b"\r\n\r\n")
195        .ok_or_else(|| "malformed HTTP response (no header terminator)".to_string())?;
196    let status_line_end = buf.iter().position(|&b| b == b'\r').unwrap_or(0);
197    let status_line = std::str::from_utf8(&buf[..status_line_end]).unwrap_or("");
198    let code = status_line.split_whitespace().nth(1).unwrap_or("");
199    if code != "200" {
200        return Err(format!("HTTP {} fetching {url}", status_line.trim()));
201    }
202    Ok(buf[sep + 4..].to_vec())
203}
204
205/// Find an entry by name (optionally pinned to an exact version).
206pub fn find_entry<'a>(
207    index: &'a RegistryIndex,
208    name: &str,
209    version: Option<&str>,
210) -> Result<&'a RegistryEntry, String> {
211    let mut matches = index.plugins.iter().filter(|e| e.name == name);
212    match version {
213        Some(v) => matches
214            .find(|e| e.version == v)
215            .ok_or_else(|| format!("no plugin '{name}' at version '{v}' in registry")),
216        None => matches
217            .next()
218            .ok_or_else(|| format!("no plugin '{name}' in registry")),
219    }
220}
221
222/// Install a plugin from the registry into `dest_dir`.
223///
224/// Verifies the SHA-256 against the index; when `trust_root` is given the entry
225/// MUST carry a signature and it is verified against the trust root. On success
226/// writes `<name>.wasm` (+ `<name>.sig` when signed) into `dest_dir`.
227pub fn install(
228    index_path: &Path,
229    name: &str,
230    version: Option<&str>,
231    dest_dir: &Path,
232    trust_root: Option<&Path>,
233) -> Result<InstallReport, String> {
234    let index = load_index(index_path)?;
235    let entry = find_entry(&index, name, version)?.clone();
236
237    // Fetch the artefact bytes. Local / file:// are read from disk; http:// is
238    // fetched over the network. Because the index is a *local, trusted* file
239    // (a `--registry` path), the `sha256` it pins makes a plain-HTTP fetch
240    // integrity-safe — a tampered download fails the hash check below.
241    let bytes = if entry.artifact.starts_with("http://") {
242        http_get(&entry.artifact)?
243    } else if entry.artifact.starts_with("https://") {
244        return Err(format!(
245            "https:// artefact fetch is not supported in this build ({}); use an \
246             http:// URL (e.g. a localhost TLS-terminating mirror) or a file:// / local \
247             artefact — the index sha256 verifies the bytes regardless of transport",
248            entry.artifact
249        ));
250    } else {
251        let artifact_path = resolve_artifact_path(index_path, &entry.artifact)?;
252        std::fs::read(&artifact_path)
253            .map_err(|e| format!("read artefact {}: {}", artifact_path.display(), e))?
254    };
255
256    // Integrity: the bytes must match the SHA-256 the index advertises.
257    let actual = sha256_hex(&bytes);
258    if !actual.eq_ignore_ascii_case(&entry.sha256) {
259        return Err(format!(
260            "sha256 mismatch for '{name}': index={} actual={actual}",
261            entry.sha256
262        ));
263    }
264
265    // Authenticity: when a trust root is configured, require + verify a
266    // signature against it.
267    let mut signed_by = None;
268    if let Some(root) = trust_root {
269        let sig = entry
270            .signature
271            .as_deref()
272            .ok_or_else(|| format!("'{name}' has no signature but a trust root was supplied"))?;
273        let label = verify_against_trust_root(&bytes, sig, root)
274            .map_err(|e| format!("signature verification failed for '{name}': {e}"))?;
275        signed_by = Some(label);
276    }
277
278    std::fs::create_dir_all(dest_dir)
279        .map_err(|e| format!("create dest dir {}: {}", dest_dir.display(), e))?;
280    let wasm_path = dest_dir.join(format!("{name}.wasm"));
281    std::fs::write(&wasm_path, &bytes)
282        .map_err(|e| format!("write {}: {}", wasm_path.display(), e))?;
283
284    let sig_path = if let Some(sig) = entry.signature.as_deref() {
285        let p = dest_dir.join(format!("{name}.sig"));
286        std::fs::write(&p, sig).map_err(|e| format!("write {}: {}", p.display(), e))?;
287        Some(p)
288    } else {
289        None
290    };
291
292    Ok(InstallReport {
293        name: entry.name,
294        version: entry.version,
295        wasm_path,
296        sig_path,
297        sha256: actual,
298        signed_by,
299    })
300}
301
302/// What a [`verify`] produced.
303#[derive(Debug, Clone, PartialEq, Eq)]
304pub struct VerifyReport {
305    pub sha256: String,
306    /// Trust-root key label that verified the signature, when a trust root was
307    /// supplied (otherwise `None` — only the digest was computed).
308    pub signed_by: Option<String>,
309}
310
311/// Verify a local plugin artefact already on disk (a pre-deploy / audit check,
312/// distinct from `install`): compute its SHA-256 and, when a trust root is
313/// given, check its Ed25519 signature. The signature is read from `sig_path`,
314/// or a `<name>.sig` sidecar next to the artefact (the convention the loader
315/// uses: `path.with_extension("sig")`).
316pub fn verify(
317    wasm_path: &Path,
318    trust_root: Option<&Path>,
319    sig_path: Option<&Path>,
320) -> Result<VerifyReport, String> {
321    let bytes =
322        std::fs::read(wasm_path).map_err(|e| format!("read {}: {}", wasm_path.display(), e))?;
323    let sha256 = sha256_hex(&bytes);
324
325    let mut signed_by = None;
326    if let Some(root) = trust_root {
327        let sig_file = match sig_path {
328            Some(p) => p.to_path_buf(),
329            None => wasm_path.with_extension("sig"),
330        };
331        let sig = std::fs::read_to_string(&sig_file)
332            .map_err(|e| format!("read signature {}: {}", sig_file.display(), e))?;
333        let label = verify_against_trust_root(&bytes, sig.trim(), root)
334            .map_err(|e| format!("signature verification failed: {e}"))?;
335        signed_by = Some(label);
336    }
337    Ok(VerifyReport { sha256, signed_by })
338}
339
340/// Scaffold a new plugin source skeleton under `dir/<name>/`.
341///
342/// Writes a `plugin.yaml` manifest, a minimal Rust `src/lib.rs` hook stub, and a
343/// README so `helios-plugin new <name>` gives a buildable starting point.
344pub fn scaffold(name: &str, dir: &Path) -> Result<PathBuf, String> {
345    if name.is_empty()
346        || !name
347            .chars()
348            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
349    {
350        return Err(format!("invalid plugin name '{name}' (use [A-Za-z0-9_-])"));
351    }
352    let root = dir.join(name);
353    if root.exists() {
354        return Err(format!("{} already exists", root.display()));
355    }
356    std::fs::create_dir_all(root.join("src"))
357        .map_err(|e| format!("create {}: {}", root.display(), e))?;
358
359    let manifest = format!(
360        "name: {name}\nversion: 0.1.0\ndescription: A HeliosProxy plugin\nlicense: Apache-2.0\nhooks:\n  - pre_query\npermissions: []\n"
361    );
362    std::fs::write(root.join("plugin.yaml"), manifest)
363        .map_err(|e| format!("write plugin.yaml: {e}"))?;
364
365    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";
366    std::fs::write(root.join("src/lib.rs"), lib_rs)
367        .map_err(|e| format!("write src/lib.rs: {e}"))?;
368
369    let readme = format!(
370        "# {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"
371    );
372    std::fs::write(root.join("README.md"), readme).map_err(|e| format!("write README.md: {e}"))?;
373
374    Ok(root)
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use base64::Engine as _;
381    use ed25519_dalek::{Signer, SigningKey};
382
383    const WASM: &[u8] = b"\x00asm\x01\x00\x00\x00pretend-real-plugin-wasm";
384
385    fn b64(bytes: &[u8]) -> String {
386        base64::engine::general_purpose::STANDARD.encode(bytes)
387    }
388
389    /// Write an artefact + a registry index referencing it (relative path).
390    fn make_registry(dir: &Path, signature: Option<&str>) -> PathBuf {
391        std::fs::write(dir.join("colmask.wasm"), WASM).unwrap();
392        let sig_field = signature
393            .map(|s| format!(",\n      \"signature\": \"{s}\""))
394            .unwrap_or_default();
395        let index = format!(
396            "{{\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}}",
397            sha256_hex(WASM)
398        );
399        let index_path = dir.join("index.json");
400        std::fs::write(&index_path, index).unwrap();
401        index_path
402    }
403
404    #[test]
405    fn install_unsigned_lands_wasm() {
406        let src = tempfile::tempdir().unwrap();
407        let dst = tempfile::tempdir().unwrap();
408        let index = make_registry(src.path(), None);
409
410        let r = install(&index, "colmask", None, dst.path(), None).unwrap();
411        assert_eq!(r.name, "colmask");
412        assert!(r.wasm_path.exists());
413        assert!(r.sig_path.is_none());
414        assert!(r.signed_by.is_none());
415        assert_eq!(std::fs::read(&r.wasm_path).unwrap(), WASM);
416    }
417
418    #[test]
419    fn install_rejects_sha256_mismatch() {
420        let src = tempfile::tempdir().unwrap();
421        let dst = tempfile::tempdir().unwrap();
422        // Index claims the right hash, then we corrupt the artefact on disk.
423        let index = make_registry(src.path(), None);
424        std::fs::write(src.path().join("colmask.wasm"), b"tampered").unwrap();
425
426        let err = install(&index, "colmask", None, dst.path(), None).unwrap_err();
427        assert!(err.contains("sha256 mismatch"), "{err}");
428    }
429
430    #[test]
431    fn install_verifies_signature_against_trust_root() {
432        let src = tempfile::tempdir().unwrap();
433        let dst = tempfile::tempdir().unwrap();
434        let trust = tempfile::tempdir().unwrap();
435
436        // Trusted publisher signs the artefact.
437        let key = SigningKey::from_bytes(&[7u8; 32]);
438        std::fs::write(
439            trust.path().join("official.pub"),
440            b64(&key.verifying_key().to_bytes()),
441        )
442        .unwrap();
443        let sig = b64(&key.sign(WASM).to_bytes());
444        let index = make_registry(src.path(), Some(&sig));
445
446        let r = install(&index, "colmask", None, dst.path(), Some(trust.path())).unwrap();
447        assert_eq!(r.signed_by.as_deref(), Some("official"));
448        assert!(r.sig_path.as_ref().unwrap().exists());
449    }
450
451    #[test]
452    fn install_rejects_untrusted_signature() {
453        let src = tempfile::tempdir().unwrap();
454        let dst = tempfile::tempdir().unwrap();
455        let trust = tempfile::tempdir().unwrap();
456
457        // Trust root holds the official key; an attacker signs with another.
458        let official = SigningKey::from_bytes(&[7u8; 32]);
459        std::fs::write(
460            trust.path().join("official.pub"),
461            b64(&official.verifying_key().to_bytes()),
462        )
463        .unwrap();
464        let attacker = SigningKey::from_bytes(&[0xABu8; 32]);
465        let sig = b64(&attacker.sign(WASM).to_bytes());
466        let index = make_registry(src.path(), Some(&sig));
467
468        let err = install(&index, "colmask", None, dst.path(), Some(trust.path())).unwrap_err();
469        assert!(err.contains("signature verification failed"), "{err}");
470    }
471
472    #[test]
473    fn install_requires_signature_when_trust_root_set() {
474        let src = tempfile::tempdir().unwrap();
475        let dst = tempfile::tempdir().unwrap();
476        let trust = tempfile::tempdir().unwrap();
477        let key = SigningKey::from_bytes(&[7u8; 32]);
478        std::fs::write(
479            trust.path().join("official.pub"),
480            b64(&key.verifying_key().to_bytes()),
481        )
482        .unwrap();
483        let index = make_registry(src.path(), None); // unsigned entry
484
485        let err = install(&index, "colmask", None, dst.path(), Some(trust.path())).unwrap_err();
486        assert!(err.contains("no signature"), "{err}");
487    }
488
489    #[test]
490    fn rejects_remote_artifact_offline() {
491        let p = resolve_artifact_path(Path::new("/tmp/index.json"), "https://example/colmask.wasm");
492        assert!(p.unwrap_err().contains("remote"));
493    }
494
495    #[test]
496    fn install_rejects_https_artifact() {
497        let src = tempfile::tempdir().unwrap();
498        let dst = tempfile::tempdir().unwrap();
499        let index = src.path().join("idx.json");
500        std::fs::write(
501            &index,
502            r#"{"plugins":[{"name":"x","artifact":"https://example/x.wasm","sha256":"00"}]}"#,
503        )
504        .unwrap();
505        let err = install(&index, "x", None, dst.path(), None).unwrap_err();
506        assert!(err.contains("https://"), "{err}");
507    }
508
509    #[test]
510    fn verify_digest_only_without_trust_root() {
511        let dir = tempfile::tempdir().unwrap();
512        let wasm = dir.path().join("p.wasm");
513        std::fs::write(&wasm, WASM).unwrap();
514        let r = verify(&wasm, None, None).unwrap();
515        assert_eq!(r.sha256, sha256_hex(WASM));
516        assert!(r.signed_by.is_none());
517    }
518
519    #[test]
520    fn verify_signature_via_sidecar() {
521        let dir = tempfile::tempdir().unwrap();
522        let trust = tempfile::tempdir().unwrap();
523        let key = SigningKey::from_bytes(&[7u8; 32]);
524        std::fs::write(
525            trust.path().join("official.pub"),
526            b64(&key.verifying_key().to_bytes()),
527        )
528        .unwrap();
529        let wasm = dir.path().join("p.wasm");
530        std::fs::write(&wasm, WASM).unwrap();
531        // `p.wasm` -> `p.sig` sidecar (with_extension), matching the loader.
532        std::fs::write(dir.path().join("p.sig"), b64(&key.sign(WASM).to_bytes())).unwrap();
533
534        let r = verify(&wasm, Some(trust.path()), None).unwrap();
535        assert_eq!(r.signed_by.as_deref(), Some("official"));
536    }
537
538    #[test]
539    fn verify_rejects_tampered_artifact() {
540        let dir = tempfile::tempdir().unwrap();
541        let trust = tempfile::tempdir().unwrap();
542        let key = SigningKey::from_bytes(&[7u8; 32]);
543        std::fs::write(
544            trust.path().join("official.pub"),
545            b64(&key.verifying_key().to_bytes()),
546        )
547        .unwrap();
548        let wasm = dir.path().join("p.wasm");
549        // Sign the real bytes, but write tampered bytes to disk.
550        std::fs::write(dir.path().join("p.sig"), b64(&key.sign(WASM).to_bytes())).unwrap();
551        std::fs::write(&wasm, b"tampered-wasm").unwrap();
552
553        let err = verify(&wasm, Some(trust.path()), None).unwrap_err();
554        assert!(err.contains("signature verification failed"), "{err}");
555    }
556
557    #[test]
558    fn scaffold_creates_skeleton() {
559        let dir = tempfile::tempdir().unwrap();
560        let root = scaffold("my-plugin", dir.path()).unwrap();
561        assert!(root.join("plugin.yaml").exists());
562        assert!(root.join("src/lib.rs").exists());
563        assert!(root.join("README.md").exists());
564        // Reject invalid names + double-create.
565        assert!(scaffold("bad name", dir.path()).is_err());
566        assert!(scaffold("my-plugin", dir.path()).is_err());
567    }
568}