diff --git a/docs/marketplace-protocol.md b/docs/marketplace-protocol.md index bf96ee9f..56e83f2c 100644 --- a/docs/marketplace-protocol.md +++ b/docs/marketplace-protocol.md @@ -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, 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 | +| **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. ### 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 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. + ## 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/ - │ └── .json # Manifests published by this node - └── config.json # Marketplace preferences (auto-refresh interval, etc.) + │ └── manifests.json # Cached discovered manifests, trust scores included + └── published/ + └── .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 4–5 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