//! Decentralized app marketplace: discover, verify, and publish app manifests //! via Nostr relays. Uses NIP-78 (kind 30078) with d-tag "archipelago-app:". //! //! See docs/marketplace-protocol.md for the full protocol specification. use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::Path; use std::time::Duration; use tokio::fs; use tracing::{debug, info, warn}; const MARKETPLACE_DIR: &str = "marketplace"; const CACHE_FILE: &str = "manifests.json"; const PUBLISHED_DIR: &str = "published"; const ARCHIPELAGO_KIND: u64 = 30078; const D_TAG_PREFIX: &str = "archipelago-app:"; const MARKETPLACE_TAG: &str = "archipelago-marketplace"; /// Categories for marketplace apps. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] pub enum AppCategory { Money, Commerce, Data, Networking, Home, Community, Other, } impl std::fmt::Display for AppCategory { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Money => write!(f, "money"), Self::Commerce => write!(f, "commerce"), Self::Data => write!(f, "data"), Self::Networking => write!(f, "networking"), Self::Home => write!(f, "home"), Self::Community => write!(f, "community"), Self::Other => write!(f, "other"), } } } /// Author information in a marketplace manifest. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ManifestAuthor { pub name: String, pub did: String, #[serde(default)] pub nostr_pubkey: String, } /// Container configuration in a marketplace manifest. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ManifestContainer { pub image: String, #[serde(default)] pub ports: Vec, #[serde(default)] pub volumes: Vec, #[serde(default)] pub env: HashMap, #[serde(default)] pub capabilities: Vec, #[serde(default = "default_true")] pub readonly_root: bool, #[serde(default = "default_true")] pub no_new_privileges: bool, #[serde(default = "default_uid")] pub run_as_user: u32, } fn default_true() -> bool { true } fn default_uid() -> u32 { 1000 } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PortMapping { pub container: u16, pub host: u16, #[serde(default = "default_tcp")] pub protocol: String, } fn default_tcp() -> String { "tcp".into() } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VolumeMapping { pub name: String, pub path: String, } /// App manifest signatures. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ManifestSignatures { pub manifest_hash: String, pub did_signature: String, } /// A marketplace app manifest. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppManifest { pub app_id: String, pub name: String, pub version: String, pub description: ManifestDescription, pub author: ManifestAuthor, pub container: ManifestContainer, pub category: AppCategory, #[serde(default)] pub icon_url: String, #[serde(default)] pub repo_url: String, #[serde(default)] pub license: String, #[serde(default)] pub min_archipelago_version: String, #[serde(default)] pub dependencies: Vec, #[serde(default)] pub signatures: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum ManifestDescription { Simple(String), Detailed { short: String, long: String }, } impl ManifestDescription { /// Return the short description regardless of variant. #[allow(dead_code)] pub fn short(&self) -> &str { match self { Self::Simple(s) => s, Self::Detailed { short, .. } => short, } } } /// A discovered marketplace app with trust scoring. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DiscoveredApp { pub manifest: AppManifest, pub trust_score: u32, pub trust_tier: String, pub relay_count: u32, pub first_seen: String, pub nostr_pubkey: String, /// Outcome of checking the manifest's DID signature. `#[serde(default)]` /// so a cache written before this field existed still loads (as `missing`, /// which is the honest answer for an entry we never verified). #[serde(default)] pub signature: SignatureStatus, } // ─── DID signature layer ──────────────────────────────────────────────── // // A marketplace manifest travels inside a Nostr event, so it already carries a // NIP-01 Schnorr signature proving *the publishing relay key* sent it. That // says nothing about the `author.did` the manifest claims. This layer closes // that gap: the author signs a digest of their own manifest with the Ed25519 // key their did:key encodes, and every consumer re-derives the digest and // checks it. // // Until this existed, `signatures.manifest_hash` / `signatures.did_signature` // were struct fields nothing read, and the trust score awarded 30 points for // `did.starts_with("did:")` — i.e. for typing a string. /// Outcome of checking a manifest's `signatures` block. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "status", rename_all = "lowercase")] pub enum SignatureStatus { /// `manifest_hash` matches the content and `did_signature` verifies against /// the key in `author.did`. Valid, /// No `signatures` block. Not an attack — an unsigned publisher — but it /// earns none of the identity-derived trust. #[default] Missing, /// A `signatures` block is present and wrong: corrupt, tampered with, or /// signed by a key other than the one `author.did` names. Invalid { reason: String }, } impl SignatureStatus { pub fn is_valid(&self) -> bool { matches!(self, Self::Valid) } } /// Recursively rebuild a JSON value with every object's keys in lexicographic /// order, so the signed preimage is byte-stable. /// /// This is not belt-and-braces. `ManifestContainer::env` is a `HashMap`, whose /// iteration order is randomised per process; and `serde_json::Map` is only a /// sorted `BTreeMap` while the `preserve_order` feature is off — a feature any /// crate anywhere in the dependency graph can turn on for everyone through /// Cargo feature unification. Either way the digest would start changing /// between runs and every signature would break. Sorting here makes the /// preimage independent of both. fn canonicalize(value: serde_json::Value) -> serde_json::Value { match value { serde_json::Value::Object(map) => { let mut pairs: Vec<(String, serde_json::Value)> = map.into_iter().collect(); pairs.sort_by(|a, b| a.0.cmp(&b.0)); let mut out = serde_json::Map::new(); for (k, v) in pairs { out.insert(k, canonicalize(v)); } serde_json::Value::Object(out) } serde_json::Value::Array(items) => { serde_json::Value::Array(items.into_iter().map(canonicalize).collect()) } other => other, } } /// The exact bytes a manifest signature covers: the manifest as canonical JSON /// (sorted keys, no whitespace) with the `signatures` block itself omitted — /// a signature cannot cover the field that holds it. pub fn canonical_signing_bytes(manifest: &AppManifest) -> Result> { let mut unsigned = manifest.clone(); unsigned.signatures = None; let value = serde_json::to_value(&unsigned).context("Serializing manifest for signing")?; serde_json::to_vec(&canonicalize(value)).context("Encoding canonical manifest JSON") } /// SHA-256 over [`canonical_signing_bytes`]. This digest is what gets signed, /// and what `signatures.manifest_hash` records as `sha256:`. pub fn manifest_digest(manifest: &AppManifest) -> Result<[u8; 32]> { use sha2::{Digest, Sha256}; Ok(Sha256::digest(canonical_signing_bytes(manifest)?).into()) } /// Sign `manifest` in place with an Ed25519 key, filling in `signatures`. /// /// The caller must ensure `author.did` is the did:key for `signing_key` — /// [`publish`] enforces that. Signing with a mismatched key produces a manifest /// that every verifier rejects. pub fn sign_manifest( manifest: &mut AppManifest, signing_key: &ed25519_dalek::SigningKey, ) -> Result<()> { use ed25519_dalek::Signer; // Clear first so a re-sign never covers a previous signature. manifest.signatures = None; let digest = manifest_digest(manifest)?; let signature = signing_key.sign(&digest); manifest.signatures = Some(ManifestSignatures { manifest_hash: format!("sha256:{}", hex::encode(digest)), did_signature: base64::Engine::encode( &base64::engine::general_purpose::STANDARD, signature.to_bytes(), ), }); Ok(()) } /// Verify a manifest's `signatures` block against its own content and the /// Ed25519 key encoded in `author.did`. /// /// Never returns an error: a manifest arriving off a public relay is untrusted /// input, and every way it can be wrong is a verdict rather than an exception. pub fn verify_manifest_signature(manifest: &AppManifest) -> SignatureStatus { use ed25519_dalek::Verifier; let invalid = |reason: &str| SignatureStatus::Invalid { reason: reason.to_string(), }; let sigs = match &manifest.signatures { Some(s) => s, None => return SignatureStatus::Missing, }; let digest = match manifest_digest(manifest) { Ok(d) => d, Err(_) => return invalid("manifest could not be canonicalized"), }; // 1. Content integrity: does the recorded hash describe this manifest? let claimed_hex = match sigs.manifest_hash.strip_prefix("sha256:") { Some(h) => h, None => return invalid("manifest_hash is not in sha256: form"), }; match hex::decode(claimed_hex) { Ok(claimed) if claimed == digest => {} Ok(_) => return invalid("manifest_hash does not match the manifest content"), Err(_) => return invalid("manifest_hash is not valid hex"), } // 2. Identity: resolve the DID to a key. let pubkey_bytes = match crate::identity::pubkey_bytes_from_did_key(&manifest.author.did) { Ok(p) => p, Err(e) => { return SignatureStatus::Invalid { reason: format!("author.did is not a resolvable Ed25519 did:key: {e}"), } } }; let verifying_key = match ed25519_dalek::VerifyingKey::from_bytes(&pubkey_bytes) { Ok(k) => k, Err(_) => return invalid("author.did does not encode a valid Ed25519 key"), }; // 3. Authenticity: did that key sign this digest? let sig_bytes = match base64::Engine::decode( &base64::engine::general_purpose::STANDARD, &sigs.did_signature, ) { Ok(b) => b, Err(_) => return invalid("did_signature is not valid base64"), }; let sig_array: [u8; 64] = match sig_bytes.as_slice().try_into() { Ok(a) => a, Err(_) => return invalid("did_signature is not a 64-byte Ed25519 signature"), }; match verifying_key.verify(&digest, &ed25519_dalek::Signature::from_bytes(&sig_array)) { Ok(()) => SignatureStatus::Valid, Err(_) => invalid("did_signature does not verify against author.did"), } } /// Cache of discovered marketplace apps. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct MarketplaceCache { pub apps: Vec, pub last_updated: String, } /// Ensure marketplace directories exist. async fn ensure_dirs(data_dir: &Path) -> Result<()> { let market_dir = data_dir.join(MARKETPLACE_DIR); fs::create_dir_all(market_dir.join("cache")) .await .context("Creating marketplace cache dir")?; fs::create_dir_all(market_dir.join(PUBLISHED_DIR)) .await .context("Creating marketplace published dir")?; Ok(()) } /// Load cached marketplace data. pub async fn load_cache(data_dir: &Path) -> Result { let path = data_dir .join(MARKETPLACE_DIR) .join("cache") .join(CACHE_FILE); if !path.exists() { return Ok(MarketplaceCache::default()); } let data = fs::read_to_string(&path) .await .context("Reading marketplace cache")?; serde_json::from_str(&data).context("Parsing marketplace cache") } /// Save marketplace cache. pub async fn save_cache(data_dir: &Path, cache: &MarketplaceCache) -> Result<()> { ensure_dirs(data_dir).await?; let path = data_dir .join(MARKETPLACE_DIR) .join("cache") .join(CACHE_FILE); let data = serde_json::to_string_pretty(cache)?; fs::write(&path, data) .await .context("Writing marketplace cache")?; Ok(()) } /// Validate a manifest meets security requirements. pub fn validate_manifest(manifest: &AppManifest) -> Vec { let mut issues = Vec::new(); // Required fields if manifest.app_id.is_empty() { issues.push("Missing app_id".into()); } if manifest.name.is_empty() { issues.push("Missing name".into()); } if manifest.version.is_empty() { issues.push("Missing version".into()); } if manifest.container.image.is_empty() { issues.push("Missing container image".into()); } // Security checks if manifest.container.image.ends_with(":latest") { issues.push("Container image uses :latest tag (must pin specific version)".into()); } if !manifest.container.readonly_root { issues.push("readonly_root is false (should be true)".into()); } if !manifest.container.no_new_privileges { issues.push("no_new_privileges is false (should be true)".into()); } if manifest.container.run_as_user < 1000 { issues.push(format!( "run_as_user is {} (must be >= 1000)", manifest.container.run_as_user )); } // app_id format if !manifest .app_id .chars() .all(|c| c.is_ascii_lowercase() || c == '-' || c.is_ascii_digit()) { issues.push("app_id must be lowercase kebab-case".into()); } issues } /// Calculate trust score for a discovered app manifest. /// `signature` gates both identity-derived factors. Pass the result of /// [`verify_manifest_signature`]. pub fn calculate_trust_score( manifest: &AppManifest, relay_count: u32, federated_dids: &[String], signature: &SignatureStatus, ) -> (u32, String) { let mut score: u32 = 0; // Identity (30 points) — the author proved control of the key their // did:key names. This used to be `did.starts_with("did:")`, i.e. a string // test any publisher could pass by typing one, which made the whole // "Verified" tier meaningless. let identity_proven = signature.is_valid(); if identity_proven { score += 30; } // Relay consensus (20 points) — found on multiple relays score += match relay_count { 0..=1 => 5, 2..=3 => 12, _ => 20, }; // Federation trust (20 points) — developer DID in federation. // // Also gated on the signature: an unverified `author.did` is just a string // the publisher chose, so without this an attacker could copy a DID the // user federates with and collect 20 points for impersonating them — // exactly the peer they trust most. if identity_proven && federated_dids.contains(&manifest.author.did) { score += 20; } // Version history (15 points) — has a proper semver version if manifest.version.split('.').count() == 3 { score += 10; } if !manifest.repo_url.is_empty() { score += 5; } // Security compliance (15 points) let issues = validate_manifest(manifest); if issues.is_empty() { score += 15; } else if issues.len() <= 2 { score += 5; } let tier = match score { 80..=100 => "verified", 50..=79 => "community", 20..=49 => "unverified", _ => "untrusted", }; (score, tier.to_string()) } /// Discover app manifests from Nostr relays. /// /// Queries configured relays for kind 30078 events with the marketplace tag, /// parses manifests, validates, scores, and returns sorted by trust score. pub async fn discover( data_dir: &Path, relays: &[String], tor_proxy: Option<&str>, federated_dids: &[String], ) -> Result> { if relays.is_empty() { return Ok(Vec::new()); } info!( relay_count = relays.len(), "Discovering marketplace apps from Nostr relays" ); let anon_keys = nostr_sdk::prelude::Keys::generate(); let client = build_nostr_client(anon_keys, tor_proxy)?; for url in relays { let _ = client.add_relay(url).await; } if tokio::time::timeout(Duration::from_secs(10), client.connect()) .await .is_err() { tracing::warn!("Nostr relay connection timed out after 10s, continuing anyway"); } let filter = nostr_sdk::prelude::Filter::new() .kind(nostr_sdk::prelude::Kind::Custom(ARCHIPELAGO_KIND as u16)) .hashtag(MARKETPLACE_TAG) .limit(200); let events = client .fetch_events(filter, std::time::Duration::from_secs(20)) .await .map(|e| e.to_vec()) .unwrap_or_default(); client.disconnect().await; debug!( event_count = events.len(), "Fetched marketplace events from relays" ); // Deduplicate by app_id, keeping the latest version let mut app_map: HashMap = HashMap::new(); for event in events { // Parse manifest from event content let manifest: AppManifest = match serde_json::from_str(&event.content) { Ok(m) => m, Err(e) => { debug!(err = %e, "Skipping invalid marketplace manifest"); continue; } }; // Validate let issues = validate_manifest(&manifest); if issues .iter() .any(|i| i.contains("Missing app_id") || i.contains("Missing container image")) { debug!(issues = ?issues, "Skipping manifest with critical issues"); continue; } // Check the DID signature before the manifest is allowed anywhere near // the cache. A *wrong* signature is not a low-trust manifest, it is a // corrupt or forged one — drop it rather than listing it at reduced // score, so it can never be installed. A *missing* signature is // different: an unsigned publisher is legitimate, just unproven, and // scores zero on the identity factors. let signature = verify_manifest_signature(&manifest); if let SignatureStatus::Invalid { reason } = &signature { warn!( app_id = %manifest.app_id, author_did = %manifest.author.did, nostr_pubkey = %event.pubkey.to_hex(), reason = %reason, "Rejecting marketplace manifest with a bad DID signature" ); continue; } let app_id = manifest.app_id.clone(); let entry = app_map.entry(app_id).or_insert_with(|| { let (trust_score, trust_tier) = calculate_trust_score(&manifest, 1, federated_dids, &signature); ( DiscoveredApp { manifest, trust_score, trust_tier, relay_count: 0, first_seen: event.created_at.to_human_datetime(), nostr_pubkey: event.pubkey.to_hex(), signature, }, 0, ) }); entry.1 += 1; } // Update relay counts and recalculate scores let mut apps: Vec = app_map .into_values() .map(|(mut app, relay_count)| { app.relay_count = relay_count; let (score, tier) = calculate_trust_score(&app.manifest, relay_count, federated_dids, &app.signature); app.trust_score = score; app.trust_tier = tier; app }) .collect(); // Sort by trust score descending apps.sort_by(|a, b| b.trust_score.cmp(&a.trust_score)); // Cache results let cache = MarketplaceCache { apps: apps.clone(), last_updated: chrono::Utc::now().to_rfc3339(), }; if let Err(e) = save_cache(data_dir, &cache).await { warn!(err = %e, "Failed to save marketplace cache"); } info!(app_count = apps.len(), "Marketplace discovery complete"); Ok(apps) } /// Publish an app manifest to Nostr relays. pub async fn publish( data_dir: &Path, manifest: &AppManifest, relays: &[String], tor_proxy: Option<&str>, ) -> Result { if relays.is_empty() { anyhow::bail!("No relays configured for publishing"); } let issues = validate_manifest(manifest); if !issues.is_empty() { anyhow::bail!("Manifest validation failed: {}", issues.join(", ")); } let identity_dir = data_dir.join("identity"); // Sign with the node's Ed25519 identity key — the same key its did:key // encodes — so consumers can verify authorship independently of whichever // Nostr key happens to relay the event. let identity = crate::identity::NodeIdentity::load_or_create(&identity_dir) .await .context("Loading node identity to sign the manifest")?; let our_did = identity.did_key().context("Deriving this node's did:key")?; let mut manifest = manifest.clone(); if manifest.author.did.is_empty() { manifest.author.did = our_did.clone(); } else if manifest.author.did != our_did { // We can only sign as ourselves. Publishing under someone else's DID // would produce a manifest every verifier rejects, so fail loudly here // instead of broadcasting garbage to every relay. anyhow::bail!( "Cannot publish as author.did {} — this node can only sign as {}", manifest.author.did, our_did ); } sign_manifest(&mut manifest, identity.signing_key()).context("Signing manifest")?; debug_assert!(verify_manifest_signature(&manifest).is_valid()); let manifest = &manifest; let keys = load_or_create_keys(&identity_dir).await?; let client = build_nostr_client(keys, tor_proxy)?; let content = serde_json::to_string(manifest).context("Serializing manifest")?; let d_tag = format!("{}{}", D_TAG_PREFIX, manifest.app_id); for url in relays { let _ = client.add_relay(url).await; } if tokio::time::timeout(Duration::from_secs(10), client.connect()) .await .is_err() { tracing::warn!("Nostr relay connection timed out after 10s, continuing anyway"); } let builder = nostr_sdk::prelude::EventBuilder::new( nostr_sdk::prelude::Kind::Custom(ARCHIPELAGO_KIND as u16), &content, ) .tag(nostr_sdk::prelude::Tag::identifier(&d_tag)) .tag(nostr_sdk::prelude::Tag::hashtag(MARKETPLACE_TAG)) .tag(nostr_sdk::prelude::Tag::hashtag(format!( "category:{}", manifest.category ))) .tag(nostr_sdk::prelude::Tag::custom( nostr_sdk::prelude::TagKind::custom("version"), [&manifest.version], )) .tag(nostr_sdk::prelude::Tag::custom( nostr_sdk::prelude::TagKind::custom("image"), [&manifest.container.image], )); let output = client.send_event_builder(builder).await?; client.disconnect().await; // Save to published directory ensure_dirs(data_dir).await?; let pub_path = data_dir .join(MARKETPLACE_DIR) .join(PUBLISHED_DIR) .join(format!("{}.json", manifest.app_id)); let pub_data = serde_json::to_string_pretty(manifest)?; fs::write(&pub_path, pub_data) .await .context("Saving published manifest")?; info!(app_id = %manifest.app_id, "Published app manifest to {} relays", relays.len()); Ok(output.id().to_hex()) } /// List manifests published by this node. pub async fn list_published(data_dir: &Path) -> Result> { let pub_dir = data_dir.join(MARKETPLACE_DIR).join(PUBLISHED_DIR); if !pub_dir.exists() { return Ok(Vec::new()); } let mut manifests = Vec::new(); let mut entries = fs::read_dir(&pub_dir) .await .context("Reading published dir")?; while let Some(entry) = entries.next_entry().await? { let path = entry.path(); if path.extension().map(|e| e == "json").unwrap_or(false) { let data = fs::read_to_string(&path).await?; if let Ok(manifest) = serde_json::from_str::(&data) { manifests.push(manifest); } } } Ok(manifests) } // Re-use nostr client builder pattern from nostr_discovery fn build_nostr_client( keys: nostr_sdk::prelude::Keys, tor_proxy: Option<&str>, ) -> Result { use nostr_sdk::prelude::*; let client = if let Some(proxy_str) = tor_proxy { let addr: std::net::SocketAddr = proxy_str .trim() .parse() .ok() .ok_or_else(|| anyhow::anyhow!("Invalid Tor proxy: {}", proxy_str))?; let connection = Connection::new().proxy(addr).target(ConnectionTarget::All); let opts = ClientOptions::new().connection(connection); Client::builder().signer(keys).opts(opts).build() } else { Client::new(keys) }; Ok(client) } /// Load or create Nostr keys for marketplace publishing. async fn load_or_create_keys(identity_dir: &Path) -> Result { use nostr_sdk::prelude::Keys; let secret_path = identity_dir.join("nostr_secret"); if secret_path.exists() { let hex_secret = fs::read_to_string(&secret_path) .await .context("Reading Nostr secret")?; Keys::parse(hex_secret.trim()).context("Invalid Nostr secret") } else { let keys = Keys::generate(); fs::create_dir_all(identity_dir).await?; fs::write(&secret_path, keys.secret_key().to_secret_hex()).await?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; tokio::fs::set_permissions(&secret_path, std::fs::Permissions::from_mode(0o600)) .await?; } Ok(keys) } } #[cfg(test)] mod tests { use super::*; fn sample_manifest() -> AppManifest { AppManifest { app_id: "test-app".into(), name: "Test App".into(), version: "1.0.0".into(), description: ManifestDescription::Detailed { short: "A test app".into(), long: "A longer description of the test app".into(), }, author: ManifestAuthor { name: "Test Dev".into(), did: "did:key:z6MkTest123".into(), nostr_pubkey: String::new(), }, container: ManifestContainer { image: "docker.io/test/app:1.0.0".into(), ports: vec![PortMapping { container: 8080, host: 8180, protocol: "tcp".into(), }], volumes: vec![], env: HashMap::new(), capabilities: vec![], readonly_root: true, no_new_privileges: true, run_as_user: 1000, }, category: AppCategory::Other, icon_url: String::new(), repo_url: "https://github.com/test/app".into(), license: "MIT".into(), min_archipelago_version: "0.1.0".into(), dependencies: vec![], signatures: None, } } #[test] fn test_validate_valid_manifest() { let manifest = sample_manifest(); let issues = validate_manifest(&manifest); assert!(issues.is_empty(), "Expected no issues, got: {:?}", issues); } #[test] fn test_validate_latest_tag() { let mut manifest = sample_manifest(); manifest.container.image = "docker.io/test/app:latest".into(); let issues = validate_manifest(&manifest); assert!(issues.iter().any(|i| i.contains("latest"))); } #[test] fn test_validate_root_user() { let mut manifest = sample_manifest(); manifest.container.run_as_user = 0; let issues = validate_manifest(&manifest); assert!(issues.iter().any(|i| i.contains("run_as_user"))); } #[test] fn test_validate_missing_fields() { let mut manifest = sample_manifest(); manifest.app_id = String::new(); manifest.container.image = String::new(); let issues = validate_manifest(&manifest); assert!(issues.len() >= 2); } /// A real Ed25519 keypair, its did:key, and a manifest signed by it. fn signed_manifest() -> (ed25519_dalek::SigningKey, String, AppManifest) { let key = ed25519_dalek::SigningKey::generate(&mut rand::rngs::OsRng); let did = crate::identity::did_key_from_pubkey_hex(&hex::encode(key.verifying_key().as_bytes())) .unwrap(); let mut manifest = sample_manifest(); manifest.author.did = did.clone(); sign_manifest(&mut manifest, &key).unwrap(); (key, did, manifest) } #[test] fn verify_accepts_a_properly_signed_manifest() { let (_key, _did, manifest) = signed_manifest(); assert_eq!(verify_manifest_signature(&manifest), SignatureStatus::Valid); } #[test] fn verify_reports_missing_when_there_is_no_signature_block() { let manifest = sample_manifest(); assert_eq!( verify_manifest_signature(&manifest), SignatureStatus::Missing ); } /// Tampering with any covered field must break the hash check. #[test] fn verify_rejects_a_tampered_field() { let (_key, _did, mut manifest) = signed_manifest(); manifest.container.image = "docker.io/evil/backdoor:1.0.0".into(); match verify_manifest_signature(&manifest) { SignatureStatus::Invalid { reason } => { assert!(reason.contains("does not match"), "reason: {reason}") } other => panic!("tampered manifest accepted: {other:?}"), } } /// The interesting attack: tamper with the content AND recompute /// `manifest_hash` so the integrity check passes. Without the key the /// signature can't be regenerated, so this must still fail. #[test] fn verify_rejects_tampering_that_also_rewrites_the_hash() { let (_key, _did, mut manifest) = signed_manifest(); let stolen_signature = manifest.signatures.clone().unwrap().did_signature; manifest.container.image = "docker.io/evil/backdoor:1.0.0".into(); let new_digest = manifest_digest(&manifest).unwrap(); manifest.signatures = Some(ManifestSignatures { manifest_hash: format!("sha256:{}", hex::encode(new_digest)), did_signature: stolen_signature, }); match verify_manifest_signature(&manifest) { SignatureStatus::Invalid { reason } => { assert!(reason.contains("does not verify"), "reason: {reason}") } other => panic!("forged manifest accepted: {other:?}"), } } /// Signing with one key while claiming another's DID must fail — this is /// the impersonation case the whole layer exists to stop. #[test] fn verify_rejects_a_signature_from_a_key_other_than_the_claimed_did() { let (_key_a, _did_a, mut manifest) = signed_manifest(); let (_key_b, did_b, _) = signed_manifest(); manifest.author.did = did_b; assert!( !verify_manifest_signature(&manifest).is_valid(), "a manifest signed by A must not verify as B" ); } #[test] fn verify_rejects_unusable_author_dids_and_malformed_signatures() { let (key, _did, base) = signed_manifest(); // Not a did:key at all. let mut m = base.clone(); m.author.did = "did:web:example.com".into(); assert!(!verify_manifest_signature(&m).is_valid()); // did:key shaped but not decodable — the old code scored this 30/30. let mut m = base.clone(); m.author.did = "did:key:z6MkTest123".into(); assert!(!verify_manifest_signature(&m).is_valid()); // Signature that isn't base64. let mut m = base.clone(); m.signatures.as_mut().unwrap().did_signature = "not!base64!".into(); assert!(!verify_manifest_signature(&m).is_valid()); // Base64 of the wrong length. let mut m = base.clone(); m.signatures.as_mut().unwrap().did_signature = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [0u8; 16]); assert!(!verify_manifest_signature(&m).is_valid()); // Hash in the wrong form. let mut m = base.clone(); m.signatures.as_mut().unwrap().manifest_hash = "deadbeef".into(); assert!(!verify_manifest_signature(&m).is_valid()); // Sanity: the untouched manifest still verifies, so the cases above // failed for their own reasons and not because the fixture is broken. let mut ok = base; sign_manifest(&mut ok, &key).unwrap(); assert!(verify_manifest_signature(&ok).is_valid()); } /// `env` is a HashMap, whose iteration order is randomised per process. If /// the preimage were not canonicalised, the same manifest would hash /// differently between runs and signatures would fail at random. #[test] fn digest_is_stable_regardless_of_map_insertion_order() { let mut a = sample_manifest(); a.container.env.insert("ZEBRA".into(), "1".into()); a.container.env.insert("ALPHA".into(), "2".into()); a.container.env.insert("MIDDLE".into(), "3".into()); let mut b = sample_manifest(); b.container.env.insert("MIDDLE".into(), "3".into()); b.container.env.insert("ALPHA".into(), "2".into()); b.container.env.insert("ZEBRA".into(), "1".into()); assert_eq!(manifest_digest(&a).unwrap(), manifest_digest(&b).unwrap()); assert_eq!( canonical_signing_bytes(&a).unwrap(), canonical_signing_bytes(&b).unwrap() ); } /// The signature must not cover the field that holds it, or re-signing an /// already-signed manifest would produce a different digest each time. #[test] fn digest_ignores_the_signatures_block() { let (key, _did, signed) = signed_manifest(); let mut unsigned = signed.clone(); unsigned.signatures = None; assert_eq!( manifest_digest(&signed).unwrap(), manifest_digest(&unsigned).unwrap() ); // Re-signing is stable (Ed25519 is deterministic). let mut resigned = signed.clone(); sign_manifest(&mut resigned, &key).unwrap(); assert_eq!( resigned.signatures.unwrap().did_signature, signed.signatures.unwrap().did_signature ); } #[test] fn test_trust_score_full() { let (_key, did, manifest) = signed_manifest(); let signature = verify_manifest_signature(&manifest); let (score, tier) = calculate_trust_score(&manifest, 3, &[did], &signature); // identity (30) + relays 2-3 (12) + federation (20) + semver (10) + repo (5) + security clean (15) = 92 assert!(score >= 80, "Expected verified, got score={score}"); assert_eq!(tier, "verified"); } #[test] fn test_trust_score_no_federation() { let (_key, _did, manifest) = signed_manifest(); let signature = verify_manifest_signature(&manifest); let (score, tier) = calculate_trust_score(&manifest, 1, &[], &signature); // identity (30) + 1 relay (5) + semver (10) + repo (5) + security (15) = 65 assert_eq!(tier, "community"); assert!((50..80).contains(&score)); } #[test] fn test_trust_score_untrusted() { let mut manifest = sample_manifest(); manifest.author.did = String::new(); manifest.repo_url = String::new(); manifest.version = "1".into(); manifest.container.readonly_root = false; let (score, _tier) = calculate_trust_score(&manifest, 1, &[], &SignatureStatus::Missing); assert!(score < 50, "Expected low score, got {score}"); } /// The regression that made "Verified" meaningless: an unsigned manifest /// with a plausible-looking DID string used to score 30/30 on identity and /// land at 65 — "Community" — on nothing but a `starts_with("did:")`. #[test] fn an_unsigned_manifest_earns_no_identity_points() { let manifest = sample_manifest(); // author.did is "did:key:z6MkTest123" let signature = verify_manifest_signature(&manifest); assert_eq!(signature, SignatureStatus::Missing); let (score, tier) = calculate_trust_score(&manifest, 1, &[], &signature); // 1 relay (5) + semver (10) + repo (5) + security (15) = 35, no identity 30. assert_eq!(score, 35); assert_eq!(tier, "unverified"); } /// Impersonation via the federation factor: claiming a DID the user /// federates with must earn nothing unless the claim is proven. #[test] fn claiming_a_federated_did_without_proving_it_earns_no_federation_points() { let (_key, victim_did, _) = signed_manifest(); let mut impostor = sample_manifest(); impostor.author.did = victim_did.clone(); assert_eq!( verify_manifest_signature(&impostor), SignatureStatus::Missing ); let federated = [victim_did]; let (unproven, _) = calculate_trust_score(&impostor, 1, &federated, &SignatureStatus::Missing); let (proven, _) = calculate_trust_score(&impostor, 1, &federated, &SignatureStatus::Valid); assert_eq!( proven - unproven, 50, "identity (30) + federation (20) must both hang off proof" ); } #[test] fn test_manifest_serialization() { let manifest = sample_manifest(); let json = serde_json::to_string(&manifest).unwrap(); let parsed: AppManifest = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.app_id, "test-app"); assert_eq!(parsed.description.short(), "A test app"); } #[test] fn test_category_display() { assert_eq!(AppCategory::Money.to_string(), "money"); assert_eq!(AppCategory::Networking.to_string(), "networking"); } #[tokio::test] async fn test_cache_persistence() { let dir = tempfile::tempdir().unwrap(); let cache = MarketplaceCache { apps: vec![DiscoveredApp { manifest: sample_manifest(), trust_score: 75, trust_tier: "community".into(), relay_count: 2, first_seen: "2026-03-10T00:00:00Z".into(), nostr_pubkey: "abc123".into(), signature: SignatureStatus::Valid, }], last_updated: "2026-03-10T00:00:00Z".into(), }; save_cache(dir.path(), &cache).await.unwrap(); let loaded = load_cache(dir.path()).await.unwrap(); assert_eq!(loaded.apps.len(), 1); assert_eq!(loaded.apps[0].manifest.app_id, "test-app"); assert_eq!(loaded.apps[0].signature, SignatureStatus::Valid); } /// A cache written before the signature field existed must still load, and /// must come back as unverified rather than silently defaulting to trusted. #[tokio::test] async fn a_legacy_cache_without_the_signature_field_loads_as_missing() { let dir = tempfile::tempdir().unwrap(); ensure_dirs(dir.path()).await.unwrap(); let legacy = serde_json::json!({ "apps": [{ "manifest": sample_manifest(), "trust_score": 75, "trust_tier": "community", "relay_count": 2, "first_seen": "2026-03-10T00:00:00Z", "nostr_pubkey": "abc123" }], "last_updated": "2026-03-10T00:00:00Z" }); let path = dir .path() .join(MARKETPLACE_DIR) .join("cache") .join(CACHE_FILE); fs::write(&path, serde_json::to_vec(&legacy).unwrap()) .await .unwrap(); let loaded = load_cache(dir.path()).await.unwrap(); assert_eq!(loaded.apps.len(), 1); assert_eq!(loaded.apps[0].signature, SignatureStatus::Missing); } /// `SignatureStatus` crosses the RPC boundary to the UI, so its wire shape /// is a contract worth pinning. #[test] fn signature_status_serialises_to_a_tagged_object() { assert_eq!( serde_json::to_value(SignatureStatus::Valid).unwrap(), serde_json::json!({ "status": "valid" }) ); assert_eq!( serde_json::to_value(SignatureStatus::Missing).unwrap(), serde_json::json!({ "status": "missing" }) ); assert_eq!( serde_json::to_value(SignatureStatus::Invalid { reason: "nope".into() }) .unwrap(), serde_json::json!({ "status": "invalid", "reason": "nope" }) ); } }