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
+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