{"id":"4b1628e866778e49fdf6c6f2406d88c82dd0d4f75506af301123db9c2019f09d","pubkey":"291c75d937a45f66a1209f8ea6611df7448c59b3526520c66ca2cdcd37f1bfbe","created_at":1780804321,"kind":30817,"tags":[["d","nip-xxxx-ipfs-archive-attestations"],["title","NIP-XXXX — IPFS Archive Attestations"],["k","7503"],["client","nostrhub.io"]],"content":"`draft` `optional`\n\n> **Status:** proposed draft. The kind number is a placeholder pending\n> assignment via the [nostr-protocol/nips](https://github.com/nostr-protocol/nips)\n> repository. The reference implementation (Lodestar) uses **7503**, an unused\n> number in the regular range chosen only to avoid collisions — the digits carry\n> no meaning. What *is* deliberate is the range: see\n> [Why a regular kind](#why-a-regular-kind).\n\n## Abstract\n\nThis NIP defines a regular event kind that **attests to a content-addressed\narchive** of a Nostr identity's events and referenced media. The attestation is\na signed commitment to a deterministic bundle (its manifest hash and IPFS root\nCID), making the archive verifiable — authentic, intact, and vouched-for by the\npubkey — independently of any relay or media host, and offline.\n\n## Motivation\n\nA pubkey's events are scattered across relays that can disappear, and its media\nlives on hosts that rot. A plain backup of those events and blobs is useful but\n*untrusted*: a recipient can't tell whether it has been altered or truncated.\nTwo properties of Nostr let us do better:\n\n1. Every event is self-verifying (`id = sha256(serialization)`, signed). An\n   archive can preserve this so each event proves its own authenticity.\n2. The author holds a key. The author can therefore *sign a commitment to the\n   whole bundle*, vouching for a specific snapshot at a known time.\n\nThis NIP standardizes that commitment so any client can produce and verify such\narchives interoperably.\n\n## Terminology\n\n- **Bundle** — a directory of files: `manifest.json`, `events.jsonl`, and\n  `media/<sha256>` blobs. It is content-addressed with UnixFS to a single\n  **root CID**.\n- **Manifest** — `manifest.json`, a canonical JSON document committing to the\n  exact event set, media set, and crawl coverage.\n- **Attestation** — the event defined here, committing to the manifest hash and\n  root CID.\n- **Canonical JSON** — JSON with object keys sorted lexicographically at every\n  depth, no insignificant whitespace, and `undefined`/absent members omitted.\n  Array element order is significant and fixed by the producer.\n\n## The attestation event\n\nA regular event. Authored (signed) by the pubkey being archived.\n\n```jsonc\n{\n  \"kind\": 7503,\n  \"pubkey\": \"<hex>\",\n  \"created_at\": 1730000000,\n  \"content\": \"{\\\"events\\\":1240,\\\"media\\\":318,\\\"blobs\\\":270,\\\"generator\\\":\\\"lodestar/1\\\"}\",\n  \"tags\": [\n    [\"manifest\", \"<sha256-hex of canonical manifest.json>\"],\n    [\"cid\", \"<UnixFS root CID, CIDv1, base32 'bafy…'>\"],\n    [\"event_count\", \"1240\"],\n    [\"media_count\", \"318\"],\n    [\"blob_count\", \"270\"],\n    [\"oldest\", \"1609459200\"],\n    [\"newest\", \"1729999000\"],\n    [\"version\", \"1\"],\n    [\"alt\", \"Archive attestation: 1240 events, 270 media blobs.\"]\n  ]\n}\n```\n\n### Tags\n\n| tag           | value                                            | required    |\n| ------------- | ------------------------------------------------ | ----------- |\n| `manifest`    | sha256 (hex) of the canonical `manifest.json`    | yes         |\n| `cid`         | UnixFS root CID of the bundle                    | yes         |\n| `version`     | archive format version                           | yes         |\n| `event_count` | number of events committed to                    | recommended |\n| `media_count` | number of media references recorded              | recommended |\n| `blob_count`  | number of media blobs embedded (bytes captured)  | recommended |\n| `oldest`      | unix seconds of the oldest archived event        | optional    |\n| `newest`      | unix seconds of the newest archived event        | optional    |\n| `alt`         | NIP-31 human-readable description                | yes         |\n\nThe `manifest` and `cid` tags are the only values required for verification; the\ncounts and `oldest`/`newest` are conveniences (they appear in the manifest too).\n`content` is an informational JSON summary and is **not** part of the verifiable\ncommitment.\n\nA pubkey MAY publish many attestations over time; each is a permanent snapshot.\nClients showing \"the latest archive\" SHOULD filter\n`{ kinds: [7503], authors: [pubkey] }` and take the greatest `created_at`.\n\n### Why a regular kind\n\nRegular events (1000–9999) are stored permanently by relays and are never\nreplaced, which is exactly right for an append-only history of archive snapshots.\nAn *addressable* kind (30000–39999) with a `d` tag would make \"my latest archive\"\na single queryable coordinate, but each new attestation would overwrite the\nprevious one — destroying the snapshot history. We therefore use a regular kind\nand query the set by `authors` + `kinds`.\n\n## The bundle\n\n### `manifest.json` (canonical JSON)\n\n```jsonc\n{\n  \"version\": \"1\",\n  \"type\": \"lodestar-archive\",\n  \"generator\": \"lodestar/1\",\n  \"pubkey\": \"<hex>\",\n  \"npub\": \"npub1…\",\n  \"createdAt\": 1730000000,\n  \"eventCount\": 1240,\n  \"eventIds\": [\"00ab…\", \"00cd…\"],        // ALL event ids, sorted ascending\n  \"kindCounts\": { \"1\": 1100, \"0\": 1, \"1063\": 80 },\n  \"oldest\": 1609459200,\n  \"newest\": 1729999000,\n  \"media\": [ /* MediaItem records, sorted by (eventId, url, source) */ ],\n  \"mediaBlobs\": [\"1f3a…\", \"9b22…\"],      // sha256 of every embedded blob, sorted\n  \"coverage\": { /* CoverageManifest, see below */ }\n}\n```\n\n`eventIds` and `mediaBlobs` are the corpus commitments. Because an event's id is\nthe sha256 of its serialization, the sorted id list pins the event set exactly:\nno event can be added, removed, or altered without changing the list, and the\nlist is reproduced from `events.jsonl`.\n\nA **MediaItem** records one reference and its capture outcome:\n\n```jsonc\n{\n  \"url\": \"https://…\",\n  \"source\": \"imeta\" | \"nip94\" | \"blossom-path\" | \"content-url\" | \"profile-picture\",\n  \"eventId\": \"<hex>\",\n  \"expectedHash\": \"<sha256 if the event declared one>\",\n  \"actualHash\": \"<sha256 of fetched bytes, if captured>\",\n  \"mimeType\": \"image/jpeg\",\n  \"size\": 48213,\n  \"status\": \"verified\" | \"captured-unverified\" | \"mismatch\" | \"cors-blocked\" | \"not-found\" | \"error\",\n  \"verified\": true | false | null,       // null = no declared hash to check against\n  \"servedFrom\": \"https://…\"\n}\n```\n\n`verified: null` is distinct from `false`: it means integrity was *unprovable*\n(the reference carried no signed hash), not that a check failed.\n\n### `events.jsonl`\n\nEvery archived event, one canonical-JSON event per line, sorted by event id.\nEach line is an unmodified, signed NIP-01 event.\n\n### `media/<sha256>`\n\nEach successfully captured blob, named by the sha256 of its bytes (its content\naddress). Blobs are deduplicated: identical bytes referenced by multiple events\nare stored once.\n\n### Coverage\n\n```jsonc\n{\n  \"pubkey\": \"<hex>\",\n  \"crawledAt\": 1730000000,\n  \"relaysQueried\": [\"wss://…\"],\n  \"relays\": [\n    {\n      \"url\": \"wss://…\",\n      \"status\": \"ok\" | \"error\" | \"partial\" | \"aborted\",\n      \"eventCount\": 1200,\n      \"pages\": 4,\n      \"oldest\": 1609459200,\n      \"newest\": 1729999000,\n      \"reachedEnd\": true,\n      \"hitWindowCap\": false,\n      \"kindCounts\": { \"1\": 1100 },\n      \"error\": \"optional message\"\n    }\n  ],\n  \"totalUnique\": 1240,\n  \"kindCounts\": { \"1\": 1100 },\n  \"complete\": false,                      // ALWAYS false — see Limitations\n  \"notes\": [\"…caveats…\"]\n}\n```\n\n## Content addressing\n\nThe bundle is encoded as a UnixFS directory over `manifest.json`,\n`events.jsonl`, and the `media/` blobs, using the **`unixfs-v1-2025` import\nprofile**: CIDv1, raw leaves, sha2-256, 1 MiB fixed-size chunks, 1024 links per\nfile node, 256-fanout HAMT directory sharding. The serialized form is a **CAR**\nwhose single root is the `cid` value; importing the CAR into any IPFS node\nreproduces that CID. Re-deriving the CID from the loose files (rather than\nimporting the CAR) reproduces it only under the same profile.\n\nThe attestation deliberately commits to the bundle but is **not contained in\nit** — a signature cannot sign over bytes that include itself. The attestation is\ndistributed alongside the bundle (e.g. as a sidecar `attestation.json`, or\nsimply published to relays).\n\n## Verification\n\nGiven the bundle, the attestation, and the expected pubkey:\n\n1. **Attestation** — verify the attestation's signature and that `pubkey` equals\n   the archive pubkey and `kind` is this kind.\n2. **Manifest** — recompute sha256 over the exact `manifest.json` bytes; it MUST\n   equal the `manifest` tag.\n3. **CID** — re-pack the bundle files (or import the CAR) under the\n   `unixfs-v1-2025` profile; the root CID MUST equal the `cid` tag.\n4. **Events** — for each line in `events.jsonl`, verify `id == sha256(serialization)`\n   and the signature; the set of ids MUST equal `manifest.eventIds`; every\n   event's `pubkey` MUST equal the archive pubkey.\n5. **Media** — each `media/<sha256>` blob's bytes MUST hash to its filename; the\n   set MUST equal `manifest.mediaBlobs`.\n\nAn archive is **verified** only if every step passes. A verifier MUST NOT report\nsuccess when a step was skipped (e.g. the CID was not recomputed); it should\ndistinguish \"verified\" from \"verified except CID.\"\n\n## Relationship to other NIPs\n\n- **NIP-01** — events are stored and verified verbatim; ids and signatures are\n  the base of authenticity.\n- **NIP-65** — the crawl SHOULD read the author's relay list (kind 10002) to\n  choose relays; the coverage manifest records which were used.\n- **NIP-92 / NIP-94 / NIP-96 / Blossom** — sources for media references and their\n  declared sha256 (`x`/`ox`, or the hash embedded in a Blossom URL path),\n  enabling per-blob verification.\n- **NIP-09** — deletion requests (kind 5) are archived as ordinary events;\n  archival is content-preservation and does not apply deletions. Clients MAY\n  record deletion status separately.\n- **NIP-31** — the `alt` tag describes the event for clients that don't implement\n  this kind.\n\n## Limitations\n\n- **Completeness is unprovable.** A crawl cannot prove it observed every event a\n  pubkey ever signed (pruned, offline, or unknown relays; silent caps).\n  `coverage.complete` is therefore permanently `false`, and `coverage` records\n  exactly what was reached. An attestation proves *authenticity* of its contents,\n  never *exhaustiveness*.\n- **Received messages.** An author-only crawl captures sent events; it does not\n  capture DMs *received* (NIP-04) or gift wraps addressed *to* the pubkey\n  (NIP-59), which are authored elsewhere.\n- **Replaceable history.** Relays usually retain only the latest version of\n  replaceable/addressable events, so historical versions are typically\n  unavailable.\n- **Unverifiable media.** References with no signed hash (bare content URLs,\n  profile pictures) can be captured but not cryptographically tied to the event\n  (`verified: null`).\n\n## Security considerations\n\n- Signatures prove authorship, not truthfulness; an attestation vouches only that\n  *this pubkey assembled this snapshot at this time*.\n- Verification requires a secure context (Web Crypto sha256) and MUST recompute\n  hashes from raw bytes rather than trusting the manifest's self-reported values.\n- A verifier should treat `verified: null` media as \"present but unattested,\"\n  never as \"intact.\"\n\n## Open questions\n\n- Should the CID profile be pinned by this NIP or negotiated per-archive (a\n  `profile` field in the manifest)?\n- An optional `prev` tag linking to the previous attestation id, to form an\n  explicit snapshot chain?\n- An optional pinning/availability hint (gateway URL, pinning-service receipt)\n  distinct from the CID commitment?\n- Multi-key / delegated archives (NIP-26-style) — out of scope for v1.","sig":"5c56531be4ed890552dfe1fbe05b136430eb271394ce013b5d5bad59478bbafad26d673baff89b4fe53c8b8766897e0d3db7cdbaf0897467b6a3000e3a2b6dbd"}