1use std::sync::OnceLock;
17
18use base64::Engine;
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21
22use crate::modules::{ModuleKind, MountKind, NavEntry};
23
24#[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#[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 #[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#[derive(Clone, Debug, Deserialize, Serialize)]
62#[serde(tag = "type", rename_all = "snake_case")]
63pub enum InstallSpec {
64 Builtin {
65 #[serde(default)]
67 availability: Option<String>,
68 #[serde(default)]
69 contact: Option<String>,
70 },
71 Sidecar {
72 #[serde(default)]
74 image: Option<String>,
75 default_base_url: String,
77 #[serde(default)]
78 subscribes: Vec<String>,
79 #[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#[derive(Clone, Debug, Deserialize)]
97pub struct SignatureDoc {
98 pub alg: String,
99 pub key_id: String,
100 pub signature: String,
102 #[serde(default)]
104 pub catalog_sha256: Option<String>,
105}
106
107pub struct TrustedKey {
114 pub key_id: &'static str,
115 pub publisher: &'static str,
116 pub first_party: bool,
117 pub ed25519_b64: &'static str,
119}
120
121pub 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#[derive(Clone)]
135struct ResolvedKey {
136 key_id: String,
137 publisher: String,
138 first_party: bool,
139 pubkey: Vec<u8>,
140}
141
142pub struct Keyset {
144 keys: Vec<ResolvedKey>,
145}
146
147impl Keyset {
148 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#[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 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
212pub 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
257const BUNDLED_CATALOG_JSON: &str = include_str!("../catalog/heldar-catalog.json");
262
263pub 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#[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#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
299#[serde(rename_all = "snake_case")]
300pub enum EntryState {
301 Available,
303 Installed,
305 Included,
307 NotInBuild,
309 Unreachable,
311 Loaded,
313}
314
315#[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 pub source: String,
325 #[serde(skip_serializing_if = "Option::is_none")]
328 pub mount: Option<MountKind>,
329}
330
331#[derive(Clone, Debug, Serialize)]
333pub struct RegistrySourceView {
334 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#[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 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 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 #[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 assert!(verify_detached(msg, &sigdoc, &ks, None, now).verified);
454 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 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}