kind 35128 · don't trust, verify
3e4ddbde7b34ca5baceec077559a8f79ffc56b0ed215124a337cf5203b112108
{
"id": "3e4ddbde7b34ca5baceec077559a8f79ffc56b0ed215124a337cf5203b112108",
"pubkey": "c2f1ab4e6a577dd8980b57e15986ed92830b95a94f374192235563532b5cac20",
"created_at": 1791814967,
"kind": 35128,
"tags": [
[
"path",
"/index.html",
"45cc5843a8696de680800b66935713709e910f833cdc8972aecbb78e761d9aca"
],
[
"path",
"/__MACOSX/._index.html",
"ada75361a6a1cb29600c6ae4aa3e5713edf8a65c0eb18be4042e698002a8cf60"
],
[
"path",
"/404.html",
"45cc5843a8696de680800b66935713709e910f833cdc8972aecbb78e761d9aca"
],
[
"server",
"https://nsite.run"
],
[
"server",
"https://nostr.download"
],
[
"server",
"https://blssm.us"
],
[
"server",
"https://blossom.yakihonne.com"
],
[
"server",
"https://zencrown.relay2aeon.quest/"
],
[
"relay",
"wss://nsite.run"
],
[
"relay",
"wss://nostr.bitcoiner.social"
],
[
"relay",
"wss://purplepag.es"
],
[
"relay",
"wss://relay.primal.net"
],
[
"relay",
"wss://zencrown.relay2aeon.quest"
],
[
"d",
"whistleblower"
],
[
"title",
"WhistleblowerNostr \u2014 Send an Anonymous Encrypted Tip to a Journalist and Get a Verifiable Receipt"
],
[
"description",
"What this is\nWhistleblowerNostr is a single-file browser app that lets a whistleblower send an encrypted tip to a journalist's Nostr public key, and lets the journalist publish a signed receipt proving that a tip with a specific content hash arrived \u2014 without revealing who sent it, when they sent it, or where they sent it from. There is no server. There is no build step. There is no npm install. You open one HTML file in a modern browser and it works, even offline, because all cryptography runs locally.\n\nWho it is for\nIt is for two people. A source who has something to say and does not want to be identified. A journalist who wants to receive that something and be able to tell the source, in a way the source can verify offline, that it arrived. It is not for ongoing conversations. It is not for file uploads. It is not a replacement for Signal or SecureDrop. It is one narrow, honest tool: encrypted one-shot submission with a cryptographic receipt.\n\nHow the source uses it\nThe source opens the app, switches to the Source tab, and pastes the journalist's npub or nprofile. The app validates the bech32 string, rejects nsec and note and mixed-case strings with a specific error, and tries to fetch the journalist's kind-0 profile from the configured relays so the source can eyeball the name and nip05 and confirm they are talking to the right person. If no profile is found, the app says so plainly and warns the source to verify out of band.\n\nThe source then composes a tip: an optional subject line up to 120 characters, a body between 1 and 8000 characters, up to five attachment URLs that must be https and must not contain credentials, and an optional content warning. The app does not upload files. It only accepts URLs the source already hosts somewhere.\n\nThe source selects relays. The app enforces a hard cap of four concurrent WebSocket connections so a reconnect storm cannot exhaust the browser. It validates every relay URL: wss only, except ws on localhost or 127.0.0.1 during development, and it rejects URLs with embedded credentials.\n\nThe app generates a fresh ephemeral keypair for this tip using the browser CSPRNG. It generates a random correlation ID. It computes a tip hash over the body, the subject, and the attachment list, domain separated and length unambiguous. It derives a human readable receipt phrase from that hash using HKDF and base32, shown as five groups of four characters. It shows a QR code and offers a Download as .txt button. The source must check a box confirming they saved the bundle before the submit button becomes enabled. This is not a nag screen. It is the only path to a receipt later.\n\nWhen the source submits, the app builds an unsigned rumor carrying the tip body, the subject, the attachment URLs as r tags, the content warning if present, and the correlation ID as a tip_id tag. It wraps that rumor in a kind-13 seal signed by the ephemeral key and encrypted to the journalist's public key using NIP-44 version 2. It wraps the seal in a kind-1059 gift wrap signed by a second, freshly generated random key, p-tagged to the journalist, with created_at randomized up to two days in the past so timing analysis cannot easily group messages. It publishes the gift wrap to the journalist's declared kind-10050 DM relays if it can fetch that list, and otherwise falls back to the user-selected relay set with a visible warning that delivery is not guaranteed.\n\nThen it watches. It opens a subscription for a kind-30078 receipt authored by the journalist with d tag wn-receipt: followed by the correlation ID. When a receipt arrives, it re-validates it: it must be signed by the journalist's pubkey, it must verify, the tip_id tag must match, the tip_hash tag must equal the hash the source just recomputed locally from the local tip body, and the phrase tag must equal the phrase the source just recomputed locally. Only if all four checks pass does the app show the green RECEIVED badge with the receipt event ID, the timestamp, and a copyable reference. If a single byte in the receipt is different, the app shows a red mismatch message. It never shows green on a partial match.\n\nHow the journalist uses it\nThe journalist opens the app, switches to the Journalist tab, and connects a signer. Three options are supported. A NIP-07 browser extension, if it exposes nip44.decrypt. A NIP-46 remote signer, if the journalist pastes a bunker:// URI. Or read-only mode, where the journalist pastes a public key and can browse but cannot decrypt or sign. The app tells the truth in each case: NIP-07 without nip44 cannot decrypt, so the app says so and refuses to try.\n\nWhen the journalist clicks Refresh and Decrypt, the app subscribes for kind-1059 gift wraps p-tagged to the journalist's pubkey on the configured relays, up to a sane frame size cap and with a strict validation pipeline. For each wrap, it runs a three-stage unwrap. It decrypts the wrap content with the signer's NIP-44 decrypt function using the ephemeral wrapper pubkey as the peer. It parses and schema-validates the resulting seal. It checks the seal is kind 13. It decrypts the seal content using the seal's author pubkey as the peer. It parses and schema-validates the resulting rumor. It extracts the tip_id, the subject, the attachment URLs from r tags, the content-warning tag if present, and the body from the rumor content. It recomputes the tip hash locally.\n\nEach decrypted tip is displayed with the subject as the headline, the tip_id and rumor timestamp as metadata, the content warning if present as a yellow banner, the body as text, and each attachment URL as a clickable link. There is no place in the UI where the ephemeral sender pubkey is shown as an identity. It is labeled only by its role in the flow, never as a person.\n\nTo acknowledge a tip, the journalist clicks Publish receipt. The app recomputes the receipt phrase from the tip hash and the journalist's pubkey, builds a kind-30078 parameterized replaceable event with d tag wn-receipt:, tip_id, tip_hash, phrase, received_at, and the relay list, and asks the signer to sign it. It validates the returned event, checks that id and sig are present and well formed, and publishes it to the journalist's relay set. The receipt is signed by the journalist's real key. It is intentionally public and indexable by d tag and tip_id so the source can find it.\n\nHow the cryptography works\nTip hash. Concatenate, in this order, with single null byte separators: the UTF-8 string whistleblower-nostr/v1/tip, the tip correlation ID as UTF-8, the SHA-256 of the body as raw bytes, the SHA-256 of the subject as raw bytes, and the SHA-256 of a canonical JSON encoding of the attachment list. Hash the whole thing with SHA-256. The result is the tip hash. It is domain separated, length unambiguous, and reproducible by both parties from the recovery bundle alone.\n\nReceipt phrase. Derive twenty bytes with HKDF-SHA256 using the tip hash as input key material, the UTF-8 string whistleblower-nostr/v1/phrase as salt, and the journalist's pubkey hex as info. Encode the output with base32, strip padding, and group into five groups of four characters. This is a human verifiable check. It is not reversible to the tip body.\n\nGift wrap. NIP-59 over NIP-44 version 2. The rumor is unsigned. The seal is signed by the ephemeral key. The gift wrap is signed by a throwaway key. The gift wrap's created_at is randomized up to two days in the past. The wrapper key is never reused. The ephemeral tip key is never reused. The journalist's real key is the only long-lived identity anywhere in the flow.\n\nDecryption. The journalist's signer does NIP-44 decrypt twice: once with the ephemeral wrapper pubkey, once with the seal author pubkey. No plaintext, no conversation key, and no ephemeral secret is ever logged to the console. The app treats the console as a threat surface.\n\nWhat the app refuses to do\nIt does not implement NIP-04. It does not implement NIP-17's kind-14 chat flow. It does not attempt to be a messenger. It does not upload files. It does not collect email addresses, IP addresses, telemetry, or analytics. It does not open a server, a proxy, or a backend. It does not open more than four WebSocket connections at a time. It does not show a green RECEIVED badge unless the journalist's pubkey matches, the tip_id matches, the tip_hash matches, and the receipt phrase matches. It does not display the ephemeral sender pubkey as an identity. It does not permit a submit without a saved recovery bundle.\n\nWhat the app does not prove\nThe receipt proves that the holder of the journalist's private key signed a statement about a tip whose content hash matches the source's. It does not prove the journalist is who they claim to be. The source must verify the journalist's npub out of band. It does not prove that an adversary who controls every relay the source uses cannot correlate timing. It does not prove that a compromised journalist key cannot forge receipts. It does not provide forward secrecy or post compromise security, because NIP-44 version 2 provides neither. For high risk sources, this app is a starting point and an honest one, not a final answer.\n\nWhat a security reviewer should look at first\nOne. The three stage unwrap in decryptGiftWrap. It checks the wrap shape, decrypts the seal, validates the seal schema and kind, decrypts the rumor, validates the rumor schema, extracts the tip fields, and recomputes the tip hash. Any failure returns a structured failure and never a thrown exception. Two. The receipt verification in watchForReceipt. It requires four matches, not three, and it fails closed. Three. The NIP-46 response handler in Nip46Client._handleResponse. It checks the remote signer pubkey, verifies the event signature, checks the p tag, decrypts with NIP-44, matches the request ID, and handles auth challenges without resolving the promise prematurely.\n\nWhat is known to be limited\nNIP-07 decryption requires the extension to expose nip44.decrypt. Some older extensions only expose nip04, in which case the app says so and refuses to try. Read-only mode cannot decrypt, and the app says so. Live subscription is a manual refresh, not a background stream. The gift-wrap timestamp randomization is two days, per NIP-59. The journalist can publish at most one receipt per tip, because the receipt's d tag is derived from the tip_id. There is no retry of failed relay publications beyond the initial fan-out. Subresource integrity attributes are omitted for the CDN modules because jsDelivr's dynamic +esm bundles do not have stable integrity hashes; pinning is by exact version string instead. The app is client-side only and therefore relies on the browser's own crypto and on the user's own operational security for device integrity.\n\nHow to run it\nSave the file as whistleblower-nostr.html. Open it from file:// in a modern Chromium or Firefox. The UI is fully interactive offline. Tip generation, recovery bundle creation, QR rendering, NIP-44 encryption, and gift-wrap construction all work without a network. Only publishing to relays and fetching a journalist's kind-0 or kind-10050 requires connectivity. There is no install, no service worker, no manifest, no build, and no dependency to vendor."
],
[
"client",
"nsite.run"
]
],
"content": "",
"sig": "79814366c11a28bde11816a132a984299ccc3ecdffbeaba851618c670444f3ec6d54d608cfdd72fba2763f697d308bca8236feee245e3f55ac57a76c2ca3fb55"
}