30817:nip-xxxx-ipfs-archive-attestations

NIP-XXXX — IPFS Archive Attestations

covers7503

Jared Logan

published
2026-06-07

draft optional

Status: proposed draft. The kind number is a placeholder pending assignment via the nostr-protocol/nips repository. The reference implementation (Lodestar) uses 7503, an unused number in the regular range chosen only to avoid collisions — the digits carry no meaning. What is deliberate is the range: see Why a regular kind.

Abstract

This NIP defines a regular event kind that attests to a content-addressed archive of a Nostr identity's events and referenced media. The attestation is a signed commitment to a deterministic bundle (its manifest hash and IPFS root CID), making the archive verifiable — authentic, intact, and vouched-for by the pubkey — independently of any relay or media host, and offline.

Motivation

A pubkey's events are scattered across relays that can disappear, and its media lives on hosts that rot. A plain backup of those events and blobs is useful but untrusted: a recipient can't tell whether it has been altered or truncated. Two properties of Nostr let us do better:

  1. Every event is self-verifying (id = sha256(serialization), signed). An archive can preserve this so each event proves its own authenticity.
  2. The author holds a key. The author can therefore sign a commitment to the whole bundle, vouching for a specific snapshot at a known time.

This NIP standardizes that commitment so any client can produce and verify such archives interoperably.

Terminology

  • Bundle — a directory of files: manifest.json, events.jsonl, and media/<sha256> blobs. It is content-addressed with UnixFS to a single root CID.
  • Manifestmanifest.json, a canonical JSON document committing to the exact event set, media set, and crawl coverage.
  • Attestation — the event defined here, committing to the manifest hash and root CID.
  • Canonical JSON — JSON with object keys sorted lexicographically at every depth, no insignificant whitespace, and undefined/absent members omitted. Array element order is significant and fixed by the producer.

The attestation event

A regular event. Authored (signed) by the pubkey being archived.

{
  "kind": 7503,
  "pubkey": "<hex>",
  "created_at": 1730000000,
  "content": "{\"events\":1240,\"media\":318,\"blobs\":270,\"generator\":\"lodestar/1\"}",
  "tags": [
    ["manifest", "<sha256-hex of canonical manifest.json>"],
    ["cid", "<UnixFS root CID, CIDv1, base32 'bafy…'>"],
    ["event_count", "1240"],
    ["media_count", "318"],
    ["blob_count", "270"],
    ["oldest", "1609459200"],
    ["newest", "1729999000"],
    ["version", "1"],
    ["alt", "Archive attestation: 1240 events, 270 media blobs."]
  ]
}

Tags

tag value required
manifest sha256 (hex) of the canonical manifest.json yes
cid UnixFS root CID of the bundle yes
version archive format version yes
event_count number of events committed to recommended
media_count number of media references recorded recommended
blob_count number of media blobs embedded (bytes captured) recommended
oldest unix seconds of the oldest archived event optional
newest unix seconds of the newest archived event optional
alt NIP-31 human-readable description yes

The manifest and cid tags are the only values required for verification; the counts and oldest/newest are conveniences (they appear in the manifest too). content is an informational JSON summary and is not part of the verifiable commitment.

A pubkey MAY publish many attestations over time; each is a permanent snapshot. Clients showing "the latest archive" SHOULD filter { kinds: [7503], authors: [pubkey] } and take the greatest created_at.

Why a regular kind

Regular events (1000–9999) are stored permanently by relays and are never replaced, which is exactly right for an append-only history of archive snapshots. An addressable kind (30000–39999) with a d tag would make "my latest archive" a single queryable coordinate, but each new attestation would overwrite the previous one — destroying the snapshot history. We therefore use a regular kind and query the set by authors + kinds.

The bundle

manifest.json (canonical JSON)

{
  "version": "1",
  "type": "lodestar-archive",
  "generator": "lodestar/1",
  "pubkey": "<hex>",
  "npub": "npub1…",
  "createdAt": 1730000000,
  "eventCount": 1240,
  "eventIds": ["00ab…", "00cd…"],        // ALL event ids, sorted ascending
  "kindCounts": { "1": 1100, "0": 1, "1063": 80 },
  "oldest": 1609459200,
  "newest": 1729999000,
  "media": [ /* MediaItem records, sorted by (eventId, url, source) */ ],
  "mediaBlobs": ["1f3a…", "9b22…"],      // sha256 of every embedded blob, sorted
  "coverage": { /* CoverageManifest, see below */ }
}

eventIds and mediaBlobs are the corpus commitments. Because an event's id is the sha256 of its serialization, the sorted id list pins the event set exactly: no event can be added, removed, or altered without changing the list, and the list is reproduced from events.jsonl.

A MediaItem records one reference and its capture outcome:

{
  "url": "https://…",
  "source": "imeta" | "nip94" | "blossom-path" | "content-url" | "profile-picture",
  "eventId": "<hex>",
  "expectedHash": "<sha256 if the event declared one>",
  "actualHash": "<sha256 of fetched bytes, if captured>",
  "mimeType": "image/jpeg",
  "size": 48213,
  "status": "verified" | "captured-unverified" | "mismatch" | "cors-blocked" | "not-found" | "error",
  "verified": true | false | null,       // null = no declared hash to check against
  "servedFrom": "https://…"
}

verified: null is distinct from false: it means integrity was unprovable (the reference carried no signed hash), not that a check failed.

events.jsonl

Every archived event, one canonical-JSON event per line, sorted by event id. Each line is an unmodified, signed NIP-01 event.

media/<sha256>

Each successfully captured blob, named by the sha256 of its bytes (its content address). Blobs are deduplicated: identical bytes referenced by multiple events are stored once.

Coverage

{
  "pubkey": "<hex>",
  "crawledAt": 1730000000,
  "relaysQueried": ["wss://…"],
  "relays": [
    {
      "url": "wss://…",
      "status": "ok" | "error" | "partial" | "aborted",
      "eventCount": 1200,
      "pages": 4,
      "oldest": 1609459200,
      "newest": 1729999000,
      "reachedEnd": true,
      "hitWindowCap": false,
      "kindCounts": { "1": 1100 },
      "error": "optional message"
    }
  ],
  "totalUnique": 1240,
  "kindCounts": { "1": 1100 },
  "complete": false,                      // ALWAYS false — see Limitations
  "notes": ["…caveats…"]
}

Content addressing

The bundle is encoded as a UnixFS directory over manifest.json, events.jsonl, and the media/ blobs, using the unixfs-v1-2025 import profile: CIDv1, raw leaves, sha2-256, 1 MiB fixed-size chunks, 1024 links per file node, 256-fanout HAMT directory sharding. The serialized form is a CAR whose single root is the cid value; importing the CAR into any IPFS node reproduces that CID. Re-deriving the CID from the loose files (rather than importing the CAR) reproduces it only under the same profile.

The attestation deliberately commits to the bundle but is not contained in it — a signature cannot sign over bytes that include itself. The attestation is distributed alongside the bundle (e.g. as a sidecar attestation.json, or simply published to relays).

Verification

Given the bundle, the attestation, and the expected pubkey:

  1. Attestation — verify the attestation's signature and that pubkey equals the archive pubkey and kind is this kind.
  2. Manifest — recompute sha256 over the exact manifest.json bytes; it MUST equal the manifest tag.
  3. CID — re-pack the bundle files (or import the CAR) under the unixfs-v1-2025 profile; the root CID MUST equal the cid tag.
  4. Events — for each line in events.jsonl, verify id == sha256(serialization) and the signature; the set of ids MUST equal manifest.eventIds; every event's pubkey MUST equal the archive pubkey.
  5. Media — each media/<sha256> blob's bytes MUST hash to its filename; the set MUST equal manifest.mediaBlobs.

An archive is verified only if every step passes. A verifier MUST NOT report success when a step was skipped (e.g. the CID was not recomputed); it should distinguish "verified" from "verified except CID."

Relationship to other NIPs

  • NIP-01 — events are stored and verified verbatim; ids and signatures are the base of authenticity.
  • NIP-65 — the crawl SHOULD read the author's relay list (kind 10002) to choose relays; the coverage manifest records which were used.
  • NIP-92 / NIP-94 / NIP-96 / Blossom — sources for media references and their declared sha256 (x/ox, or the hash embedded in a Blossom URL path), enabling per-blob verification.
  • NIP-09 — deletion requests (kind 5) are archived as ordinary events; archival is content-preservation and does not apply deletions. Clients MAY record deletion status separately.
  • NIP-31 — the alt tag describes the event for clients that don't implement this kind.

Limitations

  • Completeness is unprovable. A crawl cannot prove it observed every event a pubkey ever signed (pruned, offline, or unknown relays; silent caps). coverage.complete is therefore permanently false, and coverage records exactly what was reached. An attestation proves authenticity of its contents, never exhaustiveness.
  • Received messages. An author-only crawl captures sent events; it does not capture DMs received (NIP-04) or gift wraps addressed to the pubkey (NIP-59), which are authored elsewhere.
  • Replaceable history. Relays usually retain only the latest version of replaceable/addressable events, so historical versions are typically unavailable.
  • Unverifiable media. References with no signed hash (bare content URLs, profile pictures) can be captured but not cryptographically tied to the event (verified: null).

Security considerations

  • Signatures prove authorship, not truthfulness; an attestation vouches only that this pubkey assembled this snapshot at this time.
  • Verification requires a secure context (Web Crypto sha256) and MUST recompute hashes from raw bytes rather than trusting the manifest's self-reported values.
  • A verifier should treat verified: null media as "present but unattested," never as "intact."

Open questions

  • Should the CID profile be pinned by this NIP or negotiated per-archive (a profile field in the manifest)?
  • An optional prev tag linking to the previous attestation id, to form an explicit snapshot chain?
  • An optional pinning/availability hint (gateway URL, pinning-service receipt) distinct from the CID commitment?
  • Multi-key / delegated archives (NIP-26-style) — out of scope for v1.

Cited links

Discussion

Connect a key to comment.