30817:nostrbox
Nostrbox: PNG Attachments with Alternate Representations
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".
pngEndis the offset just past the CRC of theIENDchunk, 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.- 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.
- ZIP64 is not used. All sizes and offsets are 32-bit.
cdOffset >= pngEndandcdOffset + cdSize == length - 22. The central directory headers fill that range exactly.- 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. - 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. - Local file headers, ordered by offset, tile
[pngEnd, cdOffset)exactly: the first starts atpngEnd, each one starts where the previous entry's data ends, and the last entry's data ends atcdOffset. Each local header repeats its central header's compression method, CRC-32, sizes and name bytes, with no disallowed flags. - 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:
- Download the file from the URL in the event, never from an image proxy or
thumbnail service. If the
imetatag has anxvalue, the downloaded body MUST hash to it; otherwise show the PNG only. - 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.
- If the last 22 bytes do not start with
PK\x05\x06, show the PNG. - Apply the archive profile, then read and validate the manifest. On any error, show the PNG.
- Walk the client's own ranked list of supported types, best first (for
example
model/gltf-binary, thenvideo/webm, thenimage/png), and take the first representation of a listed type. If none is present, show the PNG. - 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
sizeandsha256. On any error, show the PNG. - 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), notPUT /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
sizeandsha256bind each representation. Readers that follow the archive profile extract the same bytes or reject the file. - A deflate bomb stops after
size + 1bytes of output, andsizeis 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, orbsdtarreading 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.