Skip to main content

heldar_kernel/
registry.rs

1//! Plugin registry catalog — types + signature verification (Phase C of the plugin platform).
2//!
3//! The store browses a *catalog*: a list of available plugins (distinct from [`crate::modules`], which
4//! tracks what is loaded/installed). A catalog comes from two kinds of source:
5//!
6//! * the **bundled** first-party catalog, compiled into the binary (`include_str!`) and therefore
7//!   trusted by construction — it lists only OPEN modules so the open repo names no proprietary product;
8//! * optional **remote** registries (admin-configured URLs) whose documents are verified against a
9//!   pinned Ed25519 public key, so a "verified publisher" badge is a real asymmetric guarantee. The
10//!   proprietary shelf is served this way at runtime, never baked into open source.
11//!
12//! This module is pure (types + crypto, no IO); the fetch/cache/merge lives in
13//! [`crate::services::registry`]. Verification is detached Ed25519 over the *exact* catalog bytes
14//! (mirroring the webhook signer), so there is no JSON-canonicalization footgun.
15
16use std::sync::OnceLock;
17
18use base64::Engine;
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21
22use crate::modules::{ModuleKind, MountKind, NavEntry};
23
24/// The signed catalog document (`heldar-catalog/v1`).
25#[derive(Clone, Debug, Deserialize)]
26pub struct CatalogDoc {
27    pub format: String,
28    #[serde(default)]
29    pub name: String,
30    #[serde(default)]
31    pub issued_at: Option<DateTime<Utc>>,
32    #[serde(default)]
33    pub expires_at: Option<DateTime<Utc>>,
34    pub entries: Vec<CatalogEntry>,
35}
36
37/// One advertised plugin. Serialized back to the dashboard (flattened) inside a [`RegistryEntryView`].
38#[derive(Clone, Debug, Deserialize, Serialize)]
39pub struct CatalogEntry {
40    pub id: String,
41    pub name: String,
42    pub publisher: String,
43    pub kind: ModuleKind,
44    pub summary: String,
45    #[serde(default)]
46    pub description: Option<String>,
47    #[serde(default)]
48    pub version: Option<String>,
49    /// Icon key resolved by the dashboard (`moduleIcon`); unknown keys fall back to a generic glyph.
50    #[serde(default)]
51    pub icon: Option<String>,
52    #[serde(default)]
53    pub homepage: Option<String>,
54    #[serde(default)]
55    pub categories: Vec<String>,
56    pub install: InstallSpec,
57}
58
59/// How an entry is installed. `builtin` modules are compiled into the binary (not runtime-installable;
60/// the store shows status + CTA); `sidecar` entries pre-fill the Phase B register form.
61#[derive(Clone, Debug, Deserialize, Serialize)]
62#[serde(tag = "type", rename_all = "snake_case")]
63pub enum InstallSpec {
64    Builtin {
65        /// `open` (Apache-2.0, included in the open build) or `commercial` (contact to obtain).
66        #[serde(default)]
67        availability: Option<String>,
68        #[serde(default)]
69        contact: Option<String>,
70    },
71    Sidecar {
72        /// Container image hint (informational only — the kernel never pulls or runs it).
73        #[serde(default)]
74        image: Option<String>,
75        /// Pre-filled, admin-editable base URL the operator points at their running sidecar.
76        default_base_url: String,
77        #[serde(default)]
78        subscribes: Vec<String>,
79        /// `viewer` | `integration` (validated by the Phase B register path).
80        #[serde(default)]
81        role: Option<String>,
82        #[serde(default)]
83        nav: Vec<NavEntry>,
84        #[serde(default)]
85        docs: Option<String>,
86    },
87}
88
89impl InstallSpec {
90    pub fn is_sidecar(&self) -> bool {
91        matches!(self, InstallSpec::Sidecar { .. })
92    }
93}
94
95/// The detached-signature sidecar artifact for a remote catalog (`<catalog-url>.sig`).
96#[derive(Clone, Debug, Deserialize)]
97pub struct SignatureDoc {
98    pub alg: String,
99    pub key_id: String,
100    /// base64 of the raw 64-byte Ed25519 signature over the exact catalog bytes.
101    pub signature: String,
102    /// Optional informational hex SHA-256 of the catalog (not trusted for verification).
103    #[serde(default)]
104    pub catalog_sha256: Option<String>,
105}
106
107/* ------------------------------------------------------------------ */
108/* Trust anchors + verification                                       */
109/* ------------------------------------------------------------------ */
110
111/// A pinned trust anchor. Only the PUBLIC key is embedded — the matching private key is held solely in
112/// the publisher's release infrastructure and never enters either repo.
113pub struct TrustedKey {
114    pub key_id: &'static str,
115    pub publisher: &'static str,
116    pub first_party: bool,
117    /// base64 of the 32-byte raw Ed25519 public key.
118    pub ed25519_b64: &'static str,
119}
120
121/// Compile-time pinned keys. Operators add their own via `HELDAR_REGISTRY_TRUSTED_KEYS`.
122///
123/// NOTE: the bundled key below is the canonical Straits-AI registry signing key. Rotating it is a
124/// kernel release (add a new entry with a fresh `key_id`); multiple pinned keys are all accepted, so a
125/// rotation overlaps cleanly. The private half lives only in Straits-AI release infrastructure.
126pub const TRUSTED_KEYS: &[TrustedKey] = &[TrustedKey {
127    key_id: "straits-ai-registry-2026",
128    publisher: "Straits-AI",
129    first_party: true,
130    ed25519_b64: "ksytnKjDmSszbYkGkf/0PighChPNhWtMqHrUUhgzEeQ=",
131}];
132
133/// A resolved key (pinned or operator-supplied) ready for verification.
134#[derive(Clone)]
135struct ResolvedKey {
136    key_id: String,
137    publisher: String,
138    first_party: bool,
139    pubkey: Vec<u8>,
140}
141
142/// The set of keys a verification runs against: the pinned [`TRUSTED_KEYS`] plus operator extras.
143pub struct Keyset {
144    keys: Vec<ResolvedKey>,
145}
146
147impl Keyset {
148    /// Build from the pinned keys plus operator extras (`key_id:base64pubkey`). Malformed extras are
149    /// skipped with a warning rather than failing the whole keyset.
150    pub fn load(extra: &[(String, String)]) -> Self {
151        let mut keys = Vec::new();
152        for k in TRUSTED_KEYS {
153            match base64::engine::general_purpose::STANDARD.decode(k.ed25519_b64) {
154                Ok(pk) if pk.len() == 32 => keys.push(ResolvedKey {
155                    key_id: k.key_id.to_string(),
156                    publisher: k.publisher.to_string(),
157                    first_party: k.first_party,
158                    pubkey: pk,
159                }),
160                _ => tracing::error!(
161                    key_id = k.key_id,
162                    "registry: pinned key is not 32-byte base64"
163                ),
164            }
165        }
166        for (key_id, b64) in extra {
167            match base64::engine::general_purpose::STANDARD.decode(b64) {
168                Ok(pk) if pk.len() == 32 => keys.push(ResolvedKey {
169                    key_id: key_id.clone(),
170                    publisher: format!("operator:{key_id}"),
171                    first_party: false,
172                    pubkey: pk,
173                }),
174                _ => tracing::warn!(
175                    key_id,
176                    "registry: operator key is not 32-byte base64; skipping"
177                ),
178            }
179        }
180        Keyset { keys }
181    }
182
183    fn find(&self, key_id: &str) -> Option<&ResolvedKey> {
184        self.keys.iter().find(|k| k.key_id == key_id)
185    }
186}
187
188/// Outcome of verifying a catalog document.
189#[derive(Clone, Debug)]
190pub struct Verification {
191    pub verified: bool,
192    pub key_id: Option<String>,
193    pub publisher: Option<String>,
194    pub first_party: bool,
195    /// Machine-readable reason when `!verified` (`bad_alg`/`unknown_key`/`malformed_signature`/
196    /// `invalid_signature`/`expired`).
197    pub reason: Option<String>,
198}
199
200impl Verification {
201    fn deny(reason: &str) -> Self {
202        Verification {
203            verified: false,
204            key_id: None,
205            publisher: None,
206            first_party: false,
207            reason: Some(reason.to_string()),
208        }
209    }
210}
211
212/// Verify a detached Ed25519 signature over the exact catalog bytes. Fail-closed: any problem returns
213/// `verified=false` with a reason; the caller drops an unverified remote source's entries.
214pub fn verify_detached(
215    catalog_bytes: &[u8],
216    sig: &SignatureDoc,
217    keyset: &Keyset,
218    expires_at: Option<DateTime<Utc>>,
219    now: DateTime<Utc>,
220) -> Verification {
221    if sig.alg != "ed25519" {
222        return Verification::deny("bad_alg");
223    }
224    let Some(key) = keyset.find(&sig.key_id) else {
225        return Verification::deny("unknown_key");
226    };
227    let Ok(sig_raw) = base64::engine::general_purpose::STANDARD.decode(sig.signature.trim()) else {
228        return Verification::deny("malformed_signature");
229    };
230    if sig_raw.len() != 64 {
231        return Verification::deny("malformed_signature");
232    }
233    let pubkey = ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, &key.pubkey);
234    if pubkey.verify(catalog_bytes, &sig_raw).is_err() {
235        return Verification::deny("invalid_signature");
236    }
237    if let Some(exp) = expires_at {
238        if exp < now {
239            return Verification {
240                verified: false,
241                key_id: Some(key.key_id.clone()),
242                publisher: Some(key.publisher.clone()),
243                first_party: key.first_party,
244                reason: Some("expired".to_string()),
245            };
246        }
247    }
248    Verification {
249        verified: true,
250        key_id: Some(key.key_id.clone()),
251        publisher: Some(key.publisher.clone()),
252        first_party: key.first_party,
253        reason: None,
254    }
255}
256
257/* ------------------------------------------------------------------ */
258/* Bundled catalog                                                    */
259/* ------------------------------------------------------------------ */
260
261const BUNDLED_CATALOG_JSON: &str = include_str!("../catalog/heldar-catalog.json");
262
263/// The first-party catalog compiled into the binary. Trusted by construction (it IS the binary), so
264/// its entries are always `verified`. Lists only OPEN modules — the proprietary shelf is remote-only.
265pub fn bundled_catalog() -> &'static CatalogDoc {
266    static CELL: OnceLock<CatalogDoc> = OnceLock::new();
267    CELL.get_or_init(|| {
268        serde_json::from_str(BUNDLED_CATALOG_JSON).expect("bundled catalog JSON is valid")
269    })
270}
271
272/* ------------------------------------------------------------------ */
273/* Merged view types (served at GET /api/v1/registry)                 */
274/* ------------------------------------------------------------------ */
275
276/// Which store shelf an entry belongs on.
277#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
278#[serde(rename_all = "snake_case")]
279pub enum Shelf {
280    Core,
281    Proprietary,
282    Community,
283    Import,
284}
285
286impl From<ModuleKind> for Shelf {
287    fn from(k: ModuleKind) -> Self {
288        match k {
289            ModuleKind::Core => Shelf::Core,
290            ModuleKind::Proprietary => Shelf::Proprietary,
291            ModuleKind::Community => Shelf::Community,
292            ModuleKind::Imported => Shelf::Import,
293        }
294    }
295}
296
297/// The per-entry live state, cross-referenced against loaded/installed modules.
298#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
299#[serde(rename_all = "snake_case")]
300pub enum EntryState {
301    /// A sidecar that can be installed now (not yet registered).
302    Available,
303    /// A sidecar that is registered.
304    Installed,
305    /// A compiled module present in this build.
306    Included,
307    /// A compiled module advertised but not in this build (e.g. a commercial add-on).
308    NotInBuild,
309    /// An installed sidecar whose health probe last failed.
310    Unreachable,
311    /// A headless plugin (e.g. a Wasm DetectionConsumer) loaded from disk and running.
312    Loaded,
313}
314
315/// One catalog entry with its computed shelf/state/verification, ready for the dashboard.
316#[derive(Clone, Debug, Serialize)]
317pub struct RegistryEntryView {
318    #[serde(flatten)]
319    pub entry: CatalogEntry,
320    pub shelf: Shelf,
321    pub state: EntryState,
322    pub verified: bool,
323    /// `bundled` or the source URL.
324    pub source: String,
325    /// How the module mounts (only set for entries derived from a loaded module, e.g. `headless` for a
326    /// Wasm plugin); `None` for advertised catalog entries.
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub mount: Option<MountKind>,
329}
330
331/// A catalog source's status (for the "registry signature" indicator + diagnostics).
332#[derive(Clone, Debug, Serialize)]
333pub struct RegistrySourceView {
334    /// `bundled` or the URL.
335    pub source: String,
336    pub name: String,
337    pub verified: bool,
338    pub first_party: bool,
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub key_id: Option<String>,
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub error: Option<String>,
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub fetched_at: Option<DateTime<Utc>>,
345    pub entry_count: usize,
346}
347
348/// The full `GET /api/v1/registry` response.
349#[derive(Clone, Debug, Serialize)]
350pub struct RegistryView {
351    pub enabled: bool,
352    pub sources: Vec<RegistrySourceView>,
353    pub entries: Vec<RegistryEntryView>,
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    // A dev keypair generated for tests (independent of the pinned production key).
361    // pub b64 below matches the secret used to produce the signatures in the asserts.
362    fn test_keyset(pub_b64: &str) -> Keyset {
363        Keyset::load(&[("test-key".to_string(), pub_b64.to_string())])
364    }
365
366    #[test]
367    fn bundled_catalog_parses_and_is_open_only() {
368        let cat = bundled_catalog();
369        assert_eq!(cat.format, "heldar-catalog/v1");
370        assert!(!cat.entries.is_empty());
371        // The open bundled catalog must name no proprietary module.
372        for e in &cat.entries {
373            assert_ne!(
374                e.kind,
375                ModuleKind::Proprietary,
376                "open bundle lists {}",
377                e.id
378            );
379        }
380    }
381
382    #[test]
383    fn rejects_bad_alg_and_unknown_key() {
384        let ks = Keyset::load(&[]);
385        let sig = SignatureDoc {
386            alg: "rsa".into(),
387            key_id: "straits-ai-registry-2026".into(),
388            signature: "AA==".into(),
389            catalog_sha256: None,
390        };
391        assert_eq!(
392            verify_detached(b"x", &sig, &ks, None, Utc::now())
393                .reason
394                .as_deref(),
395            Some("bad_alg")
396        );
397        let sig2 = SignatureDoc {
398            alg: "ed25519".into(),
399            key_id: "nope".into(),
400            signature: "AA==".into(),
401            catalog_sha256: None,
402        };
403        assert_eq!(
404            verify_detached(b"x", &sig2, &ks, None, Utc::now())
405                .reason
406                .as_deref(),
407            Some("unknown_key")
408        );
409    }
410
411    #[test]
412    fn malformed_signature_is_rejected() {
413        let ks = test_keyset("ksytnKjDmSszbYkGkf/0PighChPNhWtMqHrUUhgzEeQ=");
414        let sig = SignatureDoc {
415            alg: "ed25519".into(),
416            key_id: "test-key".into(),
417            signature: "not-base64-!!!".into(),
418            catalog_sha256: None,
419        };
420        assert_eq!(
421            verify_detached(b"x", &sig, &ks, None, Utc::now())
422                .reason
423                .as_deref(),
424            Some("malformed_signature")
425        );
426    }
427
428    /// A valid signature verifies; tampering the bytes breaks it; an expired doc is denied. Uses a
429    /// ring-generated ephemeral keypair so the whole sign→verify path is exercised, not just rejects.
430    #[test]
431    fn roundtrip_valid_tampered_expired() {
432        use base64::engine::general_purpose::STANDARD as B64;
433        use ring::rand::SystemRandom;
434        use ring::signature::{Ed25519KeyPair, KeyPair};
435
436        let rng = SystemRandom::new();
437        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
438        let kp = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
439        let pub_b64 = B64.encode(kp.public_key().as_ref());
440        let ks = test_keyset(&pub_b64);
441
442        let msg = br#"{"format":"heldar-catalog/v1","entries":[]}"#;
443        let sig = kp.sign(msg);
444        let sigdoc = SignatureDoc {
445            alg: "ed25519".into(),
446            key_id: "test-key".into(),
447            signature: B64.encode(sig.as_ref()),
448            catalog_sha256: None,
449        };
450
451        let now = Utc::now();
452        // valid
453        assert!(verify_detached(msg, &sigdoc, &ks, None, now).verified);
454        // tampered bytes -> invalid_signature
455        let bad = verify_detached(b"{\"format\":\"x\"}", &sigdoc, &ks, None, now);
456        assert!(!bad.verified);
457        assert_eq!(bad.reason.as_deref(), Some("invalid_signature"));
458        // valid signature but expired doc -> denied (fail-closed on staleness)
459        let past = now - chrono::Duration::hours(1);
460        let exp = verify_detached(msg, &sigdoc, &ks, Some(past), now);
461        assert!(!exp.verified);
462        assert_eq!(exp.reason.as_deref(), Some("expired"));
463    }
464}