Three systems name data three different ways, and the names get compared far more often than they get computed. This article builds a small Python tool that emits all three identifiers for one twelve-byte input, so you can see exactly where they agree and where they do not:
- a Nostr event id — the SHA-256 of a canonical serialization of a signed event
- an IPFS CIDv1 — a SHA-256 digest wrapped in a version, a codec, and base32
- a Blossom hash — the same SHA-256, bare
Two of those three turn out to be the same 32 bytes wearing different amounts of clothing, which is hard to believe from a table and obvious from a terminal.
The tool is standard library only at runtime, about 250 lines, and the CID
encoder is checked against the multiformats package so it is a demonstration
rather than a reimplementation you have to take on faith. A final step covers the
NIP-92 imeta tag that joins an event to its media, including the malformed
shape that circulates widely enough to be worth a parser that rejects it.
For what these identifiers imply about centralization, availability, and who pays to keep content reachable, see the companion piece: A Decentralized Protocol Is Not a Decentralized Deployment.
Versions used throughout: Python 3.11, uv 0.11.26, multiformats
0.3.1.post4, on macOS 26.5.2.
Prerequisites
- Python 3.11 or newer and
uv0.5+. The tool is standard library only at runtime. - No IPFS node and no Nostr relay. Everything here is computed locally, which is the point — these identifiers are derived from the bytes, not issued by a server.
- Familiarity with SHA-256 helps, but nothing beyond “it turns bytes into 32 bytes.”
Step 1: Two questions, two kinds of identifier
Before computing anything, it is worth being clear about what each identifier is a name for, because “identifier” means two unrelated things here. One names a statement; the other names content. They change independently, and a system usually needs both.
A Nostr event id is the SHA-256 of a canonical serialization of the event itself — author, timestamp, kind, tags, content. Edit one character of a note and it becomes a different event with a different id, even though the photo it links to is untouched. Post the same photo twice and you get two events.
An IPFS CID is derived from the bytes. The same photo has the same CID no matter who posts it, how many times, or which machine is holding a copy. That is what “content addressing” means: the name is a function of the content, so it can be verified by anyone who has the bytes and trusts nobody.
A third system belongs in this comparison, and leaving it out is what makes
“Nostr or IPFS” look like a binary. Blossom stores blobs on ordinary HTTP
servers, named by their bare SHA-256: PUT /upload to store, GET /<sha256> to
retrieve. It is content-addressed in exactly the sense above, and it is not IPFS.
Real Nostr applications reach for it often enough that leaving it out would make
the tool’s output misleading, so it gets a column here rather than a footnote.
| Nostr | IPFS | Blossom | |
|---|---|---|---|
| Primary purpose | Messaging, social and event data | Storing and distributing content | Serving blobs for Nostr apps |
| Data model | Signed events | Content-addressed blocks and files | Content-addressed opaque blobs |
| Identity | secp256k1 keypairs (Schnorr, BIP-340) | None; peer IDs name nodes, not content or authors | The same Nostr keypairs |
| Main identifier | Event id (SHA-256 of the event) | CID (multibase + version + codec + multihash) | Bare SHA-256 hex |
| Distribution | Relays | Nodes exchanging blocks, found via a DHT | HTTP servers; redundancy by explicit mirroring |
| Typical payload | Posts, profiles, DMs, reactions | Images, video, documents, whole sites | Images, video, attachments |
| Persistence | Whatever relays choose to keep | Whatever nodes choose to pin | Whatever servers choose to keep |
Two rows in that table drive everything the tool does.
Content addressing is a family, not a technology. IPFS and Blossom both name data by its hash, and both therefore give you the property that matters: anyone holding the bytes can verify the name, and no server can substitute different content without detection. They differ in how much the identifier says about itself and in how it is retrieved — a CID declares its version, codec, and hash algorithm, while a Blossom name is 32 bytes of SHA-256 and nothing else. Step 3 prints both for the same file, and the digest inside them is identical.
Identity lives on the Nostr side only. A CID carries no authorship
whatsoever: it names bytes, and peer IDs name nodes rather than documents or
people. On Nostr, identity is a secp256k1 keypair and it is the account — the
same key signs every event. Blossom’s answer is the interesting one, because it
does not invent anything: an upload is authorized by a signed Nostr event
(kind:24242) carrying the blob’s SHA-256, so the storage layer reuses the
identity layer wholesale and needs no accounts of its own. That reuse is why the
same module can compute all three names without three notions of who you are.
Step 2: Build the addressing tool
Reading that “a CID is a content address” is not the same as seeing one built. A CIDv1 for a raw block is a SHA-256 digest with four bytes of prefix and a base32 encoding, about six lines of code, and once that is visible the rest of the architecture is much easier to reason about.
Scaffold the project and its .gitignore
The .gitignore goes in first, out of habit rather than necessity here: this
project handles no secrets, but a Nostr key is one paste away from any file in a
directory like this.
Create the files
mkdir -p ~/nostr-ipfs/src ~/nostr-ipfs/tests
cd ~/nostr-ipfs
touch .gitignore
Add the code: .gitignore
# Secrets — a Nostr secret key is the whole account, so never commit one
.env
*.env
*.nsec
# Python
__pycache__/
*.py[cod]
.venv/
# Tooling caches
.pytest_cache/
.ruff_cache/
.mypy_cache/
# Sample payloads generated by the demo
out/
# OS / editor noise
.DS_Store
*.log
Detailed breakdown
*.nsecis listed even though nothing in this project writes one. That is exactly the case where a stray file gets committed, because no habit has formed around a file type you never normally see.out/covers the directory you will end up making the first time you dump a sample payload to disk.
Initialize with uv
Create the file
cd ~/nostr-ipfs
echo "3.11" > .python-version
touch pyproject.toml
Add the code: pyproject.toml
[project]
name = "nostr-ipfs-addressing"
version = "0.1.0"
description = "Compute Nostr event ids, IPFS CIDs, and Blossom hashes side by side"
readme = "README.md"
requires-python = ">=3.11"
dependencies = []
[dependency-groups]
dev = [
"multiformats>=0.3.1",
"pytest>=8.3.0",
]
Detailed breakdown
dependencies = []is a design statement, not an oversight. The runtime code useshashlib,base64, andjsonand nothing else. A reader who wants to know what a CID is made of gets to see it rather than watch a library produce one.multiformatsis a dev dependency used only in the tests, as an independent implementation to check the hand-rolled encoder against. Without it the CID tests would prove only that the encoder agrees with itself — the same circularity a published test vector exists to break.
Add the addressing module
Create the file
cd ~/nostr-ipfs
touch src/addressing.py
Add the code: src/addressing.py
"""Three ways to name the same bytes: Nostr, IPFS, and Blossom.
A decentralized application usually needs two different kinds of identifier, and
conflating them is where the confusion starts:
* **Who said what, and when** — a Nostr event id. It names a *signed statement*.
Change one character of the note and it is a different event by a different id,
even though the photo it links to is untouched.
* **What these bytes are** — an IPFS CID or a Blossom hash. It names *content*.
The same photo has the same identifier no matter who posts it, how many times,
or which server happens to be holding a copy.
This module computes all three from first principles, with no network and no
dependencies outside the standard library. That is the point: a CIDv1 for a raw
block is a SHA-256 digest with four bytes of self-describing prefix in front of
it, and seeing that spelled out is worth more than calling a library.
"""
from __future__ import annotations
import base64
import hashlib
import json
from dataclasses import dataclass
# Multiformats table entries used here. Each is a varint in the spec; every value
# below happens to be a single byte, so the encoder stays readable.
MULTIBASE_BASE32 = "b" # base32, RFC 4648 lowercase, no padding
CID_V1 = 0x01
CODEC_RAW = 0x55 # an opaque block of bytes
CODEC_DAG_PB = 0x70 # a UnixFS node (CIDv1 form; `ipfs add` defaults to CIDv0)
HASH_SHA2_256 = 0x12
SHA2_256_LENGTH = 0x20 # 32 bytes
CODEC_NAMES = {CODEC_RAW: "raw", CODEC_DAG_PB: "dag-pb"}
HASH_NAMES = {HASH_SHA2_256: "sha2-256"}
# --------------------------------------------------------------------------
# IPFS: content addressing
# --------------------------------------------------------------------------
def multihash_sha256(data: bytes) -> bytes:
"""Wrap a SHA-256 digest as a multihash: <algorithm><length><digest>.
The prefix is what makes the hash self-describing — a reader can tell it is
SHA-256 and 32 bytes long without being told out of band.
"""
return bytes([HASH_SHA2_256, SHA2_256_LENGTH]) + hashlib.sha256(data).digest()
def encode_cid_v1(multihash: bytes, codec: int) -> str:
"""Encode a CIDv1 as a base32 string.
Layout: ``<multibase><version><codec><multihash>``, where everything after
the multibase character is binary and base32-encoded together.
"""
binary = bytes([CID_V1, codec]) + multihash
encoded = base64.b32encode(binary).decode("ascii").lower().rstrip("=")
return MULTIBASE_BASE32 + encoded
def cid_v1_raw(data: bytes) -> str:
"""Return the CIDv1 of ``data`` addressed as a raw block.
This is the identifier for the bytes themselves. `ipfs add --raw-leaves`
produces this form for a small file; the resulting CID starts `bafkrei`.
"""
return encode_cid_v1(multihash_sha256(data), CODEC_RAW)
@dataclass(frozen=True)
class DecodedCid:
version: int
codec: int
codec_name: str
hash_name: str
digest: str
@property
def is_raw_block(self) -> bool:
return self.codec == CODEC_RAW
def decode_cid(cid: str) -> DecodedCid:
"""Take a base32 CIDv1 apart again.
Decoding matters more than encoding for understanding the format: it shows
that every field the reader needs travels inside the identifier, which is
exactly what "self-describing" means and exactly what a bare hex hash lacks.
"""
if not cid.startswith(MULTIBASE_BASE32):
raise ValueError(f"expected a base32 CID beginning {MULTIBASE_BASE32!r}")
body = cid[1:].upper()
padding = "=" * (-len(body) % 8)
try:
binary = base64.b32decode(body + padding)
except Exception as exc: # noqa: BLE001 - surfaced as a clear ValueError
raise ValueError(f"not valid base32: {exc}") from exc
if len(binary) < 4:
raise ValueError("CID is too short to contain a version, codec, and hash")
version, codec, hash_id, length = binary[0], binary[1], binary[2], binary[3]
if version != CID_V1:
raise ValueError(f"only CIDv1 is supported here, got version {version}")
digest = binary[4:]
if len(digest) != length:
raise ValueError(f"multihash claims {length} bytes, found {len(digest)}")
return DecodedCid(
version=version,
codec=codec,
codec_name=CODEC_NAMES.get(codec, f"0x{codec:02x}"),
hash_name=HASH_NAMES.get(hash_id, f"0x{hash_id:02x}"),
digest=digest.hex(),
)
# --------------------------------------------------------------------------
# Blossom: bare SHA-256 content addressing over HTTP
# --------------------------------------------------------------------------
def blossom_hash(data: bytes) -> str:
"""Return the identifier Blossom uses for a blob: a bare SHA-256 hex digest.
Blossom is content-addressed like IPFS, and deliberately less than IPFS: the
same bytes always get the same name, but the name carries no codec, no hash
algorithm, and no version, because a Blossom server only ever speaks SHA-256
over HTTP. Compare the digest here with `decode_cid(...).digest` — they are
the same 32 bytes wearing different clothes.
"""
return hashlib.sha256(data).hexdigest()
# --------------------------------------------------------------------------
# Nostr: event addressing
# --------------------------------------------------------------------------
def imeta_tag(url: str, mime: str | None = None, sha256: str | None = None,
dim: str | None = None, alt: str | None = None,
fallbacks: tuple[str, ...] = ()) -> list[str]:
"""Build a NIP-92 ``imeta`` tag.
The shape is the part people get wrong. NIP-92 says the tag is *variadic and
each entry is a space-delimited key/value pair*, so it is::
["imeta", "url https://…", "m image/jpeg"]
and **not**::
["imeta", "url", "https://…"]
Both are legal JSON and only the first is a valid imeta tag.
Two rules from the spec are enforced here because both are easy to miss:
a tag MUST carry a ``url`` *and at least one other field*, and the URL is
expected to also appear in the event's ``content`` — the tag annotates a
link, it does not replace one. See ``build_note``.
"""
if not url:
raise ValueError("an imeta tag needs a url")
entries = [f"url {url}"]
if mime:
entries.append(f"m {mime}")
if sha256:
entries.append(f"x {sha256}")
if dim:
entries.append(f"dim {dim}")
if alt:
entries.append(f"alt {alt}")
entries.extend(f"fallback {u}" for u in fallbacks)
if len(entries) < 2:
raise ValueError(
"NIP-92 requires a url and at least one other field; got url alone"
)
return ["imeta", *entries]
def parse_imeta(tag: list[str]) -> dict[str, list[str]]:
"""Parse an ``imeta`` tag back into keys and values.
Returns lists because NIP-92 allows a key to repeat — `fallback` is the
common case, and a parser that keeps only the last one silently drops the
redundancy that makes fallbacks worth having.
"""
if not tag or tag[0] != "imeta":
raise ValueError("not an imeta tag")
out: dict[str, list[str]] = {}
for entry in tag[1:]:
key, _, value = entry.partition(" ")
if not value:
raise ValueError(
f"malformed imeta entry {entry!r}: expected 'key value' in one element"
)
out.setdefault(key, []).append(value)
return out
def event_id(event: dict) -> str:
"""Compute a NIP-01 event id: SHA-256 of a canonical serialization.
The serialization is a six-element array with no whitespace and no ASCII
escaping. Both `separators` and `ensure_ascii` are load-bearing; either
default produces a different hash and every signature is then rejected.
"""
serialized = json.dumps(
[
0,
event["pubkey"],
event["created_at"],
event["kind"],
event["tags"],
event["content"],
],
separators=(",", ":"),
ensure_ascii=False,
)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
def build_note(content: str, pubkey: str, created_at: int, url: str,
data: bytes, mime: str) -> dict:
"""Assemble a kind-1 note that references content instead of embedding it.
This is the whole architecture in one function. The note carries the words
and the author; the bytes live somewhere addressable, and the event points
at them. Nothing here uploads anything — that is the other half of the
system, and keeping it separate is the design, not an omission.
Note where the URL goes. NIP-92 attaches media by putting the URL **in the
content**, with a matching ``imeta`` tag carrying the metadata about it.
Clients may ignore an ``imeta`` tag that matches no URL in the content, so
a note that carries the link only in the tag is the shape that fails to
render — the opposite of the intuition that the tag is the carrier.
"""
body = f"{content} {url}"
event = {
"pubkey": pubkey,
"created_at": created_at,
"kind": 1,
"tags": [imeta_tag(url, mime=mime, sha256=blossom_hash(data))],
"content": body,
}
event["id"] = event_id(event)
return event
Detailed breakdown
multihash_sha256produces<0x12><0x20><digest>. Those two bytes are the whole idea behind multiformats: the hash announces its own algorithm and length, so a decoder never has to be told what it is looking at. A bare hex digest cannot do that, which is why migrating a system off SHA-256 later is a breaking change and migrating a multihash is not.encode_cid_v1prepends the version and codec and base32-encodes the lot. The leadingbis the multibase character naming the encoding — it is not part of the binary CID, it says how to read what follows.CODEC_RAWvsCODEC_DAG_PBis the source of most “why doesn’t my CID match” confusion.rawaddresses the bytes as-is and yields abafkrei…CID.dag-pbaddresses a UnixFS node, the chunked and framed structureipfs addbuilds around a file, and yieldsbafybei…in its CIDv1 form. Same file, same hash function, different CID, because they name different objects. Note that plainipfs adddoes not print that form: kubo defaults toCidVersion = 0, so you get a base58 CIDv0Qm…unless you pass--cid-version=1.decode_cidis the more instructive direction. It pulls the version, codec, hash algorithm, and digest back out of the string, which demonstrates that all of that travelled inside the identifier. It also validates: a multihash whose declared length disagrees with its payload is rejected rather than silently truncated.blossom_hashis the comparison that makes the point. Blossom names a blob with a bare SHA-256 hex digest — the same 32 bytes a CID carries, minus the self-description. That is a real trade-off, not an oversight: Blossom servers only ever speak SHA-256 over HTTP, so the extra framing would buy nothing.imeta_tagandparse_imetaimplement NIP-92, whose format is the thing most write-ups get wrong. See Step 4.event_idimplements NIP-01’s canonical serialization: a six-element array with no whitespace and no ASCII escaping.separators=(",", ":")andensure_ascii=Falseare both load-bearing — either default changes the hash, and every signature over it is then invalid.build_noteis the architecture in miniature: the event carries words and authorship, the bytes live somewhere addressable, and a tag points from one to the other. Note what it does not do — it never uploads anything. Publishing the event and storing the bytes are separate operations against separate infrastructure, and keeping those two straight is most of what the companion article is about.
Add the demo
Create the file
cd ~/nostr-ipfs
touch src/demo.py
Add the code: src/demo.py
"""Print the three identifiers for one file, side by side.
Usage:
uv run python src/demo.py [path]
With no path it uses a built-in sample so the output is reproducible.
"""
from __future__ import annotations
import sys
from pathlib import Path
from addressing import (
blossom_hash,
build_note,
cid_v1_raw,
decode_cid,
parse_imeta,
)
SAMPLE = b"hello world\n"
PUBKEY = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e"
CREATED_AT = 1_700_000_000 # fixed so the event id is reproducible
def main(argv: list[str] | None = None) -> int:
args = sys.argv[1:] if argv is None else argv
if args:
data = Path(args[0]).read_bytes()
source = args[0]
else:
data = SAMPLE
source = "built-in sample (hello world\\n)"
cid = cid_v1_raw(data)
decoded = decode_cid(cid)
note = build_note(
"Check out this photo of my new house",
PUBKEY,
CREATED_AT,
f"ipfs://{cid}",
data,
"image/jpeg",
)
print(f"source {source} ({len(data)} bytes)\n")
print("WHAT THESE BYTES ARE — content addressing")
print(f" IPFS CIDv1 {cid}")
print(f" version {decoded.version}")
print(f" codec {decoded.codec_name}")
print(f" hash {decoded.hash_name}")
print(f" digest {decoded.digest}")
print(f" Blossom hash {blossom_hash(data)}")
print(" ^ the same 32 bytes; Blossom just does not wrap them\n")
print("WHO SAID WHAT — event addressing")
print(f" Nostr event id {note['id']}")
print(f" kind {note['kind']}")
print(f" imeta url {parse_imeta(note['tags'][0])['url'][0]}")
print(f" imeta x {parse_imeta(note['tags'][0])['x'][0]}")
print("\n The event id changes if the words change; the CID does not.")
print(" The CID changes if the bytes change; the event id does not.")
return 0
if __name__ == "__main__":
# No sys.path juggling: running this as a script already puts src/ first on
# sys.path, which is what makes the `from addressing import …` above work.
raise SystemExit(main())
Detailed breakdown
CREATED_ATis fixed, nottime.time(). A timestamp feeds the event id, so a live clock would print a different id on every run and the output in this article would be unreproducible.- The public key is the NIP-19 specification’s example key, whose private half
is deliberately published. It belongs to nobody. Never use a real person’s
npubas filler — it is still somebody’s address, and an example is exactly what gets pasted without thinking. - The two closing lines are the point of the exercise. The event id and the content address move independently, and each is stable against changes to the other.
Add the tests
The interesting assertions here are not that the functions run. They are that the
CID agrees with an independent implementation, and that the imeta tag has the
shape the specification actually defines.
Create the file
cd ~/nostr-ipfs
touch tests/test_addressing.py
Add the code: tests/test_addressing.py
"""Tests for the three addressing schemes.
No network. The IPFS side is checked against the `multiformats` library, which
is a dev dependency and deliberately not a runtime one: `addressing.py` is
stdlib-only so a reader can see what a CID is made of, and the library exists
here purely to prove that hand-rolled encoder agrees with a real implementation.
"""
from __future__ import annotations
import hashlib
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from addressing import ( # noqa: E402
CODEC_DAG_PB,
CODEC_RAW,
blossom_hash,
build_note,
cid_v1_raw,
decode_cid,
encode_cid_v1,
event_id,
imeta_tag,
multihash_sha256,
parse_imeta,
)
# `hello world\n` — its SHA-256 is one of the most widely reproduced digests
# there is, which makes it a good anchor for the whole chain.
SAMPLE = b"hello world\n"
SAMPLE_SHA256 = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"
SAMPLE_CID_RAW = "bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"
PUBKEY = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e"
class TestContentAddressing:
def test_sha256_anchor(self):
assert hashlib.sha256(SAMPLE).hexdigest() == SAMPLE_SHA256
def test_cid_matches_a_known_value(self):
assert cid_v1_raw(SAMPLE) == SAMPLE_CID_RAW
def test_cid_agrees_with_the_multiformats_library(self):
"""Cross-check the hand-rolled encoder against a real implementation.
Without this the CID tests would only prove the encoder agrees with
itself, which is the same circularity a bech32 vector guards against.
"""
from multiformats import CID, multihash
expected = CID(
"base32", 1, "raw", multihash.wrap(hashlib.sha256(SAMPLE).digest(), "sha2-256")
)
assert cid_v1_raw(SAMPLE) == str(expected)
def test_codec_changes_the_prefix_not_the_digest(self):
"""`bafkrei…` vs `bafybei…` is the codec, not a different hash.
This is the distinction behind most "why doesn't my CID match" questions:
`ipfs add` wraps a file in a UnixFS node, so its CID names that node
rather than the bytes. (By default it also emits CIDv0 — a base58
`Qm…` — so the printed string differs in version and base as well.)
"""
mh = multihash_sha256(SAMPLE)
raw = encode_cid_v1(mh, CODEC_RAW)
dag_pb = encode_cid_v1(mh, CODEC_DAG_PB)
assert raw.startswith("bafkrei")
assert dag_pb.startswith("bafybei")
assert raw != dag_pb
assert decode_cid(raw).digest == decode_cid(dag_pb).digest
def test_cid_round_trips(self):
decoded = decode_cid(cid_v1_raw(SAMPLE))
assert decoded.version == 1
assert decoded.codec_name == "raw"
assert decoded.hash_name == "sha2-256"
assert decoded.digest == SAMPLE_SHA256
assert decoded.is_raw_block
def test_the_cid_and_the_blossom_hash_are_the_same_bytes(self):
"""The headline comparison: one identifier is self-describing, one is not."""
assert decode_cid(cid_v1_raw(SAMPLE)).digest == blossom_hash(SAMPLE)
def test_different_bytes_give_a_different_cid(self):
assert cid_v1_raw(SAMPLE) != cid_v1_raw(SAMPLE + b" ")
@pytest.mark.parametrize("bad", ["", "zzz", "bnotbase32!!", "b"])
def test_rejects_malformed_cids(self, bad):
with pytest.raises(ValueError):
decode_cid(bad)
def test_rejects_a_truncated_multihash(self):
mh = multihash_sha256(SAMPLE)[:-4] # claims 32 bytes, carries 28
with pytest.raises(ValueError, match="claims 32 bytes"):
decode_cid(encode_cid_v1(mh, CODEC_RAW))
class TestImetaTag:
def test_pairs_are_space_delimited_inside_one_element(self):
"""Guards the single most common way to write this tag wrongly."""
tag = imeta_tag("ipfs://bafkrei…", mime="image/jpeg")
assert tag == ["imeta", "url ipfs://bafkrei…", "m image/jpeg"]
# The shape that looks right and is not:
assert tag != ["imeta", "url", "ipfs://bafkrei…", "m", "image/jpeg"]
def test_round_trips(self):
tag = imeta_tag(
"https://example.test/a.jpg",
mime="image/jpeg",
sha256=SAMPLE_SHA256,
dim="3024x4032",
alt="a photo",
)
parsed = parse_imeta(tag)
assert parsed["url"] == ["https://example.test/a.jpg"]
assert parsed["m"] == ["image/jpeg"]
assert parsed["x"] == [SAMPLE_SHA256]
assert parsed["dim"] == ["3024x4032"]
assert parsed["alt"] == ["a photo"]
def test_keeps_repeated_keys(self):
tag = imeta_tag("https://a.test/x", fallbacks=("https://b.test/x", "https://c.test/x"))
assert parse_imeta(tag)["fallback"] == ["https://b.test/x", "https://c.test/x"]
def test_rejects_the_split_element_shape(self):
with pytest.raises(ValueError, match="expected 'key value'"):
parse_imeta(["imeta", "url", "https://example.test/a.jpg"])
def test_requires_a_url(self):
with pytest.raises(ValueError, match="needs a url"):
imeta_tag("")
def test_requires_at_least_one_field_besides_url(self):
"""NIP-92: a tag MUST have a url *and at least one other field*."""
with pytest.raises(ValueError, match="at least one other field"):
imeta_tag("https://example.test/a.jpg")
def test_rejects_a_non_imeta_tag(self):
with pytest.raises(ValueError, match="not an imeta tag"):
parse_imeta(["e", "abc"])
class TestEventId:
def test_is_stable_and_content_sensitive(self):
base = {"pubkey": PUBKEY, "created_at": 1_700_000_000, "kind": 1,
"tags": [], "content": "hello"}
assert event_id(base) == event_id(dict(base))
assert event_id(base) != event_id({**base, "content": "hello "})
def test_the_referenced_media_participates_in_the_id(self):
"""Swapping the linked CID changes the event id but not the bytes.
This is the separation the article is about: the event names a
statement, the CID names content, and they move independently.
"""
common = dict(pubkey=PUBKEY, created_at=1_700_000_000,
content="look at this", mime="image/jpeg")
a = build_note(url="ipfs://" + cid_v1_raw(SAMPLE), data=SAMPLE, **common)
b = build_note(url="ipfs://" + cid_v1_raw(b"other"), data=SAMPLE, **common)
assert a["id"] != b["id"]
def test_the_same_content_keeps_its_cid_across_different_notes(self):
"""The other half: two authors posting the same photo agree on the CID."""
cid = cid_v1_raw(SAMPLE)
first = build_note("mine", PUBKEY, 1_700_000_000, "ipfs://" + cid, SAMPLE, "image/jpeg")
second = build_note("reposted", "f" * 64, 1_700_000_999, "ipfs://" + cid, SAMPLE, "image/jpeg")
assert first["id"] != second["id"]
assert parse_imeta(first["tags"][0])["url"] == parse_imeta(second["tags"][0])["url"]
assert parse_imeta(first["tags"][0])["x"] == parse_imeta(second["tags"][0])["x"]
class TestBuildNote:
def test_the_url_appears_in_the_content(self):
"""NIP-92 attaches media by putting the URL in the content.
The `imeta` tag annotates a URL that is already there; clients may
ignore a tag matching no URL in the content. A note carrying the link
only in the tag is therefore the shape that fails to render, which is
the opposite of the intuition that the tag is the carrier.
"""
url = "ipfs://" + cid_v1_raw(SAMPLE)
note = build_note("look at this", PUBKEY, 1_700_000_000, url, SAMPLE, "image/jpeg")
assert url in note["content"]
assert parse_imeta(note["tags"][0])["url"] == [url]
def test_produces_a_well_formed_kind_1_event(self):
note = build_note("hi", PUBKEY, 1_700_000_000,
"ipfs://" + cid_v1_raw(SAMPLE), SAMPLE, "image/png")
assert note["kind"] == 1
assert len(note["id"]) == 64
assert note["id"] == event_id(note)
assert parse_imeta(note["tags"][0])["x"] == [blossom_hash(SAMPLE)]
Detailed breakdown
test_cid_agrees_with_the_multiformats_libraryis the one test that could actually catch a wrong encoder. A CID encoder that is subtly wrong still produces a plausible-looking base32 string, so checking it against your own output proves nothing. This checks it against an implementation written by somebody else.test_codec_changes_the_prefix_not_the_digestpins thebafkrei/bafybeidistinction and asserts the digest is identical across both, which is the fact that makes the prefix difference intelligible rather than alarming.test_the_cid_and_the_blossom_hash_are_the_same_bytesis the article’s central comparison expressed as an assertion.test_rejects_the_split_element_shapeguards the exact malformedimetatag that circulates in write-ups about Nostr and IPFS. It is why the parser raises aValueErrorthere rather than quietly returning a tag with nourlin it.test_the_same_content_keeps_its_cid_across_different_notesandtest_the_referenced_media_participates_in_the_idare a matched pair: one shows the content address surviving a change of author, the other shows the event id moving when the reference changes. Together they demonstrate the independence that the whole architecture rests on.
Add the Makefile
Create the file
cd ~/nostr-ipfs
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
FILE ?=
.PHONY: help demo test check-cid clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
demo: ## Print all three identifiers (override with FILE=path/to/file)
uv run python src/demo.py $(FILE)
test: ## Run the unit tests
uv run pytest -v
check-cid: ## Re-verify the CID encoder against the multiformats library
uv run pytest -v -k multiformats
clean: ## Remove caches and generated output
rm -rf .pytest_cache .ruff_cache src/__pycache__ tests/__pycache__ out
Detailed breakdown
.DEFAULT_GOAL := helpmakes baremakeprint the help screen, and the screen is generated by grepping the file for##comments so a new target documents itself by existing.FILE ?=defaults to empty somake demoruns against the built-in sample andmake demo FILE=photo.jpgruns against a real one, with no second target.check-cidexists to be run after any change to the encoder. It is the one test whose failure means the output is wrong rather than merely different.
Step 3: Run it
With the files in place, sync and look at the three identifiers for the same twelve bytes.
cd ~/nostr-ipfs
uv sync
make test
============================== 24 passed in 0.02s ==============================
Then the demo:
make demo
source built-in sample (hello world\n) (12 bytes)
WHAT THESE BYTES ARE — content addressing
IPFS CIDv1 bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4
version 1
codec raw
hash sha2-256
digest a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447
Blossom hash a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447
^ the same 32 bytes; Blossom just does not wrap them
WHO SAID WHAT — event addressing
Nostr event id 8d819e41e8d9fceb7d014076470d2dcebbc3774f2e49e9f80cbd31e7daec1cfa
kind 1
imeta url ipfs://bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4
imeta x a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447
The event id changes if the words change; the CID does not.
The CID changes if the bytes change; the event id does not.
Bare make prints the help screen:
help Show this help screen
demo Print all three identifiers (override with FILE=path/to/file)
test Run the unit tests
check-cid Re-verify the CID encoder against the multiformats library
clean Remove caches and generated output
The line worth staring at is the repeated a948904f…. The IPFS CID and the
Blossom hash are the same SHA-256 digest. IPFS wraps it in a version, a codec,
a hash identifier, and a base32 encoding so the identifier describes itself;
Blossom prints it bare because a Blossom server only ever speaks SHA-256 over
HTTP and has nothing to disambiguate.
Step 4: The tag that joins them, and the shape everyone gets wrong
The reason this article ships a parser for a three-field tag is that the format
circulates incorrectly. Once media lives outside the event, something in the
event has to point at it, and on Nostr that something is usually a NIP-92 imeta
tag on a kind-1 note.
Start with the part that is easy to get backwards: the URL goes in the event
content, and the tag annotates it. NIP-92 attaches media “by including a URL in
the event content, along with a matching imeta tag”, and says a client MAY
ignore an imeta tag that matches no URL in the content. The tag carries
metadata about a link; it is not the link.
Within the tag, each entry is a space-delimited key/value pair in a single
array element, and a tag MUST carry a url plus at least one other field:
{
"kind": 1,
"content": "Check out this photo of my new house ipfs://bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4",
"tags": [
["imeta",
"url ipfs://bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4",
"m image/jpeg",
"x a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"]
]
}
The shape that looks reasonable and is not:
["imeta", "url", "ipfs://bafkrei…"]
Both are valid JSON. Only the first is a valid imeta tag, and a relay will
store the second without complaint, because relays validate signatures rather
than tag semantics. Be precise about what that costs, though. With the URL in the
content where it belongs, a malformed tag loses you the metadata (the
dimensions, the hash, the alt text) while the media still renders from the
content URL. The note that fails to render is the one carrying the link only
in the tag, which is the mistake the shape above invites. parse_imeta raises on
the malformed form and build_note puts the URL in the content, with tests
pinning both.
Three details in the correct version:
xcarries the SHA-256 of the content. With a raw-blockipfs://CID that is redundant, because such a CID is the SHA-256 of the bytes. With adag-pbCID it is not redundant at all, since that CID names a UnixFS node rather than the file’s own digest. With anhttps://URL it is the only thing that could stand between the reader and a server quietly serving different bytes — and only in a client that actually checks it, which many do not.fallbackmay repeat, which is the whole reasonparse_imetareturns lists — one reference, several places to try.- The URL scheme is a policy decision.
ipfs://is honest about what the reference is;https://gateway.example/ipfs/<cid>is more likely to render in a browser today. The second buys reach and costs you a dependency on whoever runs that gateway.
Troubleshooting
My CID does not match the one ipfs add printed. Expect three differences
at once, not one. Plain ipfs add defaults to CidVersion = 0, so it prints a
base58 CIDv0 Qm… naming a UnixFS node; this tool prints a base32 CIDv1
bafkrei… naming raw bytes. Version, base, and codec all differ, which is why
comparing the two strings tells you nothing on its own. ipfs add --raw-leaves
on a file under the chunk size gets you the raw form this tool computes.
My media does not render in any client. Check that the URL is in the event
content, not only in the tag. NIP-92 lets a client ignore an imeta tag that
matches no URL in the content, so a note carrying the link solely as a tag has
nothing for the client to render. If the media renders but the metadata is
missing, the tag shape is the suspect instead: ["imeta", "url", "…"] is the
common error and ["imeta", "url …"] is correct.
decode_cid raises expected a base32 CID beginning 'b'. You handed it a
CIDv0. Those begin Qm… and are base58 with no multibase prefix, so they fail
that first check rather than the base32 decode. This tool handles CIDv1 only, by
design: CIDv0 has no version byte and no codec field, which is precisely the
self-description the article is about.
The event id changes between runs. created_at is part of the serialization.
The demo pins it for reproducibility; anything using a live clock will produce a
new id every second.
Recap
You built a tool that computes three identifiers for the same twelve bytes and watched two of them come out as the same SHA-256 digest. That is the concrete version of a distinction usually left abstract: a Nostr event id names a signed statement, and a content address names bytes. Change the words and the event id moves while the CID does not; change the bytes and the CID moves while the event id does not.
Along the way the tool showed that a CIDv1 for a raw block is not mysterious — a
digest, four bytes of prefix, and a base32 encoding, verified against
multiformats by make check-cid. And parse_imeta gives you a check on the
NIP-92 tag shape that relays will not give you, because relays validate
signatures rather than tag semantics.
Where to go next:
- Point the demo at real files.
make demo FILE=…on something large is a quick way to internalize that the identifier is a function of the bytes and nothing else — same file, same CID, every time, on any machine. - Read what these names do not promise. A CID is a permanent name, not a promise that anyone still holds what it names, and the same is true of a Blossom hash and a Nostr event id. A Decentralized Protocol Is Not a Decentralized Deployment takes that apart, with a real relay as the worked example.
- Run a relay and look at real events. Install and Use Buzz on macOS stands up a Nostr relay that stores its media by SHA-256 over Blossom, exactly as computed here.