NIP-XXXX — IPFS Archive Attestations
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](#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:
- Every event is self-verifying (
id = sha256(serialization), signed). An
archive can preserve this so each event proves its own authenticity.
- 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.
- Manifest —
manifest.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:
- Attestation — verify the attestation's signature and that
pubkeyequals
the archive pubkey and kind is this kind.
- Manifest — recompute sha256 over the exact
manifest.jsonbytes; it MUST
equal the manifest tag.
- CID — re-pack the bundle files (or import the CAR) under the
unixfs-v1-2025 profile; the root CID MUST equal the cid tag.
- Events — for each line in
events.jsonl, verifyid == sha256(serialization)
and the signature; the set of ids MUST equal manifest.eventIds; every event's pubkey MUST equal the archive pubkey.
- 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
alttag 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: nullmedia 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
prevtag 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.