docs(marketplace-protocol): the DID signature layer is specified but not implemented

This doc is marked "Status: implemented ... shipped end-to-end" and then
describes a cryptographic verification chain that does not exist. On a repo
about to go public, that is the single worst kind of doc bug: it promises a
security property.

`signatures.manifest_hash` / `signatures.did_signature` appear exactly once in
the codebase — as two struct fields at `marketplace.rs:106-107`. Nothing reads
them. There is no hash comparison, no DID resolution, no signature check. The
authenticity actually delivered is the Nostr event's own NIP-01 Schnorr
signature, which proves the publishing key sent the event but says nothing about
the DID the manifest names.

Added a warning at the top, marked the "Manifest Signing (DID Layer)" section
and steps 3-6 of the verification flow as not implemented, and annotated steps
7-8 as advisory (validate_manifest returns scoring issues; it does not block
discovery or install).

The trust model was overstated in the same direction:
- "DID Verification | 30 | Manifest is signed by a valid DID key" is a
  `did.starts_with("did:")` string test. Any publisher can claim any DID and
  take the 30 points.
- "Relay Consensus | 20" is graduated and never zero (1 relay still scores 5).
- "Version History | 15 | multiple published versions (shows maintenance)" —
  nothing counts versions; it's 10 for a 3-part semver plus 5 for a non-empty
  repo_url.
Worked the arithmetic through: an unsigned manifest with a plausible DID string
and a pinned image scores 65, landing in the "Community" tier. Said so.

Other corrections:
- `marketplace.unpublish` is documented but was never implemented (the string
  appears nowhere); removed it and noted why NIP-33 makes it non-trivial. Added
  the two payment methods that do exist (`create-invoice`, `check-payment`).
- The schema section said marketplace manifests "follow the existing
  apps/{app-id}/manifest.yml schema", contradicting the header three paragraphs
  above. They are separate types.
- The security-enforcement list claimed a capability allow-list, a
  host-networking ban and system-path mount restrictions. Those rules are real
  but live in the runtime manifest parser for a different schema — marketplace
  validation checks four things and gates none of them.
- `run_as_user` documented as "> 1000" in two places while the code checks
  `>= 1000` and the doc's own example uses 1000.
- Data-storage tree listed `cache/trust-scores.json` and `config.json`; neither
  is ever written.
- The 15-minute cache TTL and 30-minute background refresh don't exist —
  discovery is RPC-triggered and the cache has no expiry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-07 20:59:45 -04:00
co-authored by Claude Opus 5
parent 69f1d18ee7
commit db60c3382d
+92 -35
View File
@@ -10,6 +10,16 @@ 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.
## Overview
Archipelago's community marketplace enables developers to publish app manifests to Nostr relays, where nodes discover and install them without a central app store. Trust is established through DID-signed manifests and community reputation.
@@ -37,7 +47,12 @@ Developer Node Nostr Relays User Node
## Manifest Schema
App manifests published to Nostr relays follow the existing `apps/{app-id}/manifest.yml` schema (see `docs/app-manifest-spec.md`), serialized as JSON within a Nostr event.
App manifests published to Nostr relays use the marketplace's own flatter JSON
schema — the `AppManifest` type in `marketplace.rs`, shown below — serialized
into the Nostr event's `content`. It is **not** the runtime
`apps/{app-id}/manifest.yml` schema in
[`app-manifest-spec.md`](app-manifest-spec.md); the two are separate types that
happen to share a name.
### Marketplace Manifest Fields
@@ -98,7 +113,7 @@ App manifests published to Nostr relays follow the existing `apps/{app-id}/manif
|-------|---------|-------------|
| `container.readonly_root` | true | Container root filesystem is read-only |
| `container.no_new_privileges` | true | Prevent privilege escalation |
| `container.run_as_user` | 1000 | UID to run as (must be > 1000) |
| `container.run_as_user` | 1000 | UID to run as (must be 1000) |
| `container.capabilities` | [] | Required Linux capabilities (drop all, add only needed) |
## Nostr Event Format
@@ -172,13 +187,21 @@ App manifests use **NIP-78 application-specific data** with event kind **30078**
Each discovered app receives a trust score (0-100) based on:
| Factor | Weight | Description |
|--------|--------|-------------|
| **DID Verification** | 30 | Manifest is signed by a valid DID key |
| **Relay Consensus** | 20 | Manifest found on multiple independent relays |
| **Federation Trust** | 20 | Developer's DID is in the user's federation network |
| **Version History** | 15 | App has multiple published versions (shows maintenance) |
| **Security Compliance** | 15 | Manifest follows all security requirements |
This table is `calculate_trust_score()` in `marketplace.rs:258-306`, and several
factors are weaker than their names suggest:
| 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 |
| **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 |
| **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.
### Trust Tiers
@@ -210,7 +233,11 @@ When a developer's DID appears in the user's federation network (trusted peer),
## Signing Protocol
### Manifest Signing (DID Layer)
### Manifest Signing (DID Layer) — *(not implemented)*
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.
```
1. Serialize manifest to canonical JSON (sorted keys, no whitespace)
@@ -233,16 +260,20 @@ 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
2. Extract manifest JSON from event content
3. Compute SHA-256 of manifest content
4. Compare with manifest.signatures.manifest_hash → Proves content integrity
5. Resolve DID document for manifest.author.did
6. Verify did_signature with DID public key → Proves developer identity
7. Check container.image tag is pinned (not :latest)
8. Validate security fields meet minimums
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]
```
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.
## RPC Endpoints
### Marketplace Discovery
@@ -259,21 +290,40 @@ Receiving Node:
| Method | Description | Auth |
|--------|-------------|------|
| `marketplace.list-published` | List manifests published by this node | Local |
| `marketplace.unpublish` | Remove a published manifest from relays | Local |
### Purchases
| Method | Description | Auth |
|--------|-------------|------|
| `marketplace.create-invoice` | Create a Lightning BOLT11 invoice for a paid app | Local |
| `marketplace.check-payment` | Poll whether an invoice has settled | Local |
`marketplace.unpublish` was specified here but **never implemented** — the
string appears nowhere in the codebase, and there is no dispatcher entry. NIP-33
replaceable events mean an unpublish would have to be a tombstone/replacement
rather than a delete, which is presumably why it stalled.
## Security Requirements
### Container Security Enforcement
Before installing a community app, the node validates:
`validate_manifest()` checks the following and returns them as a list of issues.
**These are score inputs, not gates** — a manifest that fails all of them is
still discoverable and installable, it just scores 0 on the security factor:
1. **No `latest` tag**: Image must use a specific version tag
2. **Read-only root**: `readonly_root` must be true (or explicitly overridden by user)
3. **No root**: `run_as_user` must be > 1000
4. **No new privileges**: `no_new_privileges` must be true
5. **Minimal capabilities**: Only allowed capabilities are accepted (CHOWN, NET_BIND_SERVICE, etc.)
6. **No host networking**: Apps cannot use `--network host`
7. **Volume restrictions**: Apps cannot mount system paths (/, /etc, /var, /usr)
2. **Read-only root**: `readonly_root` should be true
3. **No root**: `run_as_user` must be **≥ 1000** (the code's bound; the example
manifest above uses exactly `1000`)
4. **No new privileges**: `no_new_privileges` should be true
Items previously listed here — a capability allow-list, a host-networking ban,
and system-path mount restrictions — are **not** part of marketplace validation.
Those rules exist, but they live in the runtime manifest parser
(`core/container/src/manifest.rs`, see [`app-manifest-spec.md`](app-manifest-spec.md))
and apply to `apps/*/manifest.yml`, which is a different schema from the
marketplace manifest. Closing that gap is part of the pre-third-party-publishing
work.
### Image Verification
@@ -316,22 +366,29 @@ Accessible from Settings or a "Developer" section:
```
/var/lib/archipelago/marketplace/
├── cache/
── manifests.json # Cached discovered manifests
│ └── trust-scores.json # Cached trust scores
├── published/
│ └── <app-id>.json # Manifests published by this node
└── config.json # Marketplace preferences (auto-refresh interval, etc.)
── manifests.json # Cached discovered manifests, trust scores included
└── published/
└── <app-id>.json # Manifests published by this node
```
The earlier version of this tree also listed `cache/trust-scores.json` and
`config.json`. Neither is written: scores live on the cached entries themselves
(`MarketplaceCache`), and there is no marketplace preferences file.
## Implementation Notes
### Relay Query Strategy
1. Query all enabled relays in parallel (from `nostr_relays.rs` config)
1. Query all enabled relays in parallel (from `nostr_relays.rs` config), with a
10s connect timeout and a 20s fetch timeout per relay
2. Deduplicate manifests by `app_id` + `version`
3. If same manifest found on multiple relays, boost trust score
4. Cache results with 15-minute TTL
5. Background refresh every 30 minutes
3. If the same manifest is found on multiple relays, boost trust score
4. Write results to `cache/manifests.json`
Items 45 of the original design — a 15-minute cache TTL and a 30-minute
background refresh — are **not implemented**. The cache has no expiry and
nothing refreshes it on a timer; it is rewritten whenever
`marketplace.discover` runs.
### Version Comparison