feat(marketplace): implement the DID signature layer that was only specified

docs/marketplace-protocol.md described a full authorship-verification chain and
was marked "shipped end-to-end". It wasn't: `signatures.manifest_hash` and
`signatures.did_signature` existed only as two struct fields that nothing read.
The authenticity actually delivered was the Nostr event's NIP-01 Schnorr
signature — which proves who *relayed* an event, not who *authored* the manifest
inside it. Anyone could republish someone else's manifest under their own DID.

Implemented:

- `canonical_signing_bytes` / `manifest_digest` — the signed preimage is the
  manifest as canonical JSON (recursively sorted keys, no whitespace) with
  `signatures` omitted, SHA-256'd. Canonicalisation is load-bearing, not
  cosmetic: `container.env` is a HashMap with per-process random iteration
  order, and `serde_json::Map` is only sorted while the `preserve_order` feature
  stays off — a feature any crate in the graph can enable for everyone via
  feature unification. Either would make the digest vary between runs, so
  signatures would fail *intermittently*, which is far worse to diagnose than
  failing cleanly.
- `sign_manifest` / `verify_manifest_signature` — Ed25519 over the 32 raw digest
  bytes, verified against the key `author.did` encodes (reusing the existing
  `identity::pubkey_bytes_from_did_key`).
- `publish` signs before broadcasting, fills `author.did` when empty, and
  **refuses** to publish under a DID this node cannot sign as — otherwise we'd
  spray manifests across every relay that every verifier then rejects.
- `discover` verifies before caching. A `missing` signature is a normal
  unsigned publisher: listed, but earning no identity trust. An `invalid` one is
  tampered or forged, so it is **dropped entirely** and logged — it fails closed
  rather than appearing behind a warning badge a user can click through.

Trust scoring now requires proof for both identity-derived factors:

- The 30-point identity factor was `did.starts_with("did:")`. An unsigned
  manifest with a plausible DID string and a pinned image scored 65 —
  "Community" — on no cryptography at all. It now scores 35, "Unverified".
- **The 20-point federation factor is gated too**, which the original spec did
  not say. An unverified `author.did` is just a string the publisher chose, so
  without this an attacker could copy the DID of a peer the user federates with
  and be rewarded for impersonating the party they trust most.

`marketplace.verify` now returns the signature verdict separately from the
advisory policy issues — `valid` has always meant "passes the advisory security
checks", so conflating it with authenticity would have been its own trap.

Tests (22 pass), weighted to the adversarial cases: tampering; tampering that
also rewrites `manifest_hash` while reusing the stolen signature; signing with
key A while claiming B's DID; undecodable did:keys including the old
`z6MkTest123` fixture that used to score 30/30; malformed base64 and
wrong-length signatures; digest stability across map insertion order; the digest
ignoring the `signatures` block; the federation-impersonation case; and a legacy
cache without the new field loading as `missing` rather than defaulting trusted.

Protocol doc rewritten so the preimage rules are normative — a third-party
implementation that canonicalises differently produces signatures we reject, so
"sorted keys, no whitespace, signatures omitted, sign the raw digest" now has to
be stated exactly rather than sketched.

Not included: surfacing the verdict in Marketplace.vue, which reads only
trust_score/trust_tier today. The field reaches the frontend; where the badge
goes is a UI call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-08 05:39:55 -04:00
co-authored by Claude Opus 5
parent fe46c898d1
commit f0c289a415
3 changed files with 624 additions and 65 deletions
+8 -1
View File
@@ -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,
}))
+503 -16
View File
@@ -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<Vec<u8>> {
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:<hex>`.
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:<hex> 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<String> {
}
/// 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" })
);
}
}