Envelope format
This page specifies the bytes a NULL paste client writes and reads, so you can build your own client. The web app, the server's validation and the reference test vectors all follow it. Version 1 is a drop opened by the link alone. Version 2 is a password-protected drop that needs both the link key and the password.
The key words MUST, MUST NOT, SHOULD and MAY are used as in RFC 2119.
Overview
share link https://paste.nullnet.sh/p/<drop-id>#k=<key>
└─ never sent to the server
plaintext manifest_len | manifest (JSON) | body 0 | … | body n-1 | zero padding
envelope v1 0x01 | IV | AES-256-GCM(plaintext) ‖ tag
envelope v2 0x02 | KDF id | Argon2id params | salt | commitment | IV | AES-256-GCM(plaintext) ‖ tag
One drop is exactly one encryption under a key used for nothing else. The server stores and returns the envelope as opaque bytes. Integers are unsigned and big-endian.
Key and link
- The key is 32 bytes from a cryptographically secure RNG. Generate a new key for every drop; a key MUST NOT encrypt more than one envelope.
- In the link the key is base64url without padding (RFC 4648 §5): always exactly 43 characters. Decoders MUST reject padding, characters outside the base64url alphabet, any other length, and non-canonical encodings (non-zero unused bits in the last character).
- The key is carried in the URL fragment as
k=<key>.
Fragment
The fragment is a list of name=value pairs separated by &. A pair without = is a name with an empty value.
- Readers MUST reject a fragment without exactly one
k, and MUST ignore names they do not know. - Names are case-sensitive (
K=is notk=). No percent-decoding is applied. p=1marks a password-protected drop, so a reader can ask for the password before it consumes a view. It is a hint only. The envelope's version byte decides: a reader MUST open a version 1 envelope without a password whatever the fragment says, and MUST ask for a password for version 2 even whenpis absent. Writers of a version 2 drop SHOULD addp=1afterk.- The password MUST NOT appear in a link.
- Clients MUST NOT copy the key or the fragment into API requests, logs, telemetry, error reports or the
Refererheader.
| Fragment | Result |
|---|---|
k=aZVUynPwUqZHomKlw2eGClahSxSBuLqHd-XciABbhDc | key |
k=aZVUynPwUqZHomKlw2eGClahSxSBuLqHd-XciABbhDc&p=1 | key, password hinted |
x&k=aZVUynPwUqZHomKlw2eGClahSxSBuLqHd-XciABbhDc | key (unknown x ignored) |
empty, k=, K=…, k twice | rejected |
The web app's manage link uses the same syntax: https://paste.nullnet.sh/m/<id>#t=<manage_token>, optionally followed by &k=<key> and &p=1.
Envelope
The first byte is the version. Everything before the IV is the header, and the header is the AES-GCM additional authenticated data (AAD) in every version. Readers MUST reject an unknown version before attempting decryption.
The server validates only what it can see without a key: a known version, that version's minimum length, the version 2 parameter bounds, and the caller's size cap. Everything else is the reader's job.
Version 1: link key
| Offset | Length | Field |
|---|---|---|
| 0 | 1 | version, 0x01 |
| 1 | 12 | iv, random |
| 13 | n + 16 | AES-256-GCM ciphertext of the plaintext, then the 16-byte tag |
- Cipher: AES-256-GCM, 96-bit IV, 128-bit tag. The IV is 12 fresh random bytes per encryption.
- Content key: the link key.
- AAD: the single version byte
0x01. - Minimum length: 29 bytes (empty ciphertext).
Version 2: link key and password
| Offset | Length | Field |
|---|---|---|
| 0 | 1 | version, 0x02 |
| 1 | 1 | kdf, 0x01 = Argon2id version 0x13 (RFC 9106). No other value is defined. |
| 2 | 4 | m_cost_kib, u32: Argon2 memory in KiB |
| 6 | 4 | t_cost, u32: Argon2 passes |
| 10 | 1 | lanes: Argon2 parallelism |
| 11 | 16 | salt, random |
| 27 | 32 | commitment |
| 59 | 12 | iv, random |
| 71 | n + 16 | AES-256-GCM ciphertext of the plaintext, then the 16-byte tag |
- Cipher and IV as in version 1. The content key is derived (below).
- AAD: bytes 0–58, the whole header up to the IV.
- Minimum length: 87 bytes.
- The plaintext is exactly the version 1 plaintext.
Argon2id parameter bounds
Readers MUST reject a header that fails any of these before running Argon2id, and writers MUST NOT produce one. The server rejects such uploads with 400 invalid_envelope.
| Parameter | Allowed |
|---|---|
kdf | 0x01 |
m_cost_kib | 19456–131072 (19 MiB to 128 MiB) |
t_cost | 2–8 |
lanes | 1–4 |
m_cost_kib × t_cost | at most 393216 |
The upper bounds keep a hostile envelope from making a reader allocate gigabytes or hash for minutes. Writers use m_cost_kib 65536 (64 MiB), t_cost 3, lanes 1, and a fresh 16-byte random salt per drop. Readers take the parameters from the header.
Key derivation
password' = UTF-8(NFC(password)) 1–1024 bytes
pw_key = Argon2id(password', salt, m_cost_kib, t_cost, lanes,
version 0x13, tag length 32, no secret, no associated data)
okm = HKDF-SHA-256(IKM = link_key ‖ pw_key, salt = empty,
info = "zkbin-envelope v2", L = 64)
content_key = okm[0..32] AES-256-GCM key
commitment = okm[32..64] stored in the header
- Normalise the password to Unicode NFC, then encode it as UTF-8. It MUST be 1–1024 bytes after that; refuse other lengths before deriving anything.
link_keyis the 32-byte key from the link, fresh per drop as in version 1. Without it the password alone opens nothing, and without the password the link alone opens nothing.infois the 17 ASCII byteszkbin-envelope v2. An empty HKDF salt means 32 zero bytes (RFC 5869 §2.2).- The
commitmentbinds the envelope to one combination of link key, password, salt and parameters. A reader compares it in constant time before decrypting; a mismatch means a wrong password (or a wrong link). It also makes the ciphertext key-committing, which AES-GCM alone is not. Checking it costs a guesser the same Argon2id run as trying the GCM tag.
A password is a second factor, not secrecy from someone who has the link and the envelope: they can guess passwords offline at the cost of one Argon2id run per guess. A weak password protects little.
Plaintext
offset 0 u32 manifest_len
offset 4 manifest_len bytes of UTF-8 JSON (the manifest)
then the entry bodies, concatenated in manifest order
then padding: zero or more 0x00 bytes
manifest_lenMUST be between 1 and 16384 (16 KiB).- The sum of all entry
sizevalues MUST NOT exceed the bytes after the manifest. - Every byte after the last body is padding and MUST be
0x00. Readers MUST reject non-zero padding, so every plaintext has exactly one valid reading.
Manifest
A JSON object. Writers emit compact JSON with members in the order shown, omit absent optional fields, emit integers as plain digits, and never emit duplicate members. Readers MUST ignore members they do not know, MUST NOT depend on member order or whitespace, MUST reject null for a defined member, MUST reject non-integral or out-of-range integers, and SHOULD reject duplicate members.
| Member | Type | Required | Meaning |
|---|---|---|---|
v | integer | yes | Manifest version, 1. Readers reject other values. |
created_at | integer | yes | Sender's clock, Unix seconds, 0 to 2^53-1. Informational only; the server's expiry is authoritative. |
entries | array | yes | 1–20 entry objects. More than one entry is a bundle. |
Entry object:
| Member | Type | Required | Meaning |
|---|---|---|---|
kind | string | yes | "text" or "file". |
size | integer | yes | Body length in bytes, 0 to 2^53-1. |
name | string | file: yes, text: no | Display and download name. |
type | string | no, file only | Media type. |
lang | string | no, text only | Syntax-highlighting hint. |
- A
textbody MUST be valid UTF-8 (no BOM is added or stripped). Render it as text, never as HTML. - A
filebody is arbitrary bytes. Offer it only as a download; never render or execute it inline, whatevertypesays. typeon a text entry, orlangon a file entry, is invalid.- The 20-entry and 16 KiB limits are format limits. A tier may allow fewer entries (
max_filesfromGET /api/limits); the client enforces that before encrypting.
Example manifest for a single text entry:
{"v":1,"created_at":1792400000,"entries":[{"kind":"text","size":13}]}
Field syntax
| Field | Length (bytes) | Allowed |
|---|---|---|
name | 1–255 UTF-8 | Any Unicode except control characters (U+0000–U+001F, U+007F–U+009F), / and \; not . or ... |
type | 3–127 ASCII | type/subtype: exactly one /, both parts one or more of A-Z a-z 0-9 ! # $ & - ^ _ . +, first character alphanumeric. No parameters. |
lang | 1–32 ASCII | a-z 0-9 + # . _ - |
name is a hint. A client saving a file still applies its own platform's filename rules.
Padding
Ciphertext length reveals plaintext length, so writers pad with 0x00 bytes. The web app rounds the plaintext up to the next multiple of 4096 bytes, never beyond the tier's maximum plaintext size (max_envelope_bytes minus 16 KiB). Above 1 MiB it rounds to a coarser block: 1/16 of the largest power of two not above the unpadded length.
Bundles
A bundle is not a format change. It is a version 1 plaintext (or a version 2 envelope) whose manifest has more than one entry, sealed into one envelope and opened with one view. Bundles are a Premium feature. The server never sees the manifest, so the client does the checks:
- Read
max_filesandmax_envelope_bytesfromGET /api/limitswith the user'sX-Null-Pass. Never hard-code them. - Refuse to build a bundle with more than
max_filesentries. - The whole bundle (manifest, every body, padding, IV and tag) must fit
max_envelope_bytes. Check the sealed length before uploading, since the server answers413only after receiving the bytes. - On open, show each entry by its kind, using its
name,typeandsize. Reject the payload if the sizes do not add up. - Burn and view limits apply to the bundle as a whole: one open reveals every entry.
Writing a drop
- Build the manifest and plaintext, and pad it.
- Generate a 32-byte key and a 12-byte IV with a secure RNG.
- Version 1: encrypt the plaintext with AES-256-GCM under the key, AAD
0x01. The envelope is0x01 | iv | ciphertext ‖ tag. - Version 2: generate a 16-byte salt, derive
content_keyandcommitment, build the 59-byte header, and encrypt undercontent_keywith the header as AAD. The envelope isheader | iv | ciphertext ‖ tag. - Upload with
POST /api/drops. - Build the link
https://paste.nullnet.sh/p/<id>#k=<base64url key>, plus&p=1for version 2.
Opening a drop
- Parse the fragment and decode the key. On failure: "invalid link".
- Fetch the envelope with
POST /api/drops/{id}/open. Check that the first byte is0x01or0x02, that the length is at least 29 or 87, and for version 2 the parameter bounds. On failure: "unsupported or damaged paste". - Version 2 only: ask for the password, derive
content_keyandcommitment, and compare the commitment. On mismatch: "wrong password". Keep the envelope and let the user try again; a retry is local and costs no view. - Decrypt the bytes after the IV with the content key (version 1: the link key), the IV, and the header as AAD. On failure: "wrong link or damaged paste" (version 2: "damaged paste", since the commitment already matched). A reader cannot tell a wrong key from tampering and MUST NOT guess.
- Parse the plaintext and validate every rule above. On failure: "damaged paste". Show nothing from a payload that fails validation.
Keep these five failure classes and no finer detail in user-facing messages.
Example in Python
This script creates and opens a text drop with Python's cryptography package (version 44 or newer, for Argon2id) and the standard library. Its key encoding, plaintext, padding, version 1 and version 2 envelopes match the web app byte for byte, and it passes every valid test vector. Its entries function checks the structure and padding but not every field rule of the manifest section; a complete reader must apply them all.
zkpaste.py
import base64, hmac, json, os, struct, time, unicodedata
import urllib.request
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
API = "https://paste.nullnet.sh/api"
PARAMS = (65536, 3, 1) # m_cost_kib, t_cost, lanes: the writer defaults
def b64url(data):
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def decode_key(text):
if len(text) != 43:
raise ValueError("invalid link")
key = base64.urlsafe_b64decode(text + "=")
if b64url(key) != text: # rejects non-canonical last characters
raise ValueError("invalid link")
return key
def plaintext_for(text, max_plaintext=1024 * 1024):
body = text.encode()
manifest = json.dumps(
{"v": 1, "created_at": int(time.time()),
"entries": [{"kind": "text", "size": len(body)}]},
separators=(",", ":"),
).encode()
unpadded = 4 + len(manifest) + len(body)
if unpadded > max_plaintext:
raise ValueError("too large")
padded = min(-(-unpadded // 4096) * 4096, max_plaintext)
return struct.pack(">I", len(manifest)) + manifest + body + bytes(padded - unpadded)
def password_keys(link_key, password, salt, params):
pw = unicodedata.normalize("NFC", password).encode()
if not 1 <= len(pw) <= 1024:
raise ValueError("password must be 1-1024 bytes")
m_cost_kib, t_cost, lanes = params
pw_key = Argon2id(salt=salt, length=32, iterations=t_cost,
lanes=lanes, memory_cost=m_cost_kib).derive(pw)
okm = HKDF(algorithm=hashes.SHA256(), length=64, salt=None,
info=b"zkbin-envelope v2").derive(link_key + pw_key)
return okm[:32], okm[32:] # content_key, commitment
def seal(plaintext, key, iv, password=None, salt=None, params=PARAMS):
if password is None:
header, content_key = b"\x01", key
else:
content_key, commitment = password_keys(key, password, salt, params)
header = b"\x02\x01" + struct.pack(">IIB", *params) + salt + commitment
return header + iv + AESGCM(content_key).encrypt(iv, plaintext, header)
def in_bounds(m, t, lanes):
return 19456 <= m <= 131072 and 2 <= t <= 8 and 1 <= lanes <= 4 and m * t <= 393216
def unseal(envelope, key, password=None):
if envelope[:1] == b"\x01" and len(envelope) >= 29:
aad_len, content_key = 1, key
elif envelope[:2] == b"\x02\x01" and len(envelope) >= 87:
params = struct.unpack(">IIB", envelope[2:11])
if not in_bounds(*params):
raise ValueError("unsupported or damaged paste")
if password is None:
raise ValueError("password required")
content_key, commitment = password_keys(key, password, envelope[11:27], params)
if not hmac.compare_digest(commitment, envelope[27:59]):
raise ValueError("wrong password")
aad_len = 59
else:
raise ValueError("unsupported or damaged paste")
iv, body = envelope[aad_len:aad_len + 12], envelope[aad_len + 12:]
return AESGCM(content_key).decrypt(iv, body, envelope[:aad_len])
def entries(plaintext):
(mlen,) = struct.unpack(">I", plaintext[:4])
if not 1 <= mlen <= 16384 or 4 + mlen > len(plaintext):
raise ValueError("damaged paste")
manifest = json.loads(plaintext[4:4 + mlen].decode("utf-8"))
at, out = 4 + mlen, []
for entry in manifest["entries"]:
if entry["size"] > len(plaintext) - at:
raise ValueError("damaged paste")
out.append((entry, plaintext[at:at + entry["size"]]))
at += entry["size"]
if any(plaintext[at:]):
raise ValueError("damaged paste")
return out
def create(text, ttl=3600, burn=False, password=None):
key, iv, salt = os.urandom(32), os.urandom(12), os.urandom(16)
envelope = seal(plaintext_for(text), key, iv, password, salt)
req = urllib.request.Request(
f"{API}/drops?ttl={ttl}&burn={'true' if burn else 'false'}",
data=envelope, method="POST",
headers={"Content-Type": "application/octet-stream"})
with urllib.request.urlopen(req) as res:
created = json.load(res)
link = f"https://paste.nullnet.sh/p/{created['id']}#k={b64url(key)}"
return link + ("&p=1" if password else ""), created["manage_token"]
def open_link(link, password=None):
path, fragment = link.split("#", 1)
drop_id = path.rsplit("/", 1)[1]
keys = [v for n, _, v in (p.partition("=") for p in fragment.split("&")) if n == "k"]
if len(keys) != 1:
raise ValueError("invalid link")
key = decode_key(keys[0])
req = urllib.request.Request(f"{API}/drops/{drop_id}/open", method="POST")
with urllib.request.urlopen(req) as res: # consumes one view
envelope = res.read()
return [body.decode() if e["kind"] == "text" else body
for e, body in entries(unseal(envelope, key, password))]
if __name__ == "__main__":
link, token = create("hello, world\n", ttl=3600, burn=True)
print(link)
print(open_link(link))
open_link takes the password up front and fetches the envelope once, so a wrong password on a burn drop loses the drop. An interactive client should keep the envelope and retry unseal with a new password locally.
Test vectors
The reference vectors are generated with WebCrypto, the API the web app uses, plus Node's Argon2id for version 2. Every implementation must pass all cases.
- Version 1 has 6 valid cases (minimal text, text with a language hint padded to 4096, empty text, a binary file, a bundle with Unicode names, a manifest with an unknown member and different order) and 43 invalid cases. Invalid cases fail with one of
version,truncated,decryptorpayload, matching steps 2, 2, 4 and 5 of the reader algorithm. They also include 7 invalid key encodings and 8 fragment cases. - Version 2 has 9 password normalisation cases, 5 valid cases (default parameters, minimum parameters with a file, four lanes, the maximum cost, NFD input) and 24 invalid cases with errors
version,truncated,params,password,decryptorpayload.paramscases MUST fail without running Argon2id: some of them ask for 4 GiB.
Two cases you can check by hand:
Version 1: text_minimal
key Yt_ES_qQV7E7pbYJK_343m0CFN_71ElU7SxFf0w3Fa4
iv d4c7394b9dd7e8a59ec75611
manifest {"v":1,"created_at":1792400000,"entries":[{"kind":"text","size":13}]}
body "hello, world\n" (no padding)
plaintext 000000457b2276223a312c22637265617465645f6174223a313739323430303030302c22
656e7472696573223a5b7b226b696e64223a2274657874222c2273697a65223a31337d5d
7d68656c6c6f2c20776f726c640a
envelope 01d4c7394b9dd7e8a59ec7561191b6ffb19395ae2aba56b99f5fbc46bf48811b4a432aac
2f49a446f3b3974effac04d1c087912744659a7a7fc3f4957435f1311514d901d03a7865
d65508cfe0e0b469fe7f02113a84dc033775740e96f193320d4477779929d3656b4c59c4
d59afaa878f7de
Version 2: password_default_params
key js_PrTsDWVjq_KJQdRNu9U6aOvwBiQCIX1BlqMx4WoA
password correct horse battery staple
params m_cost_kib 65536, t_cost 3, lanes 1
salt ddddbc560f0cab48f0f615e706712456
pw_key 5160b6456c3b9fa6afb28a2ab6d519d04fc5b5f782e5b6e8694535d050d785c8
content_key e4025c7cc103f773c07c3ec35d1eb386284e86955bae5759f36733049f6cfdf1
commitment bbe7466536727094330edd9fb4cf07add8040424b90874019a86d10e56f843ba
iv 4194c04dc7e13582b1153235
plaintext same as text_minimal
envelope 0201000100000000000301ddddbc560f0cab48f0f615e706712456bbe74665367270
94330edd9fb4cf07add8040424b90874019a86d10e56f843ba4194c04dc7e13582b1
1532351e01cd7198b3331005cdaed475fad014e18d041f948174f425cdf09b7812e3
ecb6549b6790df3ba35ed07f3fbcf899efd4622f461c5d1e9898929c5d90fb1d28a6
cec67159066cbb59722b705c74ad4eb6d9d0ce9b7ef8b03068565efb461370216f2c
b4ffe7
Hex values are wrapped for reading; join the lines without spaces.
Versioning
A new envelope layout gets a new version byte. Readers reject versions they do not know, so a reader that knows only version 1 rejects a version 2 envelope at step 2 of the reader algorithm. New manifest members can be added without a new version, because readers ignore unknown members; a change to the meaning of existing data bumps the manifest v.