Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ The format is inspired by Keep a Changelog, and this project adheres to semantic

## [Unreleased]

## [2.1.0] - 2026-08-30

### Added
- NIP-17 private direct messages, with the NIP-59 gift wrapping they build on. `Nip17DirectMessageService` composes a `ChatMessage` into one gift wrap per participant and reads incoming wraps back; `Nip59GiftWrapper` implements the generic three-layer envelope (unsigned kind-14 rumor, kind-13 seal signed by the real author, kind-1059 wrap signed by a single-use key) and is usable for any event kind, not just messages. Unlike NIP-04, which hides only the message text, this conceals the correspondents, the timing, and the message count.
- `Rumor`, the unsigned event NIP-59 wraps. It is deliberately not `ISignable` and holds no signature field, so the deniability the scheme depends on is enforced by the type system rather than by convention.
- `DirectMessageRelayList` (kind 10050) and `DirectMessageService.planDelivery`, which pairs each participant's copy with the relays that participant nominated. NIP-17 permits delivery only to those relays and forbids sending at all to someone who published no list; an unreachable recipient is reported explicitly rather than omitted, so a message cannot go half-delivered unnoticed.
- `GenericEvent.update(long createdAt)`, which recomputes an event's id without consulting the clock. The existing no-arg `update()` delegates to it, so no call site changes behaviour.
- [How to send private direct messages](docs/howto/private-direct-messages.md).

### Fixed
- `GenericEvent.getByteArraySupplier()` no longer resets `created_at` to the current time. It calls `update()`, and so ran during `Identity.sign()` — meaning **signing silently moved an event in time**. Any deliberately chosen timestamp was discarded moments after being set, which made NIP-59's randomised past timestamps impossible to produce and defeated the timing-correlation defence they exist to provide, while the calling code read as though the protection were present. An event that already carries a creation time now keeps it.

### Deprecated
- NIP-04 encrypted direct messages (`EncryptedDirectMessage`, `MessageCipher04`). They remain functional for reading existing conversations and interoperating with clients that send nothing else, but new code should use NIP-17. Nothing is removed in this release.

## [2.0.8] - 2026-08-22

### Fixed
Expand Down
3 changes: 3 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Quick links to the most relevant guides and references.

- [howto/use-nostr-java-api.md](howto/use-nostr-java-api.md) — Quick start: create, sign, and send events
- [howto/api-examples.md](howto/api-examples.md) — Comprehensive examples for common use cases
- [howto/private-direct-messages.md](howto/private-direct-messages.md) — Send and read NIP-17 private direct messages
- [howto/streaming-subscriptions.md](howto/streaming-subscriptions.md) — Long-lived subscriptions with NostrRelayClient
- [howto/custom-events.md](howto/custom-events.md) — Working with custom event kinds
- [howto/diagnostics.md](howto/diagnostics.md) — Inspecting relay failures and troubleshooting
Expand All @@ -30,6 +31,8 @@ Quick links to the most relevant guides and references.

- [explanation/extending-events.md](explanation/extending-events.md) — Working with events and tags (GenericEvent, GenericTag, Kinds)
- [explanation/architecture.md](explanation/architecture.md) — Module architecture and data flow
- [explanation/nostr-java-mcp-spec.md](explanation/nostr-java-mcp-spec.md) — Draft spec for the `nostr-java-mcp` MCP server module
- [explanation/nip-17-direct-messages-spec.md](explanation/nip-17-direct-messages-spec.md) — Draft spec for NIP-17 private direct messages and NIP-59 gift wrapping
- [explanation/dependency-alignment.md](explanation/dependency-alignment.md) — How versions are aligned via BOM

## Developer
Expand Down
472 changes: 472 additions & 0 deletions docs/explanation/nip-17-direct-messages-spec.md

Large diffs are not rendered by default.

585 changes: 585 additions & 0 deletions docs/explanation/nostr-java-mcp-spec.md

Large diffs are not rendered by default.

177 changes: 177 additions & 0 deletions docs/howto/private-direct-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
# Send Private Direct Messages

Navigation: [Docs index](../README.md) · [Getting started](../GETTING_STARTED.md) · [API how-to](use-nostr-java-api.md) · [Streaming subscriptions](streaming-subscriptions.md) · [Custom events](custom-events.md)

This guide shows how to send and read private direct messages with **nostr-java**, using
[NIP-17](https://github.com/nostr-protocol/nips/blob/master/17.md) gift-wrapped messaging.

## What NIP-17 hides

A NIP-04 direct message hides only its text. The sender, the recipient, the exact time, and
the number of messages exchanged are all public on every relay that carries the event, so an
observer learns who talks to whom and when.

NIP-17 hides all of it, using three layers defined by
[NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md):

| Layer | Kind | Signed by | What it reveals |
| --- | --- | --- | --- |
| Rumor | 14 | nobody | the message, once decrypted |
| Seal | 13 | the real sender | who wrote it, to the recipient only |
| Gift wrap | 1059 | a single-use key | that *someone* sent *something* to a recipient |

The SDK builds and opens all three. You work with a `ChatMessage`, and never handle a seal,
an ephemeral key, or a conversation key yourself.

## Prerequisites

```xml
<dependency>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java-identity</artifactId>
</dependency>
```

## Publish where you receive messages

Before anyone can message you, publish a kind-10050 list naming the relays you read. NIP-17
says a sender **must not** deliver to any other relay, and **must not** send at all to
someone who has published no list. Without this, nobody can reach you.

```java
Identity alice = Identity.create(privateKeyHex);

DirectMessageRelayList inbox = new DirectMessageRelayList(
alice.getPublicKey(),
List.of(new Relay("wss://inbox.nostr.wine")),
Instant.now().getEpochSecond());

GenericEvent inboxEvent = inbox.toEvent();
alice.sign(inboxEvent);
// publish inboxEvent through your relay client
```

Keep the list short, one to three relays, and publish it to as many relays as you can so
senders can find it.

## Send a message

```java
Nip17DirectMessageService messages = new Nip17DirectMessageService(alice);

ChatMessage message = messages.message()
.to(bobPublicKey)
.subject("Dinner")
.content("Are you going to the party tonight?")
.build();

List<GenericEvent> wraps = messages.compose(message);
```

`compose` returns **several events, not one**. There is no shared envelope in NIP-17: each
participant gets their own separately encrypted copy, which is what keeps the conversation's
membership private.

> **One of those copies is addressed to you.** A sender who publishes only their recipients'
> copies can never read the conversation back, because they cannot decrypt a wrap addressed
> to someone else. Publish every event `compose` returns, including your own.

## Send to the right relays

`compose` gives you events but not destinations. Use `planDelivery` to pair each copy with
the relays its recipient nominated:

```java
DirectMessageRelayLookup relayLists = pubkey -> lookUpKind10050For(pubkey);

for (MessageDelivery delivery : messages.planDelivery(message, relayLists)) {
if (delivery.isDeliverable()) {
publish(delivery.giftWrap(), delivery.relays());
} else {
log.info("{} is not accepting private messages", delivery.recipient());
}
}
```

An unreachable recipient still appears in the plan, carrying no event. That is deliberate:
omitting them silently is how a message goes half-delivered without anyone noticing, and it
distinguishes "this person does not accept private messages" from "the relay was down".

You supply the lookup, backed by a relay query or a cache. Message composition itself never
touches the network.

## Read your messages

Subscribe to kind 1059 events tagged with your public key, then open each one:

```java
Nip17DirectMessageService messages = new Nip17DirectMessageService(bob);

for (GenericEvent giftWrap : incomingEvents) {
try {
ChatMessage received = messages.read(giftWrap);
System.out.printf("%s: %s%n", received.getSender(), received.getContent());
} catch (GiftWrapException notForUs) {
// Expected: a kind-1059 subscription also delivers wraps we cannot open.
}
}
```

**Catch and continue.** A kind-1059 subscription delivers wraps addressed to other people,
and possibly malformed ones. Abandoning the batch on the first failure lets one unopenable
event stall an entire conversation.

The sender reported by `read` is authenticated: the seal's signature is verified and its
author is checked against the rumor's before the message is returned. Because a rumor is
unsigned, that seal signature is the only evidence of who wrote the message.

## Reply to a message

```java
ChatMessage reply = messages.message()
.to(received.getSender())
.inReplyTo(receivedEventId)
.content("Yes, see you at eight")
.build();
```

## Group conversations

Add more recipients. The participants define the conversation, so adding or removing one
starts a *different* conversation with its own history.

```java
ChatMessage groupMessage = messages.message()
.to(bobPublicKey)
.to(carolPublicKey)
.content("Dinner at eight?")
.build();
```

Every participant needs their own encrypted copy, so cost grows with group size. NIP-17
advises finding another scheme beyond about ten participants.

## Ephemeral messages

For real-time chat that relays should not store, wrap in kind 21059 instead:

```java
DirectMessageService liveChat = new Nip17DirectMessageService(
alice, new Nip59GiftWrapper(alice, Kinds.EPHEMERAL_GIFT_WRAP));
```

## What the SDK does not do

- **Publishing and subscribing.** These types produce and consume events; routing them is
the caller's job, using `NostrRelayClient`.
- **Storing messages.** There is no inbox. Decide what to keep.
- **NIP-42 AUTH.** Relays are advised to serve kind-1059 events only to their addressee,
behind authentication. Delivery from such relays needs AUTH support in your client.

## Related

- [NIP-17](https://github.com/nostr-protocol/nips/blob/master/17.md) — private direct messages
- [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) — gift wrap
- [NIP-44](https://github.com/nostr-protocol/nips/blob/master/44.md) — the encryption underneath
- [Streaming subscriptions](streaming-subscriptions.md) — receiving events as they arrive
- [NIP-17 implementation spec](../explanation/nip-17-direct-messages-spec.md) — design notes
2 changes: 1 addition & 1 deletion nostr-java-client/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>2.0.8</version>
<version>2.1.0</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
2 changes: 1 addition & 1 deletion nostr-java-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>2.0.8</version>
<version>2.1.0</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,19 @@
import java.util.Arrays;
import java.util.Base64;

/**
* Encrypts direct messages according to NIP-04.
*
* @deprecated NIP-04 conceals only a message's text. The sender, the recipient, the exact time,
* and the number of messages exchanged all remain public on every relay that carries the
* event, so an observer learns who talks to whom and when. Prefer NIP-17 private direct
* messages, which hide all of it: see {@code nostr.encryption.Nip17DirectMessageService}
* and the guide at {@code docs/howto/private-direct-messages.md}. Retained for reading
* existing conversations and for interoperating with clients that send nothing else.
* @see <a href="https://github.com/nostr-protocol/nips/blob/master/04.md">NIP-04</a>
* @see <a href="https://github.com/nostr-protocol/nips/blob/master/17.md">NIP-17</a>
*/
@Deprecated(since = "2.1.0")
public class EncryptedDirectMessage {

public static String encrypt(@NonNull String message, byte[] senderPrivKey, byte[] rcptPubKey)
Expand Down
2 changes: 1 addition & 1 deletion nostr-java-event/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>2.0.8</version>
<version>2.1.0</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
36 changes: 36 additions & 0 deletions nostr-java-event/src/main/java/nostr/base/Kinds.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,37 @@ private Kinds() {}
public static final int DELETION = 5;
public static final int REPOST = 6;
public static final int REACTION = 7;
/**
* Seal (NIP-59): wraps an encrypted rumor and is signed by its real author.
*
* @see <a href="https://github.com/nostr-protocol/nips/blob/master/59.md">NIP-59</a>
*/
public static final int SEAL = 13;
/**
* Chat message (NIP-17): the rumor kind carrying private direct message content.
*
* @see <a href="https://github.com/nostr-protocol/nips/blob/master/17.md">NIP-17</a>
*/
public static final int CHAT_MESSAGE = 14;
/**
* File message (NIP-17): a rumor kind carrying an encrypted file reference.
*
* @see <a href="https://github.com/nostr-protocol/nips/blob/master/17.md">NIP-17</a>
*/
public static final int FILE_MESSAGE = 15;
public static final int REACTION_TO_WEBSITE = 17;
public static final int CHANNEL_CREATE = 40;
public static final int CHANNEL_METADATA = 41;
public static final int CHANNEL_MESSAGE = 42;
public static final int HIDE_MESSAGE = 43;
public static final int MUTE_USER = 44;
public static final int OTS_EVENT = 1040;
/**
* Gift wrap (NIP-59): the outermost layer, signed by a single-use ephemeral key.
*
* @see <a href="https://github.com/nostr-protocol/nips/blob/master/59.md">NIP-59</a>
*/
public static final int GIFT_WRAP = 1059;
public static final int REPORT = 1984;
public static final int COINJOIN_POOL = 2022;
public static final int RESERVED_CASHU_WALLET_TOKENS = 7_374;
Expand All @@ -38,9 +62,21 @@ private Kinds() {}
public static final int REPLACEABLE_EVENT = 10_000;
public static final int PIN_LIST = 10_001;
public static final int RELAY_LIST_METADATA = 10_002;
/**
* Direct message relay list (NIP-17): the relays on which a user receives private messages.
*
* @see <a href="https://github.com/nostr-protocol/nips/blob/master/17.md">NIP-17</a>
*/
public static final int DM_RELAY_LIST = 10_050;
public static final int NUTZAP_INFORMATIONAL = 10_019;
public static final int WALLET = 17_375;
public static final int EPHEMERAL_EVENT = 20_000;
/**
* Ephemeral gift wrap (NIP-59): a gift wrap relays must not store.
*
* @see <a href="https://github.com/nostr-protocol/nips/blob/master/59.md">NIP-59</a>
*/
public static final int EPHEMERAL_GIFT_WRAP = 21_059;
public static final int CLIENT_AUTH = 22_242;
public static final int NOSTR_CONNECT = 24_133;
public static final int ADDRESSABLE_EVENT = 30_000;
Expand Down
Loading