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:
co-authored by
Claude Opus 5
parent
fe46c898d1
commit
f0c289a415
@@ -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,
|
||||
}))
|
||||
|
||||
@@ -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" })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+113
-48
@@ -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:<hex>",
|
||||
"did_signature": "<base64>"
|
||||
"manifest_hash": "sha256:<64 lowercase hex chars>",
|
||||
"did_signature": "<standard base64, RFC 4648 §4, with padding>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user