Files
archy/docs/marketplace-protocol.md
T
archipelagoandClaude Opus 5 f0c289a415 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>
2026-08-08 05:39:55 -04:00

19 KiB
Raw Blame History

Decentralized App Marketplace Protocol

Status: implemented (updated 2026-07-08). This started as a protocol proposal; the described subsystem is now shipped end-to-end — core/archipelago/src/marketplace.rs (discover/publish/trust scoring), the marketplace.* RPC namespace, and Marketplace.vue. Beyond this doc, the code also adds marketplace.create-invoice (Lightning BOLT11 app purchases). What remains is maturation: publishing tooling and trust UX (see ROADMAP.md). Note: the manifest schema below is the marketplace's own flatter format, not the runtime apps/*/manifest.yml schema (app-manifest-spec.md).

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 for the exact preimage rules — they are normative, and an implementation that canonicalises differently will produce signatures this node rejects.

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.

Architecture

Developer Node                    Nostr Relays                    User Node
     │                                │                               │
     │── Publish signed manifest ──►  │                               │
     │   (NIP-78, kind 30078)         │                               │
     │                                │  ◄── Query app manifests ──   │
     │                                │      (filter by d-tag)        │
     │                                │                               │
     │                                │── Return signed manifests ──► │
     │                                │                               │
     │                                │      [Verify DID signature]   │
     │                                │      [Check trust score]      │
     │                                │      [Display in marketplace] │
     │                                │                               │
     │                                │      [User clicks Install]    │
     │                                │      [Pull container image]   │
     │                                │      [Start container]        │

Manifest Schema

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; the two are separate types that happen to share a name.

Marketplace Manifest Fields

{
  "app_id": "my-bitcoin-tool",
  "name": "My Bitcoin Tool",
  "version": "1.2.0",
  "description": {
    "short": "A useful Bitcoin utility",
    "long": "Detailed description of what this app does..."
  },
  "author": {
    "name": "Developer Name",
    "did": "did:key:z6Mkh...",
    "nostr_pubkey": "npub1..."
  },
  "container": {
    "image": "docker.io/developer/my-bitcoin-tool:1.2.0",
    "ports": [{ "container": 8080, "host": 8180, "protocol": "tcp" }],
    "volumes": [{ "name": "data", "path": "/data" }],
    "env": {
      "NETWORK": "mainnet"
    },
    "capabilities": [],
    "readonly_root": true,
    "no_new_privileges": true,
    "run_as_user": 1000
  },
  "category": "money",
  "icon_url": "https://example.com/icon.png",
  "repo_url": "https://github.com/developer/my-bitcoin-tool",
  "license": "MIT",
  "min_archipelago_version": "0.1.0",
  "dependencies": [],
  "signatures": {
    "manifest_hash": "sha256:abc123...",
    "did_signature": "base64-encoded-signature"
  }
}

Required Fields

Field Type Description
app_id string Unique identifier, lowercase kebab-case
name string Human-readable display name
version string Semantic version (major.minor.patch)
description.short string One-line description (max 120 chars)
author.did string Developer's DID (did:key method)
container.image string Full container image reference with tag (never latest)
category string One of: money, commerce, data, networking, home, community, other

Security-Required Fields

Field Default Description
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.capabilities [] Required Linux capabilities (drop all, add only needed)

Nostr Event Format

Event Kind

App manifests use NIP-78 application-specific data with event kind 30078 (replaceable parameterized). This matches the existing node discovery pattern in nostr_discovery.rs.

Event Structure

{
  "kind": 30078,
  "tags": [
    ["d", "archipelago-app:<app_id>"],
    ["t", "archipelago-marketplace"],
    ["t", "category:<category>"],
    ["version", "<semver>"],
    ["image", "<container_image>"],
    ["L", "archipelago"],
    ["l", "app-manifest", "archipelago"]
  ],
  "content": "<JSON-serialized manifest>",
  "created_at": 1710000000,
  "pubkey": "<developer's secp256k1 pubkey hex>",
  "sig": "<schnorr signature>"
}

Tag Semantics

Tag Purpose
d Unique identifier for NIP-33 replaceable events. Format: archipelago-app:<app_id>
t Searchable topic tags for relay filtering
version Allows version-specific queries
image Container image for quick display without parsing content
L/l NIP-32 labeling namespace for structured queries

Publishing a Manifest

  1. Developer creates/updates their app manifest
  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)
  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:
    {
      "kinds": [30078],
      "limit": 100,
      "#t": ["archipelago-marketplace"]
    }
    
  2. For each returned event: a. Verify Nostr event signature (standard NIP-01) b. Parse manifest JSON from content c. Verify DID signature on manifest hash d. Check manifest against security requirements e. Calculate trust score
  3. Return manifests sorted by trust score

Trust Model

Trust Score Calculation

Each discovered app receives a trust score (0-100) based on:

This table is calculate_trust_score() in marketplace.rs. What each factor actually checks:

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

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

Score Tier UI Treatment
80-100 Verified Green badge, install with one click
50-79 Community Yellow badge, install with confirmation
20-49 Unverified Orange badge, install with warning dialog
0-19 Untrusted Red badge, requires explicit security override

Federation-Based Trust

When a developer's DID appears in the user's federation network (trusted peer), the app automatically receives +20 trust points. This creates organic trust propagation: if you trust a node operator, you're more likely to trust their published apps.

ADR: Nostr Relays over Centralized Registry

Decision: Use Nostr relays as the app discovery layer instead of a centralized registry.

Context: A centralized app store contradicts Archipelago's sovereignty principles. Nostr relays provide censorship-resistant, decentralized event distribution.

Consequences:

  • (+) No single point of failure for app discovery
  • (+) Developers publish without permission or review gates
  • (+) Multiple relay sources increase availability
  • (+) Leverages existing Nostr infrastructure and key management
  • (-) No global content moderation (each node decides trust locally)
  • (-) Spam is possible (mitigated by DID verification and trust scoring)
  • (-) Relay availability varies (mitigated by querying multiple relays)

Signing Protocol

Manifest Signing (DID Layer)

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. 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:<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.

Verification Flow

Receiving Node:
  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 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

Marketplace Discovery

Method Description Auth
marketplace.discover Query relays for app manifests, verify, score, return sorted 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 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:

{
  "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

Method Description Auth
marketplace.list-published List manifests published by this node 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

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

  • Container images are pulled from registries, never transferred between nodes
  • Future: Cosign signature verification for container images (leverages core/security/)
  • Image digest pinning recommended for production apps

UI: Community Marketplace Tab

Route

Extends existing /dashboard/marketplace page.

Layout

Two tabs at the top of Marketplace.vue:

  1. Curated (existing): Built-in apps maintained by Archipelago team
  2. Community (new): Apps discovered from Nostr relays

Community Tab Components

  1. App Grid: Same card layout as curated tab, with trust score badge
  2. Search & Filter: Category filter + text search across community apps
  3. Trust Indicators: Color-coded badges (Verified/Community/Unverified/Untrusted)
  4. App Detail: Shows full manifest, developer DID, relay sources, version history
  5. Install Flow: Trust-level-dependent confirmation (one-click for Verified, warning for Untrusted)

Publishing UI

Accessible from Settings or a "Developer" section:

  1. Select a local app container to publish
  2. Fill in manifest metadata (description, category, icon)
  3. Review security compliance
  4. Sign and publish to relays
  5. View published manifests and their discovery status

Data Storage

/var/lib/archipelago/marketplace/
  ├── cache/
  │   └── 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), with a 10s connect timeout and a 20s fetch timeout per relay
  2. Deduplicate manifests by app_id + version
  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

  • Use semantic versioning for all version comparisons
  • When multiple versions exist for the same app_id, show the latest
  • Keep version history available in app detail view
  • Flag apps with versions older than 6 months as potentially unmaintained