use std::sync::OnceLock;
use base64::Engine;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::modules::{ModuleKind, MountKind, NavEntry};
#[derive(Clone, Debug, Deserialize)]
pub struct CatalogDoc {
pub format: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub issued_at: Option<DateTime<Utc>>,
#[serde(default)]
pub expires_at: Option<DateTime<Utc>>,
pub entries: Vec<CatalogEntry>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct CatalogEntry {
pub id: String,
pub name: String,
pub publisher: String,
pub kind: ModuleKind,
pub summary: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub homepage: Option<String>,
#[serde(default)]
pub categories: Vec<String>,
pub install: InstallSpec,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum InstallSpec {
Builtin {
#[serde(default)]
availability: Option<String>,
#[serde(default)]
contact: Option<String>,
},
Sidecar {
#[serde(default)]
image: Option<String>,
default_base_url: String,
#[serde(default)]
subscribes: Vec<String>,
#[serde(default)]
role: Option<String>,
#[serde(default)]
nav: Vec<NavEntry>,
#[serde(default)]
docs: Option<String>,
},
}
impl InstallSpec {
pub fn is_sidecar(&self) -> bool {
matches!(self, InstallSpec::Sidecar { .. })
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct SignatureDoc {
pub alg: String,
pub key_id: String,
pub signature: String,
#[serde(default)]
pub catalog_sha256: Option<String>,
}
pub struct TrustedKey {
pub key_id: &'static str,
pub publisher: &'static str,
pub first_party: bool,
pub ed25519_b64: &'static str,
}
pub const TRUSTED_KEYS: &[TrustedKey] = &[TrustedKey {
key_id: "straits-ai-registry-2026",
publisher: "Straits-AI",
first_party: true,
ed25519_b64: "ksytnKjDmSszbYkGkf/0PighChPNhWtMqHrUUhgzEeQ=",
}];
#[derive(Clone)]
struct ResolvedKey {
key_id: String,
publisher: String,
first_party: bool,
pubkey: Vec<u8>,
}
pub struct Keyset {
keys: Vec<ResolvedKey>,
}
impl Keyset {
pub fn load(extra: &[(String, String)]) -> Self {
let mut keys = Vec::new();
for k in TRUSTED_KEYS {
match base64::engine::general_purpose::STANDARD.decode(k.ed25519_b64) {
Ok(pk) if pk.len() == 32 => keys.push(ResolvedKey {
key_id: k.key_id.to_string(),
publisher: k.publisher.to_string(),
first_party: k.first_party,
pubkey: pk,
}),
_ => tracing::error!(
key_id = k.key_id,
"registry: pinned key is not 32-byte base64"
),
}
}
for (key_id, b64) in extra {
match base64::engine::general_purpose::STANDARD.decode(b64) {
Ok(pk) if pk.len() == 32 => keys.push(ResolvedKey {
key_id: key_id.clone(),
publisher: format!("operator:{key_id}"),
first_party: false,
pubkey: pk,
}),
_ => tracing::warn!(
key_id,
"registry: operator key is not 32-byte base64; skipping"
),
}
}
Keyset { keys }
}
fn find(&self, key_id: &str) -> Option<&ResolvedKey> {
self.keys.iter().find(|k| k.key_id == key_id)
}
}
#[derive(Clone, Debug)]
pub struct Verification {
pub verified: bool,
pub key_id: Option<String>,
pub publisher: Option<String>,
pub first_party: bool,
pub reason: Option<String>,
}
impl Verification {
fn deny(reason: &str) -> Self {
Verification {
verified: false,
key_id: None,
publisher: None,
first_party: false,
reason: Some(reason.to_string()),
}
}
}
pub fn verify_detached(
catalog_bytes: &[u8],
sig: &SignatureDoc,
keyset: &Keyset,
expires_at: Option<DateTime<Utc>>,
now: DateTime<Utc>,
) -> Verification {
if sig.alg != "ed25519" {
return Verification::deny("bad_alg");
}
let Some(key) = keyset.find(&sig.key_id) else {
return Verification::deny("unknown_key");
};
let Ok(sig_raw) = base64::engine::general_purpose::STANDARD.decode(sig.signature.trim()) else {
return Verification::deny("malformed_signature");
};
if sig_raw.len() != 64 {
return Verification::deny("malformed_signature");
}
let pubkey = ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, &key.pubkey);
if pubkey.verify(catalog_bytes, &sig_raw).is_err() {
return Verification::deny("invalid_signature");
}
if let Some(exp) = expires_at {
if exp < now {
return Verification {
verified: false,
key_id: Some(key.key_id.clone()),
publisher: Some(key.publisher.clone()),
first_party: key.first_party,
reason: Some("expired".to_string()),
};
}
}
Verification {
verified: true,
key_id: Some(key.key_id.clone()),
publisher: Some(key.publisher.clone()),
first_party: key.first_party,
reason: None,
}
}
const BUNDLED_CATALOG_JSON: &str = include_str!("../catalog/heldar-catalog.json");
pub fn bundled_catalog() -> &'static CatalogDoc {
static CELL: OnceLock<CatalogDoc> = OnceLock::new();
CELL.get_or_init(|| {
serde_json::from_str(BUNDLED_CATALOG_JSON).expect("bundled catalog JSON is valid")
})
}
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Shelf {
Core,
Proprietary,
Community,
Import,
}
impl From<ModuleKind> for Shelf {
fn from(k: ModuleKind) -> Self {
match k {
ModuleKind::Core => Shelf::Core,
ModuleKind::Proprietary => Shelf::Proprietary,
ModuleKind::Community => Shelf::Community,
ModuleKind::Imported => Shelf::Import,
}
}
}
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum EntryState {
Available,
Installed,
Included,
NotInBuild,
Unreachable,
Loaded,
}
#[derive(Clone, Debug, Serialize)]
pub struct RegistryEntryView {
#[serde(flatten)]
pub entry: CatalogEntry,
pub shelf: Shelf,
pub state: EntryState,
pub verified: bool,
pub source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub mount: Option<MountKind>,
}
#[derive(Clone, Debug, Serialize)]
pub struct RegistrySourceView {
pub source: String,
pub name: String,
pub verified: bool,
pub first_party: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub key_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fetched_at: Option<DateTime<Utc>>,
pub entry_count: usize,
}
#[derive(Clone, Debug, Serialize)]
pub struct RegistryView {
pub enabled: bool,
pub sources: Vec<RegistrySourceView>,
pub entries: Vec<RegistryEntryView>,
}
#[cfg(test)]
mod tests {
use super::*;
fn test_keyset(pub_b64: &str) -> Keyset {
Keyset::load(&[("test-key".to_string(), pub_b64.to_string())])
}
#[test]
fn bundled_catalog_parses_and_is_open_only() {
let cat = bundled_catalog();
assert_eq!(cat.format, "heldar-catalog/v1");
assert!(!cat.entries.is_empty());
for e in &cat.entries {
assert_ne!(
e.kind,
ModuleKind::Proprietary,
"open bundle lists {}",
e.id
);
}
}
#[test]
fn rejects_bad_alg_and_unknown_key() {
let ks = Keyset::load(&[]);
let sig = SignatureDoc {
alg: "rsa".into(),
key_id: "straits-ai-registry-2026".into(),
signature: "AA==".into(),
catalog_sha256: None,
};
assert_eq!(
verify_detached(b"x", &sig, &ks, None, Utc::now())
.reason
.as_deref(),
Some("bad_alg")
);
let sig2 = SignatureDoc {
alg: "ed25519".into(),
key_id: "nope".into(),
signature: "AA==".into(),
catalog_sha256: None,
};
assert_eq!(
verify_detached(b"x", &sig2, &ks, None, Utc::now())
.reason
.as_deref(),
Some("unknown_key")
);
}
#[test]
fn malformed_signature_is_rejected() {
let ks = test_keyset("ksytnKjDmSszbYkGkf/0PighChPNhWtMqHrUUhgzEeQ=");
let sig = SignatureDoc {
alg: "ed25519".into(),
key_id: "test-key".into(),
signature: "not-base64-!!!".into(),
catalog_sha256: None,
};
assert_eq!(
verify_detached(b"x", &sig, &ks, None, Utc::now())
.reason
.as_deref(),
Some("malformed_signature")
);
}
#[test]
fn roundtrip_valid_tampered_expired() {
use base64::engine::general_purpose::STANDARD as B64;
use ring::rand::SystemRandom;
use ring::signature::{Ed25519KeyPair, KeyPair};
let rng = SystemRandom::new();
let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
let kp = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
let pub_b64 = B64.encode(kp.public_key().as_ref());
let ks = test_keyset(&pub_b64);
let msg = br#"{"format":"heldar-catalog/v1","entries":[]}"#;
let sig = kp.sign(msg);
let sigdoc = SignatureDoc {
alg: "ed25519".into(),
key_id: "test-key".into(),
signature: B64.encode(sig.as_ref()),
catalog_sha256: None,
};
let now = Utc::now();
assert!(verify_detached(msg, &sigdoc, &ks, None, now).verified);
let bad = verify_detached(b"{\"format\":\"x\"}", &sigdoc, &ks, None, now);
assert!(!bad.verified);
assert_eq!(bad.reason.as_deref(), Some("invalid_signature"));
let past = now - chrono::Duration::hours(1);
let exp = verify_detached(msg, &sigdoc, &ks, Some(past), now);
assert!(!exp.verified);
assert_eq!(exp.reason.as_deref(), Some("expired"));
}
}