NKIPs

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.

NKIP6
TitlePGP Chat over Nostr
StatusDraft
TypeStandards Track
Created2026-10-27
LicenseCC0-1.0
RequiresNIP-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:

  1. 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.
  2. 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.
  3. 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 is ghost-<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 no since limit, 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 (the v1_spoof vector decrypts to exactly that).
  • Hijack a contact. A handshake that carries a known contact's own public key with a new name and topic replaces 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>"}
  • fp MUST be the fingerprint of pubKey. Clients MUST compute it from the key and refuse the invite if it differs.
  • intro is a random 32-byte intro secret. Anyone holding the invite can send the inviter a hello on 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": true MAY 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.

  • ts MUST equal created_at (the sender sets both; created_at only picks the tag's day).
  • A receiver MUST drop a payload whose ts is 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:

  1. Sign each event with a new Nostr key, used for nothing else.
  2. Set created_at to the current time.
  3. Put the z tag first. After it the event MAY carry one NIP-40 ["expiration", "<unix time>"] tag, at least 30 days after created_at so receivers can still fetch it, and one NIP-13 ["nonce", "<n>", "<target>"] proof-of-work tag. No other tags (no p, t, e or client). Senders SHOULD set an expiration so relays can delete old traffic.
  4. Put only the ASCII-armored OpenPGP message in content: no routing line before it and no armor headers (Comment:, Version:).
  5. 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-keyids in 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:

  1. Check the event shape (Event 3–5) and find which secret produced the tag for this created_at; drop the event if none did.
  2. Decrypt it, and drop it unless it carries a valid OpenPGP signature.
  3. Require the signer's fingerprint to equal from, to to equal its own fingerprint, ts to equal created_at, and ts to fall within the window in Times.
  4. Drop the payload if its id was already seen (keep ids at least as long as the subscription window). Replay protection is by id only: a payload with a new id and the same body is a new message. This is idempotence, not content dedupe; a sender that retries reuses the id.
  5. For msg and rekey: require from to be the contact whose channel secret produced the tag. A message is shown as coming from that contact.
  6. For hello: require an intro tag, and compute the fingerprint of body.pubKey itself (never trust a fingerprint carried next to it). It MUST equal from. If from is not a contact, show a contact request with the fingerprint and the (unverified) name, and add nothing until the user accepts. If from is already a contact, a hello MAY 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 signed ts wins (ties: the higher id). The secret it replaces is retired: the receiver keeps reading it for 2 more days, for messages already in flight, then drops it.
  7. Key changes. A contact who moves to a new PGP key is, to the protocol, a new key: its hello is 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 hello was 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": true only 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 p tag after z), 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 ts as a string and as null (refused, not a crash), a new id with the same body (a new message), a hello whose key isn't the signer's (the checker fingerprints body.pubKey itself), 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 ts is now the only time, with a −30 d / +10 min window, since created_at is 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 id only. Hidden recipients defined for v6 PKESK (RFC 9580). Added a note on fixing v1 in place. The checker refuses malformed inputs instead of crashing.

Markdown source · History · Comment