```
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
```

# NKIP-0006: PGP Chat over Nostr

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.

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](https://github.com/nostr-protocol/nips/pull/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.
