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 uv 0.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.

NostrIPFSBlossom
Primary purposeMessaging, social and event dataStoring and distributing contentServing blobs for Nostr apps
Data modelSigned eventsContent-addressed blocks and filesContent-addressed opaque blobs
Identitysecp256k1 keypairs (Schnorr, BIP-340)None; peer IDs name nodes, not content or authorsThe same Nostr keypairs
Main identifierEvent id (SHA-256 of the event)CID (multibase + version + codec + multihash)Bare SHA-256 hex
DistributionRelaysNodes exchanging blocks, found via a DHTHTTP servers; redundancy by explicit mirroring
Typical payloadPosts, profiles, DMs, reactionsImages, video, documents, whole sitesImages, video, attachments
PersistenceWhatever relays choose to keepWhatever nodes choose to pinWhatever 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

  • *.nsec is 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 uses hashlib, base64, and json and 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.
  • multiformats is 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_sha256 produces <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_v1 prepends the version and codec and base32-encodes the lot. The leading b is the multibase character naming the encoding — it is not part of the binary CID, it says how to read what follows.
  • CODEC_RAW vs CODEC_DAG_PB is the source of most “why doesn’t my CID match” confusion. raw addresses the bytes as-is and yields a bafkrei… CID. dag-pb addresses a UnixFS node, the chunked and framed structure ipfs add builds around a file, and yields bafybei… in its CIDv1 form. Same file, same hash function, different CID, because they name different objects. Note that plain ipfs add does not print that form: kubo defaults to CidVersion = 0, so you get a base58 CIDv0 Qm… unless you pass --cid-version=1.
  • decode_cid is 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_hash is 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_tag and parse_imeta implement NIP-92, whose format is the thing most write-ups get wrong. See Step 4.
  • event_id implements NIP-01’s canonical serialization: a six-element array with no whitespace and no ASCII escaping. separators=(",", ":") and ensure_ascii=False are both load-bearing — either default changes the hash, and every signature over it is then invalid.
  • build_note is 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_AT is fixed, not time.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 npub as 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_library is 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_digest pins the bafkrei / bafybei distinction 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_bytes is the article’s central comparison expressed as an assertion.
  • test_rejects_the_split_element_shape guards the exact malformed imeta tag that circulates in write-ups about Nostr and IPFS. It is why the parser raises a ValueError there rather than quietly returning a tag with no url in it.
  • test_the_same_content_keeps_its_cid_across_different_notes and test_the_referenced_media_participates_in_the_id are 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 := help makes bare make print 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 so make demo runs against the built-in sample and make demo FILE=photo.jpg runs against a real one, with no second target.
  • check-cid exists 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:

  • x carries the SHA-256 of the content. With a raw-block ipfs:// CID that is redundant, because such a CID is the SHA-256 of the bytes. With a dag-pb CID it is not redundant at all, since that CID names a UnixFS node rather than the file’s own digest. With an https:// 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.
  • fallback may repeat, which is the whole reason parse_imeta returns 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.