30817:nostrbox

Nostrbox: PNG Attachments with Alternate Representations

Danny the Cyber Guy

published
2026-10-04

Nostrbox: PNG Attachments with Alternate Representations

draft optional

This NIP defines a file that is an ordinary PNG image and also carries other representations of the same attachment, such as a 3D model, a video or a different image. Clients that know nothing about this NIP show the PNG. Clients that implement it pick the richest representation they can render and fall back to the PNG whenever anything is missing, unsupported or invalid.

Motivation

Rich media has no universal renderer. A sender who attaches a GLB model reaches only the clients that render GLB; everyone else sees a file card or nothing. A nostrbox file degrades instead: the image is always there, and the extra representations are additive.

No existing client, relay, media server or event kind needs to change. The file is announced as image/png and is a valid PNG.

File layout

A nostrbox file is a complete PNG followed immediately by a ZIP archive:

byte 0
  +--------------------------------------------------+
  | PNG: signature, IHDR, ..., IDAT, ..., IEND       |  image decoders stop here
  +--------------------------------------------------+  <- pngEnd
  | ZIP local file header + data   (entry 1)         |
  | ZIP local file header + data   (entry 2)         |
  | ...                                              |
  +--------------------------------------------------+  <- cdOffset
  | ZIP central directory                            |
  +--------------------------------------------------+  <- length - 22
  | ZIP end of central directory record, no comment  |  ZIP readers start here
  +--------------------------------------------------+

The PNG MUST be a valid PNG on its own. No custom chunks or marker bytes are used; PNG decoders ignore everything after IEND.

Every offset inside the archive (the central directory offset in the end of central directory record, and the local header offset in each central directory header) MUST count from byte 0 of the whole file, not from the start of the archive. This is what zip -A produces for self-extracting archives. With these offsets, renaming the file to .zip opens it in ordinary ZIP tools without warnings.

The archive MUST contain .nostr/manifest.json. It SHOULD contain a README.md at its root that explains the file to a person who opened it as a ZIP. Representations are stored as further entries, conventionally under assets/.

README.md                optional, for people
.nostr/manifest.json     required
assets/preview.png       optional image/png representation
assets/model.glb         a model/gltf-binary representation

Manifest

.nostr/manifest.json is a UTF-8 JSON object of at most 65536 bytes:

{
  "version": 1,
  "representations": [
    {
      "type": "image/png",
      "path": "assets/preview.png",
      "size": 53858,
      "sha256": "e707360675bfcdc986e3d386cc46b33d11aef0051e2bbf7df0079b80673e6b25"
    },
    {
      "type": "model/gltf-binary",
      "path": "assets/model.glb",
      "size": 21612284,
      "sha256": "5185313d3957cca6245f9883df6e13264886d05a95a3a1c1617454b6e3707fac"
    }
  ]
}
Field Requirement
version MUST be the integer 1. Readers MUST ignore the package for any other value.
representations Array of 1 to 16 objects.
type Lowercase type/subtype MIME type without parameters.
path Name of an archive entry. Segments of [A-Za-z0-9._-] joined by /. MUST NOT be .nostr/manifest.json. MUST be unique within the array.
size Uncompressed size in bytes. MUST equal the entry's declared size.
sha256 Lowercase hex SHA-256 of the uncompressed entry.

Readers MUST ignore unknown fields, so later revisions can add optional metadata. Readers MUST skip representations whose type they do not support; an unknown type is not an error.

Writers MUST NOT emit duplicate object keys. Readers SHOULD reject manifests that contain them.

No representation is primary, and array order carries no preference. It only breaks ties between two representations of the same type (the first wins).

The PNG at the start of the file is the fallback for clients that do not implement this NIP, or that support none of the listed types. It is not a representation. A client that implements this NIP renders a representation from the manifest instead, and that MAY be an image: an image/png entry can repeat the outer PNG (useful to people browsing the ZIP) or differ from it, so that aware and unaware clients show different pictures.

Archive profile

ZIP implementations disagree on malformed or ambiguous archives. To keep every reader on the same bytes, writers MUST produce, and readers MUST accept only, archives of the following shape. Anything else is treated as "no package".

  1. pngEnd is the offset just past the CRC of the IEND chunk, found by walking PNG chunk lengths from byte 8. A chunk length above 2^31-1, or one that runs past the end of the file, means no package.
  2. The end of central directory record is exactly the last 22 bytes of the file. Its comment length is 0, both disk numbers are 0, and both entry counts are equal and between 1 and 64. Readers MUST NOT scan backwards for the signature.
  3. ZIP64 is not used. All sizes and offsets are 32-bit.
  4. cdOffset >= pngEnd and cdOffset + cdSize == length - 22. The central directory headers fill that range exactly.
  5. Each central directory header has general purpose flags only within 0x0806 (no encryption, no data descriptor), compression method 0 (stored) or 8 (deflate), and start disk 0. Stored entries have equal compressed and uncompressed sizes.
  6. Entry names are valid UTF-8 and non-empty, have no leading /, contain no \, :, NUL or other control characters, and have no empty, . or .. segments (a trailing / marks a directory). Names MUST be unique after lowercasing with the Unicode default case mapping.
  7. Local file headers, ordered by offset, tile [pngEnd, cdOffset) exactly: the first starts at pngEnd, each one starts where the previous entry's data ends, and the last entry's data ends at cdOffset. Each local header repeats its central header's compression method, CRC-32, sizes and name bytes, with no disallowed flags.
  8. Decompression MUST stop at the declared uncompressed size. Output beyond it, output short of it, or a CRC-32 mismatch rejects the entry.

Client behavior

A client that implements this NIP treats a PNG attachment as follows:

  1. Download the file from the URL in the event, never from an image proxy or thumbnail service. If the imeta tag has an x value, the downloaded body MUST hash to it; otherwise show the PNG only.
  2. Decode the PNG with the client's normal image decoder. If that fails, the file is a broken image, as it would be without this NIP.
  3. If the last 22 bytes do not start with PK\x05\x06, show the PNG.
  4. Apply the archive profile, then read and validate the manifest. On any error, show the PNG.
  5. Walk the client's own ranked list of supported types, best first (for example model/gltf-binary, then video/webm, then image/png), and take the first representation of a listed type. If none is present, show the PNG.
  6. Extract the entry. Its size MUST NOT exceed the client's limit for that media type, checked before decompressing. The bytes MUST match the manifest's size and sha256. On any error, show the PNG.
  7. Pass the bytes to the renderer the client already uses for direct attachments of that type, with the same limits and isolation. If it fails, show the PNG.

Clients SHOULD keep a way back to the PNG, and SHOULD save or forward the original file, not the extracted representation.

For encrypted media (for example NIP-17 file messages or Marmot MIP-04), step 1 applies to the ciphertext as the encryption scheme defines; the remaining steps run on the decrypted file.

Publishing

A nostrbox file is published like any PNG. Use the NIP-92 imeta tag (or NIP-94 tags) with m image/png, dim of the PNG, and x set to the SHA-256 of the whole file. No new tag is defined; clients find the package by content.

{
  "kind": 1,
  "content": "my key https://blossom.example/c2c1729c1d6108991132f878b2aacc7b841c3b93481577191a57d5bc146a85f9.png",
  "tags": [
    [
      "imeta",
      "url https://blossom.example/c2c1729c1d6108991132f878b2aacc7b841c3b93481577191a57d5bc146a85f9.png",
      "m image/png",
      "x c2c1729c1d6108991132f878b2aacc7b841c3b93481577191a57d5bc146a85f9",
      "dim 512x512"
    ]
  ]
}

The package survives only where bytes are stored verbatim. Anything that decodes and re-encodes the image drops the archive and leaves a plain PNG:

  • Uploaders MUST NOT resize, recompress or strip metadata from a nostrbox file. A client SHOULD detect the package before upload and skip its usual image processing.
  • Blossom uploads MUST use PUT /upload (BUD-02), not PUT /media (BUD-05).
  • NIP-96 uploads MUST set no_transform=true, and the uploader SHOULD verify that the returned file still hashes to the original.

Security considerations

  • Every byte after IEND, every archive field and every manifest field is attacker-controlled. The archive profile is an allow-list; readers MUST NOT loosen it to accept more archives.
  • The manifest's size and sha256 bind each representation. Readers that follow the archive profile extract the same bytes or reject the file.
  • A deflate bomb stops after size + 1 bytes of output, and size is capped by the client before decompression starts.
  • Entry names are validated even though readers never write them to disk, so a nostrbox file cannot carry a path traversal for the user's ZIP tool.
  • The component that reads the package only moves bytes. It MUST NOT parse the representation itself, and it MUST NOT give a representation access that a direct attachment of the same type would not have.
  • A representation is rendered only by a renderer the client already trusts for that media type.
  • Aware and unaware clients can show entirely different content from the same file, by design. Moderation, reporting and media-scanning tools that look only at the PNG miss the representations; they SHOULD inspect the manifest entries as well.

Compatibility notes

  • Streaming ZIP readers that expect a local header at byte 0 (for example Java ZipInputStream, or bsdtar reading from a pipe) cannot read a nostrbox file. Seekable readers can.
  • Office suites cannot open a PNG-prefixed archive as an Office document in general: LibreOffice since July 2024 rejects any data before the first ZIP entry. This is why the archive explains itself with README.md.
  • Some PNG validators (pngcheck, exiftool) warn about data after IEND.

Discussion

Connect a key to comment.