NKIP-0006 · Draft
PGP Chat over Nostr
Abstract. Two people whose identity is an OpenPGP key chat through ordinary Nostr relays. Neither needs a Nostr identity. Relays see neither who sends a message nor who it is for.
| NKIP | 6 |
|---|---|
| Title | PGP Chat over Nostr |
| Status | Draft |
| Type | Standards Track |
| Created | 2026-10-27 |
| License | CC0-1.0 |
| Requires | NIP-01, RFC 9580 |
Files: schema.json · vectors/v1.json
Contents
This NKIP documents the deployed protocol (v1), says what is wrong with it, and specifies v2.
Motivation
A PGP-over-Nostr chat is already in use between at least two independent clients (v1 below). Its users swap PGP keys out of band (QR code, clipboard), and relays carry the encrypted messages. The idea is sound: an existing PGP key, already published or verified elsewhere, becomes a chat identity, and the relays are dumb pipes. v1 gets three things badly wrong:
- Anyone can read who is messaging whom. Every message is a public kind 1 note tagged with the recipient's short key id. Each client publishes from one key per session and reads its own inbox over the same connection.
- Anyone can pose as your contacts. Messages are not signed. The sender is
a
[FROM:<id>]text inside the ciphertext, and anyone holding your public key (which is meant to be shared) can write it. - Anyone can rewrite your contacts. An unsigned handshake can replace the name and inbox channel stored for a contact you already have.
Goals:
- G1 A relay or observer can't link a conversation to either person or key. It also can't link a conversation's messages across days, or one person's conversations with different contacts. Within one UTC day, the messages of one conversation do share a tag, so they can be linked to each other (how many, when, how big; see Security considerations). IP addresses are out of scope (use Tor).
- G2 A message is shown as coming from a contact only if that contact's key signed it, for this recipient.
- G3 Contacts are added only by explicit user action, and stored contacts change only through messages signed by their key.
- G4 Any OpenPGP implementation (RFC 9580 or RFC 4880) can take part. No Nostr identity is needed.
Non-goals:
- Forward secrecy. PGP keys are long-lived; if one leaks, messages encrypted to it can be read. NIP-44 has the same property.
- Group chat.
- Hiding the sender's IP address from relays (use Tor or a proxy).
Relation to NIP-17
NIP-17 (private direct messages: NIP-44 encryption, NIP-59 gift wrap) is the Nostr-native way to send private messages, and the better-supported one. Users who have Nostr identities SHOULD use NIP-17. This NKIP exists only for people whose identity is a PGP key: users without a Nostr key, or users who want their existing, already-verified PGP key to be the identity.
Metadata compared:
| v1 (this NKIP, deployed) | NIP-17 | v2 (this NKIP) | |
|---|---|---|---|
| Sender visible to relays | per-session key, links every message the sender sends in a session | no (random key per wrap) | no (random key per event) |
| Recipient visible to relays | yes: short key id in a tag, in a plaintext header and in the PGP packet | yes: recipient pubkey in the wrap's p tag (inbox relays with NIP-42 AUTH can limit who reads it) |
no: per-pair tag that changes daily. A conversation's messages on the same day share it |
| Sender authenticated | no | yes (seal signed by the sender's key) | yes (OpenPGP signature) |
| Identity | PGP key | Nostr key | PGP key |
Carrying the PGP message inside a NIP-59 gift wrap was considered and
rejected. A gift wrap must name the recipient's Nostr pubkey in p, so it
would bring back the recipient link that v2 removes, and PGP-only users have
no Nostr pubkey to name.
Roles
| Role | Does |
|---|---|
| Client | Holds the user's PGP key, contacts and per-contact channel secrets. Publishes and reads events. |
| Relay | Any NIP-01 relay. Stores and forwards events; learns nothing it can use beyond IP addresses and timing. |
Specification
The key words MUST, SHOULD and MAY are used as in RFC 2119. Fingerprint means the full OpenPGP fingerprint, uppercase hex (40 characters for v4 keys, 64 for v6). The 8-character short id that v1 uses is never an identity in v2.
v1 (deployed; deprecated)
This section only describes what is on relays today.
- Identity. A PGP key. Short id
FP8= the last 8 hex digits of the fingerprint, uppercase. A user's inbox topic isghost-<FP8>. - Event. Kind 1, tags
[["t", "ghost-<FP8 of recipient>"], ["client", "<client name>"]], signed by a Nostr key the client generates once per session and uses for every message in that session. - Content. A routing line
[<label>: <FP8 of recipient>], a newline, then an ASCII-armored OpenPGP message encrypted (not signed) to the recipient's key. The plaintext is[FROM:<FP8 of sender>] <text>. - Handshake. Before the first message to a peer, the client sends the
plaintext
[FROM:<FP8>] {"ver":1,"name":…,"pubKey":<armored>,"fp":<FP8>,"topic":"ghost-<FP8>"}the same way. Receivers import the key and store the name and topic. - Reading. Subscribe to
{"kinds":[1], "#t":["ghost-<own FP8>"]}, with nosincelimit, so every session re-downloads the whole history.
What a relay or anyone else reads from a v1 message, with no key at all
(checked by v1_observer in the vectors):
| Field | Reveals |
|---|---|
t tag |
Recipient's FP8. Anyone holding the recipient's public key (it is shared by design) can compute it and list every message ever sent to them: count, times, sizes. Kind 1 notes are kept indefinitely and show up in hashtag search and indexers. |
| Routing line | The same FP8, in plain text. |
| OpenPGP PKESK packet | The recipient's 64-bit encryption-subkey id (also in their public key). Even without the tag, messages link to the key. |
| Event pubkey | One key per session. Every message a sender sends in that session links together, so writing to ghost-A and ghost-B reveals talking to both. |
| Connection | The same connection subscribes to the sender's own topic and publishes to the peer's. Each relay sees both ends of the conversation, sender FP8 ↔ recipient FP8. |
And what anyone can do:
- Pose as a contact.
[FROM:<FP8>]is just text, so anyone holding your public key can encrypt[FROM:<your contact's FP8>] …to you (thev1_spoofvector decrypts to exactly that). - Hijack a contact. A handshake that carries a known contact's own public
key with a new
nameandtopicreplaces that contact's stored name and inbox topic in deployed clients. Your next messages then go to a topic the attacker picked: they can't read them, but the contact never gets them. - Collide short ids. A key with a chosen FP8 takes about 2³² key generations, so a short id is not an identity.
A client can close the spoofing and hijack bugs without leaving v1: sign every
message and handshake (other v1 clients decrypt a signed message unchanged),
check the signature against the contact the [FROM:] names, check an invite's
fp against its key, and never let a handshake rename or re-route a known
contact. At least one deployed client already does this. Even then, v1's
metadata leaks remain.
v2 clients MAY keep a v1 reader for old contacts (see Coexistence).
v2 overview
Each client gives each contact its own random channel secret for messages to the client. Every event carries a tag derived from that secret and the current day, so only the two parties can tell which events belong to them. Messages are OpenPGP messages that are signed and encrypted, with the recipient key id hidden, and are published from a fresh Nostr key each time.
Invite (out of band)
A user shares an invite as JSON (QR code, clipboard, a web page):
{"ver":2, "name":"<display name>", "pubKey":"<armored public key>",
"fp":"<fingerprint>", "intro":"<64 lowercase hex>"}
fpMUST be the fingerprint ofpubKey. Clients MUST compute it from the key and refuse the invite if it differs.introis a random 32-byte intro secret. Anyone holding the invite can send the inviter ahelloon the intro channel. A user MAY issue different invites with different intro secrets, and SHOULD retire an intro secret that attracts spam.name: at most 64 characters, no control characters. It is a hint only."v1": trueMAY be added to say the inviter still reads v1 (see Coexistence).
Channel tag
epoch = floor(created_at / 86400)
tag = lowercase hex of the first 16 bytes of
HMAC-SHA256(key = secret (32 bytes), msg = "nkip06/2 tag" || uint64_be(epoch))
created_at is the event's own timestamp. A sender MUST derive the tag from
the event's created_at, and a receiver MUST check that the tag matches the
secret it expects for that created_at. So an event can't be copied into
another day without the secret.
The channel secret is long-lived. The daily epoch changes the tag's value,
not the secret, so anyone who learns a secret can link that conversation on
every day, past and future. The defence is that relays and observers never
see a secret: it travels only inside signed, encrypted hello and rekey
payloads. A rekey limits the damage of a leak to the days before it.
Times
created_at is chosen by whoever holds the event's throwaway Nostr key, so it
says nothing about freshness. The send time is the inner ts, which the
sender's PGP key signs, and it is the only time a client shows or trusts.
tsMUST equalcreated_at(the sender sets both;created_atonly picks the tag's day).- A receiver MUST drop a payload whose
tsis more than 30 days before its own clock (its subscription window) or more than 10 minutes after it. - A contact can still backdate its own messages inside the window; the time shown is that contact's signed claim, not a proof.
Event
{"kind": 9061, "created_at": <now>, "tags": [["z", "<tag>"]], "content": "<armored PGP message>"}
The sender MUST:
- Sign each event with a new Nostr key, used for nothing else.
- Set
created_atto the current time. - Put the
ztag first. After it the event MAY carry one NIP-40["expiration", "<unix time>"]tag, at least 30 days aftercreated_atso receivers can still fetch it, and one NIP-13["nonce", "<n>", "<target>"]proof-of-work tag. No other tags (nop,t,eorclient). Senders SHOULD set an expiration so relays can delete old traffic. - Put only the ASCII-armored OpenPGP message in
content: no routing line before it and no armor headers (Comment:,Version:). - Encrypt to the recipient's key with a hidden recipient and sign with
the sender's key. In a v3 PKESK packet (RFC 4880 and 9580) the key id is
all zeros (
--throw-keyidsin GnuPG). In a v6 PKESK packet (RFC 9580) the key-version and fingerprint field is empty (length 0).
The sender SHOULD publish to at least two relays. Every event comes from a
new key, which relays that filter by web of trust, payment or account age
will refuse. Senders and receivers SHOULD agree on relays that accept such
events (open relays, or relays that accept NIP-13 proof of work). A sender
SHOULD add proof of work when its relays ask for it, and SHOULD check the
NIP-01 OK response before treating a message as sent. It SHOULD use separate
connections to publish and to subscribe, and SHOULD use Tor or a proxy.
Inner payload
The signed plaintext is UTF-8 JSON:
{"v":"nkip06/2", "type":"msg"|"hello"|"rekey", "id":"<32 lowercase hex, random>",
"ts":<created_at of the event>, "from":"<sender fingerprint>",
"to":"<recipient fingerprint>", "body":…, "pad":"<spaces>"}
type |
Sent on | body |
|---|---|---|
msg |
the recipient's channel for the sender | the message text |
hello |
the recipient's intro channel | {"name", "pubKey", "chan"}: the sender's key and the channel secret the recipient should use to write back |
rekey |
the recipient's channel for the sender | {"chan"}: a new channel secret for messages to the sender |
The sender SHOULD pad with spaces in pad so that the plaintext length is a
multiple of 256 bytes.
Receiving
A client subscribes to {"kinds":[9061], "#z":[…]}, listing the tags for:
- each contact's channel secret;
- each live intro secret;
- every epoch from the last one it read (at most 30 days back) through the next one, to allow for clock skew.
A receiver MAY spread these tags over several relays or connections (one subscription per contact, say). That way no single relay sees the whole set of tags and can tie the user's conversations together (see Security considerations).
For each event it MUST, in order:
- Check the event shape (Event 3–5) and find which secret produced the tag
for this
created_at; drop the event if none did. - Decrypt it, and drop it unless it carries a valid OpenPGP signature.
- Require the signer's fingerprint to equal
from,toto equal its own fingerprint,tsto equalcreated_at, andtsto fall within the window in Times. - Drop the payload if its
idwas already seen (keep ids at least as long as the subscription window). Replay protection is byidonly: a payload with a newidand the same body is a new message. This is idempotence, not content dedupe; a sender that retries reuses theid. - For
msgandrekey: requirefromto be the contact whose channel secret produced the tag. A message is shown as coming from that contact. - For
hello: require an intro tag, and compute the fingerprint ofbody.pubKeyitself (never trust a fingerprint carried next to it). It MUST equalfrom. Iffromis not a contact, show a contact request with the fingerprint and the (unverified) name, and add nothing until the user accepts. Iffromis already a contact, ahelloMAY update the channel secret (it is signed by that key) but MUST NOT change the stored key or name. When several valid hellos or rekeys arrive, the one with the latest signedtswins (ties: the higherid). The secret it replaces is retired: the receiver keeps reading it for 2 more days, for messages already in flight, then drops it. - Key changes. A contact who moves to a new PGP key is, to the protocol,
a new key: its
hellois a new contact request, even if it uses the old contact's name. It MUST NOT be merged into or replace the existing contact. The user may link the two by hand, ideally after checking the new fingerprint out of band (or a message signed by the old key).
Flow
Alice Bob
| invite {fp, pubKey, intro} ── out of band ──> |
| imports, checks fp
|<── hello on tag(intro_A): {pubKey_B, chan_B→A} ─| chan_B→A: Bob's secret for messages to Bob
| contact request → accept |
|── rekey on tag(chan_B→A): {chan_A→B} ──────────>| chan_A→B: Alice's secret for messages to Alice
|<── msg on tag(chan_A→B) ─────────────────────────|
|── msg on tag(chan_B→A) ────────────────────────>|
Each side picks the secret for its own inbound channel, separately for each contact, so tags never link one person's conversations with different people.
Coexistence with v1
- A v2 client MUST NOT send v1 to a contact whose invite or
hellowas v2. - A v2 client MAY read v1 (
#t: ghost-<own FP8>) for contacts who only speak v1. It MUST mark such messages as unverified sender, MUST NOT let a v1 handshake change an existing contact's key, name or topic, and SHOULD treat a v1 handshake from an unknown key as a contact request. - A v2 client MAY send v1 to a v1-only contact after warning the user that the conversation is visible to relays and that the contact can't tell the message is really from them.
- Running a v1 reader keeps the recipient-linking leak for the user's own
inbox. Invites SHOULD set
"v1": trueonly while v1 contacts remain.
Requirements
| ID | Level | Goal | Requirement | Acceptance check |
|---|---|---|---|---|
| R1 | MUST | G1 | Event: kind 9061, the z tag first (from the channel secret and created_at), then optionally one NIP-40 expiration (≥ 30 days out) and one NIP-13 nonce, no other tags, fresh Nostr key per event |
Test vectors: tags, invalid events, reused pubkey |
| R2 | MUST | G1 | Content is only the armored message: no routing line, no armor headers, wildcard recipient key id | Test vectors: example event, invalid events |
| R3 | MUST | G2 | Messages are signed; signer = from, to = own fingerprint, ts = created_at |
Test vectors: receive cases |
| R4 | MUST | G2 | A msg/rekey counts only on the channel of the contact who signed it |
Test vectors: receive cases |
| R5 | MUST | G3 | Invites: fp = fingerprint of pubKey; a hello from a new key is only a contact request; nothing changes an existing contact's key or name |
Test vectors: invites; UI review |
| R6 | MUST | G2 | Replays dropped by inner id; a replay into another day fails the tag check |
Test vectors: receive cases |
| R7 | MUST | G1 | No v1 sent to a v2 contact; v1 reception marked unverified and never alters contacts | Code review |
| R8 | SHOULD | G1 | Inner payload padded to 256-byte multiples; separate publish/subscribe connections; Tor or proxy | Test vectors: example padding |
| R9 | MUST before leaving Draft | — | Schema, test vectors and a checker in CI; a second independent implementation | CI on this repo |
Security considerations
| Threat | Likelihood | Control |
|---|---|---|
| Relay or observer links messages to a recipient | High in v1 (certain) | v2: per-pair daily tags, wildcard recipient key id, no routing line |
| Relay links a sender's conversations | High in v1 (session key, shared connection) | v2: fresh key per event; per-contact secrets; separate connections RECOMMENDED. IP addresses still link a connection's traffic: use Tor |
| Same-day linkage: a relay groups one conversation's messages within a UTC day by their shared tag and learns their count, timing and sizes | Certain (by design) | It still learns neither party, and nothing across days or contacts. Padding (R8) hides exact sizes. Per-message tags would not fix this: a receiver must subscribe ahead of time to the tags it expects, and one subscription listing them together links them for that relay just as well. The receiver MAY spread subscriptions over relays and connections (Receiving) |
| Subscription linkage: a connection's REQ lists every tag a user reads (all their contacts' channels), and the same IP asks again the next day | High without Tor | It reveals how many conversations a user has, not with whom. Use Tor and separate connections per role; MAY split subscriptions per contact |
| Backdated or future-dated messages | Medium | created_at isn't trusted; the signed ts must equal it and fall within −30 days / +10 minutes (Times). A contact can still backdate inside the window; the shown time is the contact's own claim |
| Contact's key replaced | Medium | A new key is a new contact request, never an update (Receiving 7) |
| Channel secret leaks (device theft, a backup) | Low | Whoever holds it links that conversation on every day, past and future, though they can't read it. rekey limits future linkage. Secrets never appear on the wire in plain form |
| Someone poses as a contact | High in v1 (anyone holding your public key) | v2: OpenPGP signature, signer = from, tag bound to the contact (R3, R4) |
| Contact hijack via handshake | High in v1 | v2: contact requests need user approval; existing contacts' keys and names never change from the wire (R5) |
| Signed message forwarded to a third party ("surreptitious forwarding") | Medium | to inside the signed payload (R3) |
| Replay | Medium | Inner id dedupe; a tag binds the event to its day (R6) |
| Short-id collision (2³² work) | Medium | v2 never routes or identifies by short id |
| Spam on the intro channel | Medium | Only invite holders can derive the intro tag; issue invites per audience and retire spammed intro secrets. A hello is only a request |
| Message length reveals content | Low | Padding to 256 bytes (R8) |
| Time correlation (send and receive moments) | Medium | Not addressed; delayed sending is a client option |
| Key compromise exposes past messages | Medium | No forward secrecy (non-goal); same as NIP-44. Users can rotate PGP keys |
| Lost channel secrets | Medium | Contacts can't be read until a new hello/rekey; secrets are part of contact backups |
| Harvesting stored v1 history | Certain for past v1 traffic | Already public; v1 readers SHOULD stop once no v1 contacts remain |
| Harvesting stored v2 traffic later (if a channel secret leaks) | Low | Senders SHOULD set a NIP-40 expiration, so relays that honour it delete old events |
| Relays refuse events from fresh keys | High on filtered relays | Agree on accepting relays; NIP-13 proof of work; check OK before marking a message sent |
Implementations
Add yours with a pull request. Projects by this NKIP's authors are marked.
| Project | Role | Status |
|---|---|---|
| Ripley Terminal (authors' project) | Client | Speaks v1 as described here |
| Ghost Chat desktop (authors' project) | Client | Speaks v1 as described here |
Open questions
- Kind registration requested upstream in nostr-protocol/nips#2529; keep or replace the provisional numbers based on that review.
- Sunset date for v1 reading in clients.
- Should the intro channel require proof of work (NIP-13) to price spam? (v2 already allows it on every event.)
- Multi-device: share channel secrets across a user's devices, or one secret per device?
Test vectors
nkip-0006/vectors/v1.json, checked by python3 tools/nkip0006.py check
nkips/nkip-0006/vectors/v1.json (standard library only).
These are shape and protocol-decision tests, not end-to-end crypto tests. The checker parses OpenPGP armor and packet headers itself (fingerprints, the recipient key id, computed from the armored keys), but it never decrypts or verifies a signature: the receive cases give the signer as a fingerprint. The v2 example was decrypted and its signature verified once with an independent OpenPGP implementation (openpgp.js 6.3.1); implementations should repeat that with the test keys.
- Test keys (curve25519, v4) for alice, bob and carol, private keys included. Never use them.
- v1 observer: a v1 message to bob, in the deployed shape. With no key, the checker reads bob's short id from the tag and the routing line, and bob's encryption-subkey id from the PGP packet.
- v1 spoof: a message that decrypts with bob's key to
[FROM:<alice's short id>] …and carries no signature. - Channel tags: six cases, including both sides of a day boundary, plus one same-day case: one secret gives the same tag all day and a new tag the next day.
- v2 example: a real event from alice to bob. Its recipient key id is the wildcard, and it decrypts with bob's key to a 256-byte inner payload signed by alice.
- Events: ten invalid ones, each with the reason (including an expiration inside the 30-day window and a
ptag afterz), including an RFC 9580 v6 PKESK that names the recipient. Extra valid shapes: z followed by NIP-40 expiration and NIP-13 nonce tags, and a v6 PKESK with an anonymous recipient (placeholder ciphertext). Plus a pair that reuses a pubkey. - Invites: one valid, five invalid.
- Receive decisions: twenty-two cases. They include
tsas a string and as null (refused, not a crash), a newidwith the same body (a new message), a hello whose key isn't the signer's (the checker fingerprintsbody.pubKeyitself), a contact's new key claiming the contact's name (a new request), a known contact's hello (channel refresh only), and backdated and future-dated messages.
The schema nkip-0006/schema.json checks shape only (the event, the inner
payload, the hello body and the invite). Padding length, pubkey freshness,
the tag, fingerprints and times are checked by the tool.
Changelog
- 2026-10-27: Draft. v1 described as deployed, with its metadata leaks and its unauthenticated sender and handshake. v2 adds per-contact daily channel tags, signed and encrypted messages with hidden recipients, a fresh Nostr key per event, contact requests, and rules for coexisting with v1. NIP-17 is recommended for users with Nostr identities.
- 2026-10-27: Review round 1. G1 is narrowed to what daily tags give: a
conversation's messages on the same day can be linked to each other. Added
same-day and subscription linkage threats and an optional rule to spread
subscriptions. The signed
tsis now the only time, with a −30 d / +10 min window, sincecreated_atis chosen by whoever holds the throwaway key. A contact's new key is always a new contact request. The checker fingerprints hello keys itself. Vectors are stated to be shape/protocol tests, and the schema is marked shape-only. - 2026-10-27: Optional NIP-40 expiration and NIP-13 nonce tags after
z; relay acceptance of fresh keys; Implementations section. - 2026-10-27: Review round 2 (passed 8.3). Channel secrets are stated to be
long-lived (rekey limits a leak). The latest signed hello or rekey wins, and
the old secret is read for 2 more days. Replay protection is by
idonly. Hidden recipients defined for v6 PKESK (RFC 9580). Added a note on fixing v1 in place. The checker refuses malformed inputs instead of crashing.