diff --git a/core/archipelago/src/api/rpc/marketplace.rs b/core/archipelago/src/api/rpc/marketplace.rs index 6938b93e..5437b19f 100644 --- a/core/archipelago/src/api/rpc/marketplace.rs +++ b/core/archipelago/src/api/rpc/marketplace.rs @@ -118,11 +118,18 @@ impl RpcHandler { .map_err(|e| anyhow::anyhow!("Invalid manifest: {}", e))?; let issues = marketplace::validate_manifest(&manifest); - let (trust_score, trust_tier) = marketplace::calculate_trust_score(&manifest, 0, &[]); + let signature = marketplace::verify_manifest_signature(&manifest); + let (trust_score, trust_tier) = + marketplace::calculate_trust_score(&manifest, 0, &[], &signature); Ok(serde_json::json!({ + // Security-policy compliance (advisory — these are score inputs). "valid": issues.is_empty(), "issues": issues, + // Cryptographic authorship: valid | missing | invalid(+reason). + // This is the one that says whether author.did is proven. + "signature": signature, + "signature_valid": signature.is_valid(), "trust_score": trust_score, "trust_tier": trust_tier, })) diff --git a/core/archipelago/src/marketplace.rs b/core/archipelago/src/marketplace.rs index 8608cd74..2dd6cc46 100644 --- a/core/archipelago/src/marketplace.rs +++ b/core/archipelago/src/marketplace.rs @@ -158,6 +158,181 @@ pub struct DiscoveredApp { 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. @@ -256,15 +431,22 @@ pub fn validate_manifest(manifest: &AppManifest) -> Vec { } /// 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; - // DID verification (30 points) — has a valid DID in author - if !manifest.author.did.is_empty() && manifest.author.did.starts_with("did:") { + // 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; } @@ -275,8 +457,13 @@ pub fn calculate_trust_score( _ => 20, }; - // Federation trust (20 points) — developer DID in federation - if federated_dids.contains(&manifest.author.did) { + // 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; } @@ -377,9 +564,28 @@ pub async fn discover( 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); + let (trust_score, trust_tier) = + calculate_trust_score(&manifest, 1, federated_dids, &signature); ( DiscoveredApp { manifest, @@ -388,6 +594,7 @@ pub async fn discover( relay_count: 0, first_seen: event.created_at.to_human_datetime(), nostr_pubkey: event.pubkey.to_hex(), + signature, }, 0, ) @@ -400,7 +607,8 @@ pub async fn discover( .into_values() .map(|(mut app, relay_count)| { app.relay_count = relay_count; - let (score, tier) = calculate_trust_score(&app.manifest, relay_count, federated_dids); + let (score, tier) = + calculate_trust_score(&app.manifest, relay_count, federated_dids, &app.signature); app.trust_score = score; app.trust_tier = tier; app @@ -440,6 +648,32 @@ pub async fn publish( } 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)?; @@ -636,21 +870,179 @@ mod tests { 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 manifest = sample_manifest(); - let (score, tier) = - calculate_trust_score(&manifest, 3, &["did:key:z6MkTest123".to_string()]); - // DID (30) + relay consensus 2-3 (12) + federation (20) + semver (10) + repo (5) + security clean (15) = 92 - assert!(score >= 80, "Expected verified, got score={}", score); + 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 manifest = sample_manifest(); - let (score, tier) = calculate_trust_score(&manifest, 1, &[]); - // DID (30) + 1 relay (5) + no federation (0) + semver (10) + repo (5) + security (15) = 65 + 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)); } @@ -662,8 +1054,49 @@ mod tests { manifest.repo_url = String::new(); manifest.version = "1".into(); manifest.container.readonly_root = false; - let (score, _tier) = calculate_trust_score(&manifest, 1, &[]); - assert!(score < 50, "Expected low score, got {}", score); + 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] @@ -692,6 +1125,7 @@ mod tests { 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(), }; @@ -699,5 +1133,58 @@ mod tests { 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" }) + ); } } diff --git a/docs/marketplace-protocol.md b/docs/marketplace-protocol.md index 56e83f2c..b73bd845 100644 --- a/docs/marketplace-protocol.md +++ b/docs/marketplace-protocol.md @@ -10,15 +10,13 @@ purchases). What remains is maturation: publishing tooling and trust UX own flatter format, **not** the runtime `apps/*/manifest.yml` schema (`app-manifest-spec.md`). -> ⚠️ **The DID signature layer is specified here but NOT implemented.** -> `signatures.manifest_hash` and `signatures.did_signature` exist as fields on -> the manifest struct (`marketplace.rs:106-107`) and nothing anywhere in the -> codebase reads them — there is no hash comparison and no signature check. The -> authenticity you actually get today is the **Nostr event signature** (NIP-01 -> Schnorr, verified by the client library), which proves the event came from the -> publishing key. It does not prove the manifest was signed by the DID it names. -> Sections marked *(not implemented)* below are design, not behaviour. Treat this -> as the gap to close before third-party publishing opens. +> **The DID signature layer is implemented** as of 2026-08-08. `publish` signs +> with the node's Ed25519 identity key, `discover` verifies every manifest +> before caching it, and a manifest whose signature is *present but wrong* is +> dropped rather than listed at a lower score. See +> [Signing Protocol](#signing-protocol) for the exact preimage rules — they are +> normative, and an implementation that canonicalises differently will produce +> signatures this node rejects. ## Overview @@ -156,13 +154,21 @@ App manifests use **NIP-78 application-specific data** with event kind **30078** ### Publishing a Manifest 1. Developer creates/updates their app manifest -2. Serialize manifest as JSON -3. Compute SHA-256 hash of the serialized manifest -4. Sign the hash with the developer's DID key -5. Embed manifest + signature in Nostr event content -6. Sign the Nostr event with the node's secp256k1 key +2. `author.did` is filled in with the node's own `did:key` if empty. If it is + set to a **different** DID, publishing is refused — the node can only sign as + itself, and broadcasting a manifest every verifier will reject helps nobody +3. Canonicalise the manifest without `signatures` and SHA-256 it (see + [Signing Protocol](#signing-protocol)) +4. Sign the digest with the node's Ed25519 identity key and attach `signatures` +5. Embed the signed manifest as the Nostr event content +6. Sign the Nostr event with the node's secp256k1 Nostr key 7. Publish to all configured Nostr relays +Note the two distinct keys: the **Ed25519 identity key** proves *authorship of +the manifest* and is what `author.did` names; the **secp256k1 Nostr key** proves +*who sent this event*. They are separate on purpose — relaying is not +authorship, and only the first survives being copied between relays. + ### Discovering Manifests 1. Node queries configured relays with filter: @@ -187,21 +193,29 @@ App manifests use **NIP-78 application-specific data** with event kind **30078** Each discovered app receives a trust score (0-100) based on: -This table is `calculate_trust_score()` in `marketplace.rs:258-306`, and several -factors are weaker than their names suggest: +This table is `calculate_trust_score()` in `marketplace.rs`. What each factor +actually checks: -| Factor | Max | What is actually checked | -|--------|-----|--------------------------| -| **DID present** | 30 | `author.did` is non-empty and starts with `did:` — a **string prefix test, not a signature check**. Any publisher can claim any DID and collect these 30 points | +| Factor | Max | What is checked | +|--------|-----|-----------------| +| **Identity proven** | 30 | The manifest carries a `valid` DID signature — the author demonstrated control of the key `author.did` encodes. Requires key material; cannot be faked by choosing a string | | **Relay consensus** | 20 | Graduated, and never zero: 1 relay → 5, 2–3 → 12, 4+ → 20 | -| **Federation trust** | 20 | `author.did` appears in the user's federated DID list | -| **Provenance** | 15 | 10 for a 3-part semver `version`, 5 for a non-empty `repo_url`. Nothing counts published versions — the old "shows maintenance" reading was wrong | +| **Federation trust** | 20 | `author.did` is in the user's federated DID list **and** identity is proven. Both halves are required — see below | +| **Provenance** | 15 | 10 for a 3-part semver `version`, 5 for a non-empty `repo_url`. Nothing counts published versions | | **Security compliance** | 15 | 15 when `validate_manifest()` returns no issues, 5 when it returns 1–2, 0 otherwise | -Because "DID present" needs no key material, an unsigned manifest with a -plausible-looking DID string and a pinned image already scores 30 + 5 + 10 + 5 + -15 = 65 — **"Community" tier**. Read the tiers below with that in mind until the -signature layer lands. +Both identity-derived factors hang off the signature, which is the point: + +- Before, "DID present" was `did.starts_with("did:")`, so an unsigned manifest + with a plausible-looking DID string and a pinned image scored 65 — *Community* + tier — on no cryptography whatsoever. It now scores 35, *Unverified*. +- Federation trust is gated too. An unverified `author.did` is just a string the + publisher chose, so an attacker could otherwise copy the DID of a peer the + user federates with and collect 20 points for impersonating precisely the + party the user trusts most. + +An unsigned publisher is not punished beyond losing those points: `missing` is a +normal state, and such apps still appear. ### Trust Tiers @@ -233,25 +247,46 @@ When a developer's DID appears in the user's federation network (trusted peer), ## Signing Protocol -### Manifest Signing (DID Layer) — *(not implemented)* +### Manifest Signing (DID Layer) -The steps below are the intended design. Nothing in the codebase produces or -checks a `did_signature` today; `marketplace.publish` emits the manifest inside -a Nostr event and relies on the event's own Schnorr signature. +**Normative.** These rules define the signed preimage byte-for-byte. An +implementation that canonicalises differently will produce signatures this node +rejects, so they are worth following exactly. ``` -1. Serialize manifest to canonical JSON (sorted keys, no whitespace) -2. Compute: manifest_hash = SHA-256(canonical_json) -3. Sign: did_signature = Ed25519_Sign(did_private_key, manifest_hash) -4. Attach to manifest: +1. Take the manifest with `signatures` REMOVED (a signature cannot cover the + field that holds it; omit the key entirely rather than setting it null). +2. Canonicalise to JSON: + - every object's keys sorted lexicographically, recursively; + - no insignificant whitespace; + - arrays keep their order. +3. manifest_hash = SHA-256(canonical_json_bytes) +4. did_signature = Ed25519_Sign(author_private_key, manifest_hash) + ^ the signature covers the 32 RAW DIGEST BYTES, not the "sha256:..." + string and not the JSON itself. +5. Attach: { "signatures": { - "manifest_hash": "sha256:", - "did_signature": "" + "manifest_hash": "sha256:<64 lowercase hex chars>", + "did_signature": "" } } ``` +The signing key MUST be the Ed25519 key that `author.did` encodes — `author.did` +is a `did:key` whose multibase body is `0xed01 || <32-byte public key>`. A +publisher signing with any other key produces a manifest that verifies as +`invalid` and is dropped. + +**Why canonicalisation is required and not cosmetic.** `container.env` is a map, +and map iteration order is not stable across processes or implementations. Sign +the serialiser's natural output and the same manifest hashes differently between +runs, so signatures fail at random rather than never — much harder to diagnose +than a clean rejection. Sorting keys removes the ambiguity. + +`archipelago` implements this in `marketplace::canonical_signing_bytes` / +`sign_manifest` / `verify_manifest_signature`. + ### Event Signing (Nostr Layer) Standard NIP-01 Schnorr signature over the event ID (hash of serialized event fields). This is handled by the Nostr client library. @@ -260,19 +295,31 @@ Standard NIP-01 Schnorr signature over the event ID (hash of serialized event fi ``` Receiving Node: - 1. Verify Nostr event signature (NIP-01) → Proves event authenticity [IMPLEMENTED] - 2. Extract manifest JSON from event content [IMPLEMENTED] - 3. Compute SHA-256 of manifest content [NOT IMPLEMENTED] - 4. Compare with manifest.signatures.manifest_hash → content integrity [NOT IMPLEMENTED] - 5. Resolve DID document for manifest.author.did [NOT IMPLEMENTED] - 6. Verify did_signature with DID public key → developer identity [NOT IMPLEMENTED] - 7. Check container.image tag is pinned (not :latest) [ADVISORY ONLY] - 8. Validate security fields meet minimums [ADVISORY ONLY] + 1. Verify Nostr event signature (NIP-01) → event authenticity [IMPLEMENTED] + 2. Extract manifest JSON from event content [IMPLEMENTED] + 3. Canonicalise the manifest without `signatures`, SHA-256 it [IMPLEMENTED] + 4. Compare with manifest.signatures.manifest_hash → content integrity [IMPLEMENTED] + 5. Resolve author.did (did:key) to its Ed25519 public key [IMPLEMENTED] + 6. Verify did_signature over the digest → author identity [IMPLEMENTED] + 7. Check container.image tag is pinned (not :latest) [ADVISORY] + 8. Validate security fields meet minimums [ADVISORY] ``` -Steps 3–6 are the unimplemented DID layer from the warning at the top of this -document. Steps 7–8 run, but `validate_manifest()` returns a list of *issues* -that feed the trust score — they do not block discovery or installation. +Steps 3–6 are `verify_manifest_signature()`, which returns one of three verdicts +rather than a boolean: + +| Verdict | Meaning | What discovery does | +|---|---|---| +| `valid` | Hash matches the content **and** the key named by `author.did` signed it | Listed; earns the identity-derived trust points | +| `missing` | No `signatures` block | Listed, but scores **zero** on identity and federation. An unsigned publisher is unproven, not hostile | +| `invalid` | A `signatures` block is present and wrong — tampered, corrupt, or signed by another key | **Dropped entirely**, with the reason logged. Never cached, never installable | + +That `invalid` handling is deliberate: a broken signature is not a low-quality +manifest, it is a forged or corrupted one, so it fails closed rather than +appearing with a scary badge someone can click past. + +Steps 7–8 still run, but `validate_manifest()` returns a list of *issues* that +feed the trust score — they do not block discovery or installation. ## RPC Endpoints @@ -281,9 +328,27 @@ that feed the trust score — they do not block discovery or installation. | Method | Description | Auth | |--------|-------------|------| | `marketplace.discover` | Query relays for app manifests, verify, score, return sorted | Local | -| `marketplace.publish` | Publish an app manifest to configured relays | Local | +| `marketplace.publish` | Sign the manifest with this node's identity key, then publish to configured relays | Local | | `marketplace.get-manifest` | Get full manifest for a specific app by ID | Local | -| `marketplace.verify` | Verify a manifest's signatures and security compliance | Local | +| `marketplace.verify` | Check a manifest's DID signature and security compliance without publishing it | Local | + +`marketplace.verify` returns the signature verdict separately from the advisory +policy issues, because they mean different things: + +```json +{ + "signature": { "status": "invalid", "reason": "did_signature does not verify against author.did" }, + "signature_valid": false, + "valid": true, // ← policy compliance only; NOT authenticity + "issues": [], + "trust_score": 35, + "trust_tier": "unverified" +} +``` + +`valid` has always meant "passes the advisory security checks". Read +`signature_valid` for authenticity. Discovered apps carry the same verdict in +their `signature` field. ### Manifest Management