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
+113 -48
View File
@@ -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, 23 → 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 12, 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 keydeveloper 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 36 are the unimplemented DID layer from the warning at the top of this
document. Steps 78 run, but `validate_manifest()` returns a list of *issues*
that feed the trust score — they do not block discovery or installation.
Steps 36 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 78 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