From d6f7d9314b81bee70cf8364dd857c7b1adbf4190 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Sun, 30 Aug 2026 09:30:44 +0100 Subject: [PATCH 01/15] docs: draft spec for nostr-java-mcp module --- docs/README.md | 1 + docs/explanation/nostr-java-mcp-spec.md | 206 ++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 docs/explanation/nostr-java-mcp-spec.md diff --git a/docs/README.md b/docs/README.md index 71d98618..cdd0b8c6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -30,6 +30,7 @@ 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/dependency-alignment.md](explanation/dependency-alignment.md) — How versions are aligned via BOM ## Developer diff --git a/docs/explanation/nostr-java-mcp-spec.md b/docs/explanation/nostr-java-mcp-spec.md new file mode 100644 index 00000000..6814179f --- /dev/null +++ b/docs/explanation/nostr-java-mcp-spec.md @@ -0,0 +1,206 @@ +# nostr-java-mcp: Module Specification (Draft) + +This document specifies a new module, `nostr-java-mcp`, that exposes the nostr-java SDK +as a [Model Context Protocol](https://modelcontextprotocol.io) server so that LLM agents +can read from and write to Nostr relays using natural language. It is a design +explanation: it states the goals, the boundaries, the tool surface, and the open +decisions. It is not yet an implementation guide. + +## 1. Motivation + +Today an application must speak Java to use nostr-java: build an event, sign it with an +`Identity`, and publish it through `NostrRelayClient`. An LLM agent cannot do that +directly. MCP is the emerging standard for giving an agent typed, discoverable +capabilities over stdio or HTTP. Wrapping the SDK in an MCP server means any MCP client +(Claude Desktop, Claude Code, IDE agents, custom hosts) gets Nostr access with no +Nostr-specific code, and the SDK gains a natural-language front door without polluting +the existing modules. + +## 2. Goals and non-goals + +### Goals + +- Expose a small, deep tool surface covering the Nostr operations an agent actually needs: + publish, query, subscribe-and-collect, profile lookup, direct messages, and relay/key + introspection. +- Keep signing keys inside the server process. The agent names an identity; it never sees + or supplies a private key. +- Support both stdio (local desktop agents) and streamable HTTP (remote/hosted) transports. +- Be strictly additive: no changes required in `core`, `event`, `identity`, or `client`. +- Make every destructive or public-facing action (anything that writes to a relay) opt-in + and auditable. + +### Non-goals + +- No relay implementation. The module is a client only. +- No LLM inference. Natural-language understanding lives in the MCP host, not here. +- No persistent event store beyond an optional in-memory/embedded cache for subscriptions. +- No new NIP support. The module only surfaces what the SDK already implements; missing + NIPs are implemented in `nostr-java-event`, not here. + +## 3. Position in the module graph + +``` +nostr-java-core ──▶ nostr-java-event ──▶ nostr-java-identity ──▶ nostr-java-client + │ + ▼ + nostr-java-mcp +``` + +`nostr-java-mcp` is a leaf: it depends on `client` (and transitively on the rest) and +nothing depends on it. This satisfies the Stable Dependencies Principle — the volatile, +protocol-adapting component depends on the stable ones, never the reverse. + +It is packaged as an executable Spring Boot application **and** a library jar, so it can +be run standalone (`java -jar nostr-java-mcp.jar`) or embedded in a host application. + +## 4. Architecture + +Four layers, each with one reason to change: + +| Layer | Responsibility | Changes when | +| --- | --- | --- | +| **Transport** | MCP stdio / streamable HTTP wiring, JSON-RPC framing | The MCP spec changes | +| **Tool adapters** | Map MCP tool calls to domain commands; validate arguments; shape results | The tool surface changes | +| **Nostr services** | `PublishEventService`, `QueryEventService`, `ProfileService`, `DirectMessageService` — the domain operations | Nostr semantics change | +| **SDK facade** | Thin wrappers over `NostrRelayClient`, `Identity`, event factories | The SDK API changes | + +Tool adapters depend on service *interfaces*, not on `NostrRelayClient`, so the tool layer +is testable with in-memory fakes and no relay. + +### Key components + +- `NostrMcpServer` — bootstraps the transport and registers tools. +- `NostrToolRegistry` — the single place where tools are declared, one class per tool + implementing a common `NostrTool` interface (name, JSON schema, `execute`). New tools are + added by adding a class, not by editing a switch (Open/Closed). +- `RelayPool` — resolves logical relay names to URLs, owns connection lifecycle and reuse. +- `IdentityVault` — resolves a logical identity name (`"default"`, `"alice"`) to an + `Identity`. Private keys are loaded once at startup from configuration or an external + signer and never leave the vault. +- `WriteGuard` — the policy object consulted before any relay write (see §7). + +## 5. Tool surface (v1) + +Names are namespaced `nostr_*` so they read clearly in an agent's tool list. Every tool +returns structured JSON plus a short human-readable summary. + +| Tool | Purpose | Key arguments | +| --- | --- | --- | +| `nostr_publish_note` | Publish a kind-1 text note | `content`, `identity?`, `relays?`, `replyTo?`, `mentions?` | +| `nostr_publish_event` | Publish an arbitrary event (escape hatch) | `kind`, `content`, `tags?`, `identity?`, `relays?` | +| `nostr_query_events` | One-shot REQ, collect until EOSE | `authors?`, `kinds?`, `tags?`, `since?`, `until?`, `limit`, `relays?` | +| `nostr_fetch_thread` | Resolve a note and its replies (NIP-10) | `eventId`, `depth?` | +| `nostr_get_profile` | Fetch and decode kind-0 metadata | `pubkey` or `nip05`, `relays?` | +| `nostr_update_profile` | Publish kind-0 metadata | `fields`, `identity?` | +| `nostr_get_contacts` | Read a kind-3 contact list (NIP-02) | `pubkey?` | +| `nostr_send_direct_message` | Encrypted DM (NIP-17 preferred, NIP-04 legacy) | `recipient`, `content`, `identity?` | +| `nostr_read_direct_messages` | Decrypt DMs addressed to an identity | `identity?`, `since?`, `limit` | +| `nostr_list_identities` | Public keys the server can sign with | — | +| `nostr_list_relays` | Configured relays and connection state | — | +| `nostr_relay_info` | NIP-11 relay metadata | `relay` | + +### Resources and prompts + +- **Resources**: `nostr://identity/{name}` (public key, npub, configured relays) and + `nostr://relay/{name}` (NIP-11 document), so an agent can read context without a tool + call. +- **Prompts**: a small set of guided templates, e.g. `compose-note` and `catch-up-feed`, + that teach the host how to sequence the tools. + +### Argument conventions + +- Public keys accept hex or `npub`; event ids accept hex or `note`/`nevent`. Decoding is + centralised in one `NostrIdentifier` value type — the tools never parse bech32 inline. +- Timestamps accept ISO-8601 or relative expressions (`"24h"`, `"7d"`) and are normalised + to Unix seconds at the boundary. +- `relays` defaults to the configured write/read set; explicit relays override it. + +## 6. Configuration + +Spring Boot properties under `nostr.mcp.*`, overridable by environment variables: + +```yaml +nostr: + mcp: + transport: stdio # stdio | http + identities: + default: + private-key: ${NOSTR_PRIVATE_KEY} # nsec or hex; or signer: nip46 + relays: + read: [wss://relay.damus.io, wss://nos.lol] + write: [wss://relay.damus.io] + limits: + max-events-per-query: 500 + query-timeout: 15s + write-policy: confirm # deny | confirm | allow +``` + +Keys must never be logged. The startup banner prints public keys only. + +## 7. Safety model + +Publishing to Nostr is public and irreversible: an event, once accepted by a relay, cannot +be reliably deleted (NIP-09 is advisory). The module therefore treats every write as a +guarded action. + +- `write-policy: deny` — read-only server; write tools are not registered at all. +- `write-policy: confirm` (default) — write tools are registered but return a preview of + the signed event plus a `confirmationToken`; the agent must call again with the token. + This turns a hallucinated post into a no-op. +- `write-policy: allow` — writes proceed directly, for trusted automation. +- Rate limits per identity and per relay, enforced in `WriteGuard`. +- Every write is logged with event id, kind, identity pubkey, and target relays. +- DM decryption is opt-in per identity, since it exposes private correspondence to the + model. + +## 8. Error handling + +Errors are returned as MCP tool errors with a stable machine-readable `code` and a message +the model can act on, never as stack traces. Categories mirror the SDK's exception +hierarchy: `RELAY_UNREACHABLE`, `RELAY_REJECTED`, `INVALID_ARGUMENT`, `IDENTITY_UNKNOWN`, +`WRITE_FORBIDDEN`, `TIMEOUT`. Partial success on multi-relay publish is a success with a +per-relay result list, not an error. + +## 9. Testing strategy + +- **Unit**: each tool adapter against fake services — argument validation, bech32 decoding, + relative-time parsing, `WriteGuard` policy transitions. +- **Integration**: the full server over an in-process MCP client against a stub relay + (reusing the existing Docker relay harness), asserting round trips for publish, query, + and DM. +- **Contract**: every registered tool's JSON schema is validated, and a golden-file test + pins the tool list so accidental surface changes are visible in review. +- Run with `mvn -q verify` from the repository root as usual. + +## 10. Delivery plan + +1. Module skeleton, POM, BOM entries, stdio transport, `nostr_list_identities` and + `nostr_list_relays` — proves the wiring end to end. +2. Read path: `nostr_query_events`, `nostr_get_profile`, `nostr_relay_info`. +3. Write path behind `WriteGuard`: `nostr_publish_note`, `nostr_publish_event`, + `nostr_update_profile`. +4. Social layer: threads, contacts, direct messages. +5. HTTP transport, resources, prompts, and documentation (a how-to for wiring the server + into an MCP host). + +## 11. Open questions + +- Which Java MCP SDK: the official `io.modelcontextprotocol` SDK, or Spring AI's MCP server + starter? The latter fits the existing Spring dependency, the former has fewer transitive + dependencies. +- Should remote signing (NIP-46) be in v1, so the server never holds a private key at all? +- Does long-lived streaming (`subscribe` with push notifications) belong in v1, or is + query-until-EOSE enough? +- Should the module ship a Dockerfile and `docker-compose.yml`, per the repo's convention + for runnable components? +- Minimum viable NIP set for "natural language Nostr": is NIP-17 gift-wrapped DM support + present in the SDK today, or must NIP-04 be the v1 fallback? + +## Related documents + +- [architecture.md](architecture.md) — existing module architecture and data flow +- [../howto/streaming-subscriptions.md](../howto/streaming-subscriptions.md) — the + subscription mechanics the query tools build on +- [../operations/configuration.md](../operations/configuration.md) — configuration + conventions this module follows From 7c1af8e67925856f20955dc869245dd54556c0ba Mon Sep 17 00:00:00 2001 From: tcheeric Date: Sun, 30 Aug 2026 09:42:16 +0100 Subject: [PATCH 02/15] docs: resolve open questions in nostr-java-mcp spec Official MCP SDK, local encrypted keystore with NIP-46 deferred, long-lived streaming subscriptions, docker-compose packaging, and NIP-17 direct messages. --- docs/explanation/nostr-java-mcp-spec.md | 244 +++++++++++++++++++----- 1 file changed, 193 insertions(+), 51 deletions(-) diff --git a/docs/explanation/nostr-java-mcp-spec.md b/docs/explanation/nostr-java-mcp-spec.md index 6814179f..2a18143c 100644 --- a/docs/explanation/nostr-java-mcp-spec.md +++ b/docs/explanation/nostr-java-mcp-spec.md @@ -21,7 +21,7 @@ the existing modules. ### Goals - Expose a small, deep tool surface covering the Nostr operations an agent actually needs: - publish, query, subscribe-and-collect, profile lookup, direct messages, and relay/key + publish, query, long-lived subscriptions, profile lookup, direct messages, and relay/key introspection. - Keep signing keys inside the server process. The agent names an identity; it never sees or supplies a private key. @@ -34,11 +34,22 @@ the existing modules. - No relay implementation. The module is a client only. - No LLM inference. Natural-language understanding lives in the MCP host, not here. -- No persistent event store beyond an optional in-memory/embedded cache for subscriptions. -- No new NIP support. The module only surfaces what the SDK already implements; missing - NIPs are implemented in `nostr-java-event`, not here. +- No persistent event store. Live subscriptions buffer in memory only (§5.1). +- No new NIP support beyond NIP-17, which this module needs and the SDK lacks (§5.2). + Everything else missing is implemented in `nostr-java-event`, not here. +- No remote signing (NIP-46) in v1. Keys are held locally; see §6. -## 3. Position in the module graph +## 3. Resolved decisions + +| Decision | Choice | +| --- | --- | +| MCP library | Official SDK, `io.modelcontextprotocol.sdk:mcp` (2.x), plus its stdio and HTTP transports. Not Spring AI's starter. | +| Signing | Local `nsec`/hex keys in an `IdentityVault`, unlocked at startup. NIP-46 deferred but designed for. | +| Subscriptions | Long-lived streaming, surfaced as MCP resources with change notifications. | +| Packaging | Executable jar plus `Dockerfile` and `docker-compose.yml`. | +| Direct messages | NIP-17 gift-wrapped DMs. Requires NIP-17 support to be added to `nostr-java-event` first. | + +## 4. Position in the module graph ``` nostr-java-core ──▶ nostr-java-event ──▶ nostr-java-identity ──▶ nostr-java-client @@ -54,20 +65,30 @@ protocol-adapting component depends on the stable ones, never the reverse. It is packaged as an executable Spring Boot application **and** a library jar, so it can be run standalone (`java -jar nostr-java-mcp.jar`) or embedded in a host application. -## 4. Architecture +## 5. Architecture Four layers, each with one reason to change: | Layer | Responsibility | Changes when | | --- | --- | --- | -| **Transport** | MCP stdio / streamable HTTP wiring, JSON-RPC framing | The MCP spec changes | +| **Transport** | Official MCP SDK server (`McpServer`) over `StdioServerTransportProvider` or `HttpServletStreamableServerTransportProvider`; JSON-RPC framing | The MCP spec changes | | **Tool adapters** | Map MCP tool calls to domain commands; validate arguments; shape results | The tool surface changes | -| **Nostr services** | `PublishEventService`, `QueryEventService`, `ProfileService`, `DirectMessageService` — the domain operations | Nostr semantics change | +| **Nostr services** | `PublishEventService`, `QueryEventService`, `SubscriptionService`, `ProfileService`, `DirectMessageService` | Nostr semantics change | | **SDK facade** | Thin wrappers over `NostrRelayClient`, `Identity`, event factories | The SDK API changes | Tool adapters depend on service *interfaces*, not on `NostrRelayClient`, so the tool layer is testable with in-memory fakes and no relay. +### MCP library choice + +The official SDK (`io.modelcontextprotocol.sdk:mcp` 2.x) is used directly rather than Spring +AI's `spring-ai-starter-mcp-server`. The official SDK is the reference implementation of the +spec, tracks it first, and pulls in only Jackson plus a transport. Spring AI's starter would +drag a large AI-framework dependency tree into an SDK whose only concern is Nostr. The +module still uses Spring Boot for configuration and lifecycle, because `nostr-java-client` +already does, and registers the MCP server as a bean rather than via an autoconfiguration we +do not control. + ### Key components - `NostrMcpServer` — bootstraps the transport and registers tools. @@ -76,11 +97,11 @@ is testable with in-memory fakes and no relay. added by adding a class, not by editing a switch (Open/Closed). - `RelayPool` — resolves logical relay names to URLs, owns connection lifecycle and reuse. - `IdentityVault` — resolves a logical identity name (`"default"`, `"alice"`) to an - `Identity`. Private keys are loaded once at startup from configuration or an external - signer and never leave the vault. -- `WriteGuard` — the policy object consulted before any relay write (see §7). + `Identity`. Private keys are loaded once at startup and never leave the vault (§7.1). +- `SubscriptionRegistry` — owns live subscriptions and their bounded buffers (§6.1). +- `WriteGuard` — the policy object consulted before any relay write (see §8). -## 5. Tool surface (v1) +## 6. Tool surface (v1) Names are namespaced `nostr_*` so they read clearly in an agent's tool list. Every tool returns structured JSON plus a short human-readable summary. @@ -90,25 +111,72 @@ returns structured JSON plus a short human-readable summary. | `nostr_publish_note` | Publish a kind-1 text note | `content`, `identity?`, `relays?`, `replyTo?`, `mentions?` | | `nostr_publish_event` | Publish an arbitrary event (escape hatch) | `kind`, `content`, `tags?`, `identity?`, `relays?` | | `nostr_query_events` | One-shot REQ, collect until EOSE | `authors?`, `kinds?`, `tags?`, `since?`, `until?`, `limit`, `relays?` | +| `nostr_subscribe` | Open a long-lived subscription (§6.1) | `filters`, `relays?`, `name?`, `bufferSize?` | +| `nostr_read_subscription` | Drain buffered events from a subscription | `subscriptionId`, `max?` | +| `nostr_list_subscriptions` | Live subscriptions, filters, buffer depth, drop count | — | +| `nostr_unsubscribe` | Close a subscription and free its buffer | `subscriptionId` | | `nostr_fetch_thread` | Resolve a note and its replies (NIP-10) | `eventId`, `depth?` | | `nostr_get_profile` | Fetch and decode kind-0 metadata | `pubkey` or `nip05`, `relays?` | | `nostr_update_profile` | Publish kind-0 metadata | `fields`, `identity?` | | `nostr_get_contacts` | Read a kind-3 contact list (NIP-02) | `pubkey?` | -| `nostr_send_direct_message` | Encrypted DM (NIP-17 preferred, NIP-04 legacy) | `recipient`, `content`, `identity?` | -| `nostr_read_direct_messages` | Decrypt DMs addressed to an identity | `identity?`, `since?`, `limit` | +| `nostr_send_direct_message` | Gift-wrapped encrypted DM (NIP-17) | `recipient`, `content`, `identity?`, `subject?` | +| `nostr_read_direct_messages` | Unwrap and decrypt DMs addressed to an identity | `identity?`, `since?`, `limit` | | `nostr_list_identities` | Public keys the server can sign with | — | | `nostr_list_relays` | Configured relays and connection state | — | | `nostr_relay_info` | NIP-11 relay metadata | `relay` | -### Resources and prompts - -- **Resources**: `nostr://identity/{name}` (public key, npub, configured relays) and - `nostr://relay/{name}` (NIP-11 document), so an agent can read context without a tool - call. -- **Prompts**: a small set of guided templates, e.g. `compose-note` and `catch-up-feed`, - that teach the host how to sequence the tools. - -### Argument conventions +### 6.1 Long-lived subscriptions + +Query-until-EOSE is not enough: an agent asked to "watch my mentions" needs events that +arrive after the call returns. MCP has no server-push-into-a-tool-result mechanism, so +subscriptions are modelled as **stateful server resources**: + +- `nostr_subscribe` opens a REQ through `NostrRelayClient` (which already supports + long-lived subscriptions, see the streaming-subscriptions how-to) and returns a + `subscriptionId`. +- Incoming events land in a **bounded ring buffer** per subscription (default 500 events). + When it overflows the oldest events are dropped and a monotonic `droppedCount` is + incremented, so the agent is told it missed data rather than silently losing it. Nothing + is persisted; a restart drops all subscriptions. +- Each subscription is also exposed as an MCP resource, `nostr://subscription/{id}`, and the + server emits `notifications/resources/updated` when new events arrive. A host that + supports resource subscriptions gets push; one that does not can poll + `nostr_read_subscription`. +- `nostr_read_subscription` **drains** what it returns, so repeated calls yield only new + events. This is a command that also answers, which we accept deliberately here because + an at-most-once read is what keeps an agent's context from filling with duplicates. +- Subscriptions have a configurable idle TTL (default 1 hour) after which they are closed + and reaped, so an abandoned agent session cannot leak relay connections. Total live + subscriptions are capped. +- Reconnection is delegated to `NostrRelayClient`'s retry logic; on reconnect the REQ is + replayed with `since` set to the last received event's timestamp. + +### 6.2 Direct messages and NIP-17 + +The SDK today ships NIP-04 (`EncryptedDirectMessage`) and the NIP-44 v2 primitives +(`EncryptedPayloads`, including `getConversationKey`), but **not** NIP-17. NIP-17 needs a +kind-14 chat rumor, sealed in a kind-13 with NIP-44, then gift-wrapped in a kind-1059 signed +by a fresh throwaway key with a randomised `created_at`. + +NIP-04 leaks metadata (both pubkeys and the conversation are visible to every relay) and is +unsuitable as the DM story for a tool an agent drives on a user's behalf. So v1 ships NIP-17 +only, and NIP-04 is not exposed at all. + +The NIP-17 seal/gift-wrap logic is **not** implemented in this module. It belongs in +`nostr-java-event` (and the NIP-44 layer it builds on in `nostr-java-identity`), where every +consumer of the SDK benefits. This makes NIP-17 support a **hard prerequisite** of the DM +phase of the delivery plan, tracked as its own work item against those modules. + + +### 6.3 Resources and prompts + +- **Resources**: `nostr://identity/{alias}` (public key, npub, configured relays), + `nostr://relay/{name}` (NIP-11 document), and `nostr://subscription/{id}` (buffered + events, updated by notification), so an agent can read context without a tool call. +- **Prompts**: a small set of guided templates, e.g. `compose-note`, `catch-up-feed`, and + `watch-mentions`, that teach the host how to sequence the tools. + +### 6.4 Argument conventions - Public keys accept hex or `npub`; event ids accept hex or `note`/`nevent`. Decoding is centralised in one `NostrIdentifier` value type — the tools never parse bech32 inline. @@ -116,7 +184,7 @@ returns structured JSON plus a short human-readable summary. to Unix seconds at the boundary. - `relays` defaults to the configured write/read set; explicit relays override it. -## 6. Configuration +## 7. Configuration Spring Boot properties under `nostr.mcp.*`, overridable by environment variables: @@ -124,12 +192,21 @@ Spring Boot properties under `nostr.mcp.*`, overridable by environment variables nostr: mcp: transport: stdio # stdio | http + keystore: + type: encrypted-file # env | encrypted-file | os-keychain + path: ${HOME}/.nostr-java/keys.jceks identities: default: - private-key: ${NOSTR_PRIVATE_KEY} # nsec or hex; or signer: nip46 + alias: personal # entry in the keystore; never the key itself + relays: + write: [wss://relay.damus.io] relays: read: [wss://relay.damus.io, wss://nos.lol] write: [wss://relay.damus.io] + subscriptions: + max-live: 16 + buffer-size: 500 + idle-ttl: 1h limits: max-events-per-query: 500 query-timeout: 15s @@ -138,7 +215,61 @@ nostr: Keys must never be logged. The startup banner prints public keys only. -## 7. Safety model +### 7.1 Key storage + +Keys are held locally, which makes *where* and *how* the central security decision of this +module. Three backends behind one `KeySource` interface, chosen by `keystore.type`, so a +later NIP-46 remote signer is a fourth implementation and not a redesign: + +**A. `env` — environment variables (dev only).** +`NOSTR_MCP_IDENTITY_DEFAULT_NSEC` and friends. Zero setup, works in a container, matches +how the SDK's tests already pass keys. But the key sits in the process environment where +any child process, `/proc`, and most crash reporters can read it. Acceptable for a throwaway +test identity; the server logs a warning at startup when this backend is active. + +**B. `encrypted-file` — a password-protected keystore (default, recommended).** +A JCEKS/PKCS#12 file at `~/.nostr-java/keys.jceks`, one entry per identity alias, the file +encrypted with a passphrase supplied at startup (prompt for stdio, `NOSTR_MCP_KEYSTORE_PASSPHRASE` +for headless). Decrypted keys live only inside `IdentityVault`, held as `byte[]`/`char[]` +that are zeroed on shutdown rather than as `String`, since a `String` cannot be wiped and +may be interned. File permissions are checked at startup and the server refuses to start on +a world-readable keystore. This is a familiar, portable, dependency-free mechanism and it +survives a container restart via a mounted volume. + +**C. `os-keychain` — delegate to the platform.** +macOS Keychain, Windows DPAPI, or Secret Service / `libsecret` on Linux, reached through a +small adapter. Best available protection on a developer desktop and no passphrase to manage, +but it is platform-specific, awkward in containers, and needs a native dependency. Offered +as an option, not the default. + +Regardless of backend: + +- A `nostr_list_identities` result exposes aliases and public keys only. No tool, resource, + or error message can ever return a private key; a unit test asserts this over the whole + tool surface. +- `Identity` objects are never handed to the tool layer. Tools pass an alias to a signing + service, which returns a signed event. Signing is the only capability that crosses the + vault boundary. +- A **generate** path (`nostr-mcp keygen `) creates a key inside the keystore so a + user never has to paste an `nsec` into a shell. +- Import accepts `nsec` or hex once, at rest it is always encrypted. + +**Deferred: NIP-46 remote signing.** The strongest answer is for the server to hold no key +at all and delegate signing to a bunker (Amber, nsec.app). It is out of scope for v1 because +the SDK has no NIP-46 support, but `KeySource`/signing-service seam above exists precisely so +adding `type: nip46` later touches one class. + +### 7.2 Packaging + +Ships as an executable Spring Boot jar, a `Dockerfile` (distroless JRE 21 base, non-root +user), and a `docker-compose.yml` that runs the server in HTTP transport mode alongside the +existing test relay container, with the keystore mounted read-only as a volume and the +passphrase supplied as a secret. Per repo convention the compose file is verified with +`docker-compose build` in CI. The stdio transport is documented as a bare `java -jar` +invocation, since an MCP host launches the process itself and containerising stdio adds +little. + +## 8. Safety model Publishing to Nostr is public and irreversible: an event, once accepted by a relay, cannot be reliably deleted (NIP-09 is advisory). The module therefore treats every write as a @@ -152,50 +283,61 @@ guarded action. - Rate limits per identity and per relay, enforced in `WriteGuard`. - Every write is logged with event id, kind, identity pubkey, and target relays. - DM decryption is opt-in per identity, since it exposes private correspondence to the - model. + model. NIP-17's gift wrapping means the relay cannot see the correspondents, but the MCP + host can, so this stays an explicit per-identity grant. -## 8. Error handling +## 9. Error handling Errors are returned as MCP tool errors with a stable machine-readable `code` and a message the model can act on, never as stack traces. Categories mirror the SDK's exception hierarchy: `RELAY_UNREACHABLE`, `RELAY_REJECTED`, `INVALID_ARGUMENT`, `IDENTITY_UNKNOWN`, -`WRITE_FORBIDDEN`, `TIMEOUT`. Partial success on multi-relay publish is a success with a -per-relay result list, not an error. +`WRITE_FORBIDDEN`, `TIMEOUT`, `SUBSCRIPTION_UNKNOWN`, `SUBSCRIPTION_LIMIT_REACHED`, +`KEYSTORE_LOCKED`. Partial success on multi-relay publish is a success with a per-relay +result list, not an error. -## 9. Testing strategy +## 10. Testing strategy - **Unit**: each tool adapter against fake services — argument validation, bech32 decoding, - relative-time parsing, `WriteGuard` policy transitions. + relative-time parsing, `WriteGuard` policy transitions, ring-buffer overflow and + `droppedCount`, subscription TTL reaping. +- **Security**: a test that walks every registered tool and resource and asserts no response + or error message can contain a private key; keystore permission and passphrase-failure + paths. - **Integration**: the full server over an in-process MCP client against a stub relay (reusing the existing Docker relay harness), asserting round trips for publish, query, - and DM. + a live subscription receiving an event published mid-test, and a NIP-17 DM round trip. - **Contract**: every registered tool's JSON schema is validated, and a golden-file test pins the tool list so accidental surface changes are visible in review. +- **Packaging**: `docker-compose build` runs in CI. - Run with `mvn -q verify` from the repository root as usual. -## 10. Delivery plan +## 11. Delivery plan -1. Module skeleton, POM, BOM entries, stdio transport, `nostr_list_identities` and +0. **Prerequisite, in `nostr-java-event`/`nostr-java-identity`**: NIP-17 support — kind-14 + rumor, kind-13 seal, kind-1059 gift wrap over the existing NIP-44 primitives. Tracked + separately; blocks phase 5 only, so the rest can proceed in parallel. +1. Module skeleton, POM, BOM entry, official MCP SDK on stdio, `IdentityVault` with the + `encrypted-file` keystore and `keygen`, plus `nostr_list_identities` and `nostr_list_relays` — proves the wiring end to end. 2. Read path: `nostr_query_events`, `nostr_get_profile`, `nostr_relay_info`. 3. Write path behind `WriteGuard`: `nostr_publish_note`, `nostr_publish_event`, `nostr_update_profile`. -4. Social layer: threads, contacts, direct messages. -5. HTTP transport, resources, prompts, and documentation (a how-to for wiring the server - into an MCP host). - -## 11. Open questions - -- Which Java MCP SDK: the official `io.modelcontextprotocol` SDK, or Spring AI's MCP server - starter? The latter fits the existing Spring dependency, the former has fewer transitive - dependencies. -- Should remote signing (NIP-46) be in v1, so the server never holds a private key at all? -- Does long-lived streaming (`subscribe` with push notifications) belong in v1, or is - query-until-EOSE enough? -- Should the module ship a Dockerfile and `docker-compose.yml`, per the repo's convention - for runnable components? -- Minimum viable NIP set for "natural language Nostr": is NIP-17 gift-wrapped DM support - present in the SDK today, or must NIP-04 be the v1 fallback? +4. Subscriptions: `SubscriptionRegistry`, the four subscription tools, resource + notifications, TTL reaping. +5. Social layer: threads, contacts, NIP-17 direct messages. +6. HTTP transport, `Dockerfile` and `docker-compose.yml`, prompts, and documentation (a + how-to for wiring the server into an MCP host). + +## 12. Open questions + +- Which keystore backend is the default on a fresh install: prompt-for-passphrase + (`encrypted-file`) is safest but blocks unattended startup. Is a passphrase-less + `os-keychain` default better for desktop users? +- Should `write-policy: confirm` tokens expire, and after how long? +- Does the HTTP transport need authentication of its own (bearer token) in v1, or is it + documented as bind-to-localhost only? +- Does NIP-17 support land as a contribution to this repo, or is it already planned + upstream in the BOM's event module? ## Related documents From 791b2def6e9ff6e8fd8676c0d39185dfe8e55c89 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Sun, 30 Aug 2026 09:47:14 +0100 Subject: [PATCH 03/15] docs: draft spec for NIP-17 private direct messages --- docs/README.md | 1 + .../nip-17-direct-messages-spec.md | 303 ++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 docs/explanation/nip-17-direct-messages-spec.md diff --git a/docs/README.md b/docs/README.md index cdd0b8c6..f0d415ba 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,6 +31,7 @@ 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 diff --git a/docs/explanation/nip-17-direct-messages-spec.md b/docs/explanation/nip-17-direct-messages-spec.md new file mode 100644 index 00000000..20637571 --- /dev/null +++ b/docs/explanation/nip-17-direct-messages-spec.md @@ -0,0 +1,303 @@ +# NIP-17 Private Direct Messages: Implementation Specification (Draft) + +This document specifies how to add [NIP-17](https://github.com/nostr-protocol/nips/blob/master/17.md) +private direct messages, and the [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) +gift-wrap machinery it stands on, to nostr-java. It is a design explanation: it states what +exists today, what must be built, where each piece belongs, and the constraints the current +codebase imposes. It is a prerequisite for the direct-message tools in the +[nostr-java-mcp spec](nostr-java-mcp-spec.md), but it stands on its own: gift wrapping is +useful to every consumer of the SDK. + +## 1. Motivation + +The SDK's only direct-message support is NIP-04 (`EncryptedDirectMessage`, kind 4). NIP-04 +is deprecated in practice and leaks metadata badly: the sender pubkey, the recipient `p` +tag, the exact timestamp, and the message count are all public on every relay that carries +the event. An observer learns who talks to whom and when, and only the message text is +hidden. + +NIP-17 fixes this by triple-layering. An **unsigned rumor** carries the content, a +**kind-13 seal** signed by the real sender hides it from everyone but the recipient, and a +**kind-1059 gift wrap** signed by a fresh throwaway key hides the sender. With randomised +timestamps on the outer two layers, a relay sees only "some random key sent something to +this pubkey at roughly this time". + +Building it means the SDK can offer private messaging that is actually private, and gains a +reusable gift-wrap primitive that NIP-59 explicitly intends other protocols to build on. + +## 2. What already exists + +| Piece | Status | Location | +| --- | --- | --- | +| NIP-44 v2 encrypt/decrypt | Present | `nostr.crypto.nip44.EncryptedPayloads` (core) | +| NIP-44 conversation key (ECDH + HKDF) | Present | `EncryptedPayloads.getConversationKey` | +| NIP-44 cipher facade | Present | `nostr.encryption.MessageCipher44` (identity) | +| Schnorr signing, key generation | Present | `Schnorr`, `Identity`, `PrivateKey.generateRandomPrivKey` | +| Event id computation over an unsigned event | Present | `EventSerializer.computeEventId` | +| Event JSON encode/decode | Present | `BaseEventEncoder`, `EventJsonMapper` | +| Kind constants | Partial | `Kinds` has `ENCRYPTED_DIRECT_MESSAGE = 4`; 13/14/15/1059/10050 missing | +| Rumor (unsigned event) as a first-class concept | **Missing** | — | +| Seal, gift wrap, unwrapping | **Missing** | — | +| DM relay list (kind 10050) | **Missing** | — | + +The cryptography is done. What is missing is the event-composition layer above it. + +## 3. Constraints in the current codebase + +Three facts about the existing code shape the design, and each needs an explicit decision. + +### 3.1 `GenericEvent.update()` forces `created_at` to now + +```java +public void update() { + this.createdAt = Instant.now().getEpochSecond(); + ... +} +``` + +NIP-59 requires the seal and gift wrap to carry a **randomised** `created_at`, up to two +days in the past, precisely to defeat timing correlation. The current `update()` makes that +impossible: any caller that computes an id also overwrites the timestamp. + +**Resolution.** Split the two responsibilities. `update()` keeps its current behaviour for +backwards compatibility but delegates to a new `update(long createdAt)` that does not touch +the clock. This is additive, changes no existing call site's behaviour, and gives the +gift-wrap code the seam it needs. The alternative, a `Clock`/`Supplier` injected into +`GenericEvent`, is a larger change to a widely-used class for no extra benefit here. + +### 3.2 `MessageCipher44` takes raw key bytes and prefixes `02` + +```java +EncryptedPayloads.getConversationKey( + NostrUtil.bytesToHex(senderPrivateKey), "02" + NostrUtil.bytesToHex(recipientPublicKey)); +``` + +Usable as-is. NIP-17 needs the same conversation-key derivation with two different key +pairs per message (sender↔recipient for the seal, ephemeral↔recipient for the wrap), which +is just two `MessageCipher44` instances. No change required, but the gift-wrap code must +construct ciphers rather than assume one conversation. + +### 3.3 A rumor is an event that must never be signed + +`GenericEvent` implements `ISignable` and `validate()` requires a signature. A rumor has an +`id` and no `sig`, and encoding it must emit `"sig"` absent (or empty) without the encoder +rejecting it. + +**Resolution.** Model the rumor as its own type rather than as a `GenericEvent` in a +half-built state (see §4.1). Making illegal states unrepresentable is worth more here than +reusing the class, because the whole security property of NIP-59 rests on a rumor never +acquiring a signature. + +## 4. Design + +All new code lands in `nostr-java-event` and `nostr-java-identity`. Nothing in `core` +changes; nothing in `client` changes. + +### 4.1 New types in `nostr-java-event` + +**`Rumor`** — an unsigned event. Holds `pubkey`, `createdAt`, `kind`, `tags`, `content`, and +a derived `id`. It is deliberately **not** an `ISignable` and exposes no signature field, so +a rumor cannot be signed by accident. It serialises to the same JSON shape as an event with +`"sig": ""`. + +**`Kinds` additions**: + +```java +public static final int SEAL = 13; +public static final int CHAT_MESSAGE = 14; +public static final int FILE_MESSAGE = 15; +public static final int GIFT_WRAP = 1059; +public static final int EPHEMERAL_GIFT_WRAP = 21_059; +public static final int DM_RELAY_LIST = 10_050; +``` + +**`ChatMessage`** — the kind-14 rumor: content, recipients, optional `subject`, optional `e` +tag for a reply parent. Built through a builder; it is the only type an application author +should need to touch for the common case. + +**`DirectMessageRelayList`** — the kind-10050 event, a list of `relay` tags. Needed because +NIP-17 states clients MUST only publish DMs to relays in the recipient's kind-10050 list, +and MUST NOT send at all when no list is found. Publishing a DM to an arbitrary relay is a +protocol violation, so this is not optional. + +### 4.2 New capability in `nostr-java-identity`: `GiftWrapper` + +Signing lives in `identity`, and gift wrapping is fundamentally a signing operation with +two keys, so the wrap/unwrap logic belongs there alongside `MessageCipher`. + +```java +public interface GiftWrapper { + GenericEvent wrap(Rumor rumor, PublicKey recipient); + Rumor unwrap(GenericEvent giftWrap); +} +``` + +Two methods, one responsibility: hide a rumor, and reveal it. Everything in §5 is an +implementation detail behind this interface, which is what makes it a deep module — a large +amount of protocol behaviour behind a surface an application author can hold in their head. + +`Nip59GiftWrapper` is the implementation, constructed with the sender's `Identity`. A second +implementation for `kind:21059` ephemeral wraps differs only in the outer kind, so the wrap +kind is a constructor parameter rather than a flag argument on the method. + +### 4.3 The DM service + +`DirectMessageService`, also in `identity`, is the NIP-17-specific layer above the +NIP-59-generic `GiftWrapper`: + +```java +List compose(ChatMessage message); // one gift wrap per recipient, plus self +ChatMessage read(GenericEvent giftWrap); // unwrap, verify, decode +``` + +`compose` returning a **list** is not incidental: NIP-17 requires a separate gift wrap per +recipient *and* one addressed to the sender, since the sender cannot otherwise read their +own history. Making that plurality visible in the signature stops callers from publishing +one event and silently losing their outbox. + +## 5. The algorithm + +### Sending + +1. Build the kind-14 rumor: real sender pubkey, real `created_at`, `p` tag per recipient, + optional `subject` and `e` tags. Compute its id. **Do not sign it.** +2. For each recipient, and once more for the sender's own pubkey: + 1. Encrypt the JSON rumor with NIP-44 under `conversationKey(senderPrivkey, recipientPubkey)`. + 2. Put the ciphertext in a kind-13 seal with **empty tags**, `created_at` randomised up + to two days in the past, and sign it with the sender's key. + 3. Generate a **fresh** random keypair, used for exactly one wrap and then discarded. + 4. Encrypt the JSON seal under `conversationKey(ephemeralPrivkey, recipientPubkey)`. + 5. Put that in a kind-1059 gift wrap with a single `p` tag for the recipient, an + **independently** randomised past `created_at`, and sign it with the ephemeral key. +3. Fetch each recipient's kind-10050 list and publish their wrap only to those relays. If a + recipient has no list, do not send; report it. + +### Receiving + +1. Subscribe to kind 1059 with `#p` = your pubkey. +2. NIP-44 decrypt `content` with `conversationKey(yourPrivkey, giftWrap.pubkey)`. +3. Parse the seal. **Verify its signature.** +4. NIP-44 decrypt the seal's content with `conversationKey(yourPrivkey, seal.pubkey)`. +5. Parse the rumor and **verify `rumor.pubkey == seal.pubkey`.** +6. Recompute the rumor's id and check it matches. + +### Security rules, stated as invariants + +These are the parts a naive implementation gets wrong, so each becomes a named check with +its own test: + +- **The impersonation check.** A rumor whose `pubkey` differs from the sealing key's is + rejected. NIP-17 calls this out explicitly: without it, anyone can forge a message from + anyone by changing one field. The rumor is unsigned, so the seal's signature is the *only* + thing binding content to author. +- **The seal signature is verified**, not assumed. An unverified seal is an unauthenticated + message. +- **Ephemeral keys are single-use** and never persisted, logged, or reused across + recipients. Reuse links wraps together and undoes the whole scheme. +- **Seal tags are always empty.** Any tag on a kind-13 leaks to an observer who can see the + seal. +- **Timestamps are independently randomised** per layer, and never in the future, since many + relays drop future-dated events. +- **A rumor is never signed.** Enforced by the type system (§4.1), not by discipline. +- **Failure to decrypt is not an error condition.** A subscription to kind 1059 receives + wraps addressed to you but also, potentially, garbage. Undecryptable wraps are skipped, + not thrown, or one malformed event stops a whole inbox from loading. + +## 6. Public API sketch + +```java +Identity alice = Identity.create(alicePrivateKey); +DirectMessageService messages = new Nip17DirectMessageService(alice); + +ChatMessage message = ChatMessage.builder() + .to(bobPublicKey) + .subject("Dinner") + .content("Are you going to the party tonight?") + .build(); + +// One wrap for Bob, one for Alice's own copy. +List wraps = messages.compose(message); + +// Read side. +ChatMessage received = messages.read(incomingGiftWrap); +``` + +The application author never sees a seal, an ephemeral key, or a conversation key. That is +the point. + +## 7. Scope + +**In scope for this work:** kind-14 chat messages, NIP-59 seal and gift wrap (kinds 13, +1059, 21059), unwrapping with full verification, kind-10050 DM relay lists, and the +`Rumor` type. + +**Out of scope, deliberately:** kind-15 file messages (needs AES-GCM file encryption and an +upload story, and is a separate NIP-96 shaped problem); disappearing messages via +`expiration` tags; NIP-42 AUTH, which relays require to serve gift wraps and which the +client module must add separately; group chats beyond a handful of recipients, which NIP-17 +itself says to avoid. Kind-15 and expiration are noted here because the `Rumor` and +`GiftWrapper` types must not preclude them: any rumor kind is wrappable, which they already +allow. + +NIP-04 is left in place, deprecated in Javadoc, and not removed. Removing it is a breaking +change and a separate decision. + +## 8. Testing strategy + +- **Vectors from the NIPs.** NIP-59 §"An Example" gives an author key, recipient key, + ephemeral key, and the exact resulting seal and gift wrap. With the ephemeral key and both + timestamps injected, our output must match byte for byte. NIP-17 supplies a second pair of + wraps. These are the highest-value tests available and they pin the implementation to the + spec rather than to our reading of it. +- **Round trip**: `unwrap(wrap(rumor)) == rumor`, over generated content including Unicode, + empty strings, and the NIP-44 maximum plaintext size. +- **The impersonation attack**: hand-build a wrap whose rumor pubkey differs from the seal + pubkey and assert it is rejected. This test is the reason the check exists, so it must + fail loudly if the check is ever removed. +- **Adversarial inputs**: a wrap encrypted to someone else, a corrupted ciphertext, a + kind-13 with a bad signature, a kind-13 carrying tags, a rumor with a mismatched id, and a + future-dated wrap. Each has a defined outcome. +- **Non-determinism**: two wraps of the same rumor produce different ephemeral pubkeys, + different ciphertexts, and different `created_at` values, all within the two-day window + and none in the future. Randomness is injected so the assertions are deterministic. +- **Property test**: over many random keypairs and messages, the round trip holds and no + ephemeral key ever repeats. +- **Integration**: publish a gift wrap to the Docker test relay, subscribe as the recipient, + and read the message back. +- Run with `mvn -q verify` from the repository root. + +## 9. Delivery plan + +1. `Kinds` constants, `Rumor` type and its JSON codec, `GenericEvent.update(long)`. +2. `Nip59GiftWrapper`: wrap and unwrap with all §5 invariants, validated against the NIP-59 + vectors. +3. `ChatMessage` (kind 14) and `Nip17DirectMessageService`, validated against the NIP-17 + vectors. +4. `DirectMessageRelayList` (kind 10050) and relay-selection on publish. +5. Documentation: a how-to for sending and reading private messages, and a `CHANGELOG.md` + entry under `Added`. + +Phases 1–3 are what the MCP module's DM tools need; phase 4 is required before any real +network use, since publishing without a recipient's relay list violates the NIP. + +## 10. Open questions + +- Should `GiftWrapper` live in `nostr-java-identity` (with signing, as proposed) or in + `nostr-java-event` (with event construction)? It genuinely needs both, and the deciding + factor is that it must hold a private key. +- Randomness injection: constructor-supplied `SecureRandom`, or a package-private seam used + only by tests? The vector tests need determinism without widening the public API. +- Does `Rumor` warrant its own type, or is a `GenericEvent` with a null signature and a + documented convention enough? This spec argues for the type; it costs a class and a codec. +- Should NIP-04 be deprecated with `@Deprecated` in this change, signalling a removal in the + next major version? +- NIP-42 AUTH in `nostr-java-client` is a hard dependency for real-world DM delivery, since + relays are told to gate kind-1059 behind it. Should it be pulled into this work or tracked + as its own? + +## Related documents + +- [nostr-java-mcp-spec.md](nostr-java-mcp-spec.md) — the MCP module that consumes this work +- [extending-events.md](extending-events.md) — how custom events and tags are modelled +- [architecture.md](architecture.md) — module boundaries this design respects +- [../howto/custom-events.md](../howto/custom-events.md) — working with custom event kinds From e153f448dcff15e1038773a3ac10840f005ec03c Mon Sep 17 00:00:00 2001 From: tcheeric Date: Sun, 30 Aug 2026 09:50:46 +0100 Subject: [PATCH 04/15] docs: add identity lifecycle management to nostr-java-mcp spec Create, import, rename, export, and remove identities as tools, with key material never crossing the model context and irreversible operations guarded by two-step confirmation. --- docs/explanation/nostr-java-mcp-spec.md | 159 +++++++++++++++++++++--- 1 file changed, 144 insertions(+), 15 deletions(-) diff --git a/docs/explanation/nostr-java-mcp-spec.md b/docs/explanation/nostr-java-mcp-spec.md index 2a18143c..e0b0307a 100644 --- a/docs/explanation/nostr-java-mcp-spec.md +++ b/docs/explanation/nostr-java-mcp-spec.md @@ -121,7 +121,13 @@ returns structured JSON plus a short human-readable summary. | `nostr_get_contacts` | Read a kind-3 contact list (NIP-02) | `pubkey?` | | `nostr_send_direct_message` | Gift-wrapped encrypted DM (NIP-17) | `recipient`, `content`, `identity?`, `subject?` | | `nostr_read_direct_messages` | Unwrap and decrypt DMs addressed to an identity | `identity?`, `since?`, `limit` | -| `nostr_list_identities` | Public keys the server can sign with | — | +| `nostr_list_identities` | Aliases and public keys the server can sign with | — | +| `nostr_create_identity` | Generate a new keypair in the keystore (§6.4) | `alias`, `relays?`, `publishProfile?` | +| `nostr_import_identity` | Adopt an existing key held outside the model (§6.4) | `alias`, `source` | +| `nostr_rename_identity` | Change an alias, keeping the key | `alias`, `newAlias` | +| `nostr_set_default_identity` | Choose the identity used when `identity` is omitted | `alias` | +| `nostr_export_identity_backup` | Write an encrypted backup file to disk (§6.4) | `alias`, `path`, `passphrase?` | +| `nostr_remove_identity` | Forget a key, irreversibly (§6.4) | `alias`, `confirmationToken` | | `nostr_list_relays` | Configured relays and connection state | — | | `nostr_relay_info` | NIP-11 relay metadata | `relay` | @@ -167,8 +173,117 @@ The NIP-17 seal/gift-wrap logic is **not** implemented in this module. It belong consumer of the SDK benefits. This makes NIP-17 support a **hard prerequisite** of the DM phase of the delivery plan, tracked as its own work item against those modules. +### 6.4 Identity management -### 6.3 Resources and prompts +An agent that can only use pre-configured keys is half a tool. "Make me a throwaway account +for this project" and "stop using that key" are natural requests, so the keystore is +managed through tools rather than by hand-editing YAML. But an identity **is** the user's +Nostr account: creating one is cheap, losing one is unrecoverable, and exposing one is +irreversible. The lifecycle is therefore designed around what can and cannot be undone. + +#### The safety asymmetry + +| Operation | Reversible? | Treatment | +| --- | --- | --- | +| Create | Yes, discard the new key | Allowed freely | +| Rename, set default | Yes | Allowed freely | +| Import | Yes, remove it again | Allowed, but never through the model (see below) | +| Export backup | **No** — a key, once copied, cannot be uncopied | Guarded, file only | +| Remove | **No** — the key is gone and every event ever signed with it is orphaned | Two-step, guarded, backup-first | + +`WriteGuard` already exists for relay writes (§8). Identity mutations reuse the same +two-step confirmation mechanism for the irreversible half of this table, so there is one +confirmation concept in the module, not two. + +#### Creating + +`nostr_create_identity` generates a keypair with `PrivateKey.generateRandomPrivKey()`, +stores it in the keystore under the alias, and returns **only** the alias, public key, and +npub. The private key never leaves `IdentityVault`, so the agent can create an account it +can use but cannot leak. + +Optional `publishProfile` publishes a kind-0 for the new identity in the same call, since a +key with no metadata is invisible to every Nostr client. That publish still goes through +`WriteGuard` like any other write. + +A fresh identity starts with the server's default relay set unless `relays` is given. + +#### Importing + +Importing means supplying an existing `nsec` or hex key, and this is where the design says +no to the obvious thing. **`nostr_import_identity` never accepts key material as an +argument.** If it did, the key would pass through the model's context window, be written to +the host's conversation log, and very likely be sent to a third-party inference API. That is +the single worst thing this module could do. + +Instead `source` names *where the server should read the key from itself*: + +- `source: "file:/path/to/key"` — the server reads and then offers to shred the file. +- `source: "env:NOSTR_IMPORT_KEY"` — read once from its own environment. +- `source: "prompt"` — on stdio, the server reads from its controlling terminal, invisible + to the agent. On HTTP this returns `INPUT_REQUIRED` with a one-time local URL the human + opens to paste the key. + +The key is validated, its public key derived and reported back, and the source cleared. The +model orchestrates the import without ever seeing the secret. This is the same principle as +§7.1's rule that `Identity` objects never reach the tool layer, applied to the way in. + +#### Removing + +`nostr_remove_identity` is the only genuinely dangerous tool in the module: an npub with no +nsec is a dead account, and no relay, backup, or protocol can restore it. + +- It is two-step. The first call returns the alias, public key, whether a backup exists, + and a `confirmationToken`; nothing is deleted. The second call with the token deletes. +- It **refuses** if no backup has ever been exported for that alias, unless + `acknowledgeNoBackup` is explicitly set. An agent that has not been told a key is + disposable should not be able to destroy it on a hunch. +- Deletion zeroes the in-memory key, removes the keystore entry, and closes any + subscription or pending write bound to that alias. +- The removal is logged with the public key, so the audit trail outlives the key. +- Under `write-policy: deny` the tool is not registered at all, matching how write tools + behave: a read-only server cannot mutate the keystore either. + +#### Exporting + +`nostr_export_identity_backup` writes a passphrase-encrypted file to a path on the server's +filesystem and returns **the path only, never the contents**. This is what makes removal +safe without ever putting key material in the agent's context. A passphrase is required; if +omitted the server prompts for one by the same `source: "prompt"` mechanism as import. + +#### Interface + +One interface, mirroring `KeySource` from §7.1 so a future NIP-46 backend can decline +mutations cleanly rather than pretending to support them: + +```java +public interface IdentityStore { + List list(); // aliases and public keys only + IdentitySummary create(String alias); + IdentitySummary importFrom(KeyLocation source, String alias); + void rename(String alias, String newAlias); + Path exportBackup(String alias, Path destination, char[] passphrase); + void remove(String alias); + boolean supportsMutation(); // false for a remote signer +} +``` + +`IdentitySummary` is a value type carrying alias, public key, and npub. It has no field that +could hold a private key, so "no tool can return a secret" is a property of the type rather +than a rule someone has to remember. + +#### Multi-identity behaviour + +- Every signing tool takes an optional `identity` alias; omitting it uses the configured + default. An **ambiguity guard** applies: when more than one identity exists and no default + is set, signing tools fail with `IDENTITY_AMBIGUOUS` and list the aliases rather than + guessing. Posting from the wrong account is a public, irreversible mistake. +- Aliases are human-meaningful (`personal`, `project-bot`) and validated against + `[a-z0-9-]{1,32}`, since they appear in resource URIs. +- Per-identity relay sets are supported, because a throwaway identity often belongs on + different relays than a main one. + +### 6.5 Resources and prompts - **Resources**: `nostr://identity/{alias}` (public key, npub, configured relays), `nostr://relay/{name}` (NIP-11 document), and `nostr://subscription/{id}` (buffered @@ -176,7 +291,7 @@ phase of the delivery plan, tracked as its own work item against those modules. - **Prompts**: a small set of guided templates, e.g. `compose-note`, `catch-up-feed`, and `watch-mentions`, that teach the host how to sequence the tools. -### 6.4 Argument conventions +### 6.6 Argument conventions - Public keys accept hex or `npub`; event ids accept hex or `note`/`nevent`. Decoding is centralised in one `NostrIdentifier` value type — the tools never parse bech32 inline. @@ -250,14 +365,17 @@ Regardless of backend: - `Identity` objects are never handed to the tool layer. Tools pass an alias to a signing service, which returns a signed event. Signing is the only capability that crosses the vault boundary. -- A **generate** path (`nostr-mcp keygen `) creates a key inside the keystore so a - user never has to paste an `nsec` into a shell. -- Import accepts `nsec` or hex once, at rest it is always encrypted. +- A **generate** path (`nostr_create_identity`, §6.4) creates a key inside the keystore so a + user never has to paste an `nsec` into a shell or into a chat window. +- Import reads the key from a location the server resolves itself; at rest it is always + encrypted (§6.4). **Deferred: NIP-46 remote signing.** The strongest answer is for the server to hold no key at all and delegate signing to a bunker (Amber, nsec.app). It is out of scope for v1 because the SDK has no NIP-46 support, but `KeySource`/signing-service seam above exists precisely so -adding `type: nip46` later touches one class. +adding `type: nip46` later touches one class. A remote signer reports +`supportsMutation() == false`, so the identity lifecycle tools degrade to read-only rather +than failing confusingly. ### 7.2 Packaging @@ -281,7 +399,11 @@ guarded action. This turns a hallucinated post into a no-op. - `write-policy: allow` — writes proceed directly, for trusted automation. - Rate limits per identity and per relay, enforced in `WriteGuard`. -- Every write is logged with event id, kind, identity pubkey, and target relays. +- Identity removal and backup export are guarded by the same two-step confirmation, and + neither is registered under `write-policy: deny` (§6.4). +- No tool accepts private key material as an argument, and no tool returns it (§6.4, §7.1). +- Every write is logged with event id, kind, identity pubkey, and target relays. Every + keystore mutation is logged with the alias and public key. - DM decryption is opt-in per identity, since it exposes private correspondence to the model. NIP-17's gift wrapping means the relay cannot see the correspondents, but the MCP host can, so this stays an explicit per-identity grant. @@ -292,8 +414,9 @@ Errors are returned as MCP tool errors with a stable machine-readable `code` and the model can act on, never as stack traces. Categories mirror the SDK's exception hierarchy: `RELAY_UNREACHABLE`, `RELAY_REJECTED`, `INVALID_ARGUMENT`, `IDENTITY_UNKNOWN`, `WRITE_FORBIDDEN`, `TIMEOUT`, `SUBSCRIPTION_UNKNOWN`, `SUBSCRIPTION_LIMIT_REACHED`, -`KEYSTORE_LOCKED`. Partial success on multi-relay publish is a success with a per-relay -result list, not an error. +`KEYSTORE_LOCKED`, `IDENTITY_AMBIGUOUS`, `ALIAS_IN_USE`, `NO_BACKUP_EXISTS`, +`MUTATION_UNSUPPORTED`, `INPUT_REQUIRED`. Partial success on multi-relay publish is a +success with a per-relay result list, not an error. ## 10. Testing strategy @@ -301,8 +424,9 @@ result list, not an error. relative-time parsing, `WriteGuard` policy transitions, ring-buffer overflow and `droppedCount`, subscription TTL reaping. - **Security**: a test that walks every registered tool and resource and asserts no response - or error message can contain a private key; keystore permission and passphrase-failure - paths. + or error message can contain a private key, and that no tool's input schema accepts one; + keystore permission and passphrase-failure paths; identity removal refused without a + backup; `IDENTITY_AMBIGUOUS` raised rather than a key guessed. - **Integration**: the full server over an in-process MCP client against a stub relay (reusing the existing Docker relay harness), asserting round trips for publish, query, a live subscription receiving an event published mid-test, and a NIP-17 DM round trip. @@ -316,9 +440,10 @@ result list, not an error. 0. **Prerequisite, in `nostr-java-event`/`nostr-java-identity`**: NIP-17 support — kind-14 rumor, kind-13 seal, kind-1059 gift wrap over the existing NIP-44 primitives. Tracked separately; blocks phase 5 only, so the rest can proceed in parallel. -1. Module skeleton, POM, BOM entry, official MCP SDK on stdio, `IdentityVault` with the - `encrypted-file` keystore and `keygen`, plus `nostr_list_identities` and - `nostr_list_relays` — proves the wiring end to end. +1. Module skeleton, POM, BOM entry, official MCP SDK on stdio, `IdentityVault` and + `IdentityStore` with the `encrypted-file` keystore, the full identity lifecycle tools + (§6.4), and `nostr_list_relays` — proves the wiring end to end and makes the server + usable from a cold start with no hand-written config. 2. Read path: `nostr_query_events`, `nostr_get_profile`, `nostr_relay_info`. 3. Write path behind `WriteGuard`: `nostr_publish_note`, `nostr_publish_event`, `nostr_update_profile`. @@ -334,6 +459,10 @@ result list, not an error. (`encrypted-file`) is safest but blocks unattended startup. Is a passphrase-less `os-keychain` default better for desktop users? - Should `write-policy: confirm` tokens expire, and after how long? +- Should identity mutation have its own policy switch (`identity-policy`) separate from + `write-policy`, so an agent can be allowed to post but not to touch the keystore? +- Should the server auto-create a `default` identity on first run when the keystore is + empty, or refuse to start until one exists? - Does the HTTP transport need authentication of its own (bearer token) in v1, or is it documented as bind-to-localhost only? - Does NIP-17 support land as a contribution to this repo, or is it already planned From 82e0a81391ae9c8c453792373db98bee8155d47e Mon Sep 17 00:00:00 2001 From: tcheeric Date: Sun, 30 Aug 2026 09:50:52 +0100 Subject: [PATCH 05/15] docs: fix section numbering in nostr-java-mcp spec --- docs/explanation/nostr-java-mcp-spec.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/explanation/nostr-java-mcp-spec.md b/docs/explanation/nostr-java-mcp-spec.md index e0b0307a..fe29101a 100644 --- a/docs/explanation/nostr-java-mcp-spec.md +++ b/docs/explanation/nostr-java-mcp-spec.md @@ -122,12 +122,12 @@ returns structured JSON plus a short human-readable summary. | `nostr_send_direct_message` | Gift-wrapped encrypted DM (NIP-17) | `recipient`, `content`, `identity?`, `subject?` | | `nostr_read_direct_messages` | Unwrap and decrypt DMs addressed to an identity | `identity?`, `since?`, `limit` | | `nostr_list_identities` | Aliases and public keys the server can sign with | — | -| `nostr_create_identity` | Generate a new keypair in the keystore (§6.4) | `alias`, `relays?`, `publishProfile?` | -| `nostr_import_identity` | Adopt an existing key held outside the model (§6.4) | `alias`, `source` | +| `nostr_create_identity` | Generate a new keypair in the keystore (§6.3) | `alias`, `relays?`, `publishProfile?` | +| `nostr_import_identity` | Adopt an existing key held outside the model (§6.3) | `alias`, `source` | | `nostr_rename_identity` | Change an alias, keeping the key | `alias`, `newAlias` | | `nostr_set_default_identity` | Choose the identity used when `identity` is omitted | `alias` | -| `nostr_export_identity_backup` | Write an encrypted backup file to disk (§6.4) | `alias`, `path`, `passphrase?` | -| `nostr_remove_identity` | Forget a key, irreversibly (§6.4) | `alias`, `confirmationToken` | +| `nostr_export_identity_backup` | Write an encrypted backup file to disk (§6.3) | `alias`, `path`, `passphrase?` | +| `nostr_remove_identity` | Forget a key, irreversibly (§6.3) | `alias`, `confirmationToken` | | `nostr_list_relays` | Configured relays and connection state | — | | `nostr_relay_info` | NIP-11 relay metadata | `relay` | @@ -173,7 +173,7 @@ The NIP-17 seal/gift-wrap logic is **not** implemented in this module. It belong consumer of the SDK benefits. This makes NIP-17 support a **hard prerequisite** of the DM phase of the delivery plan, tracked as its own work item against those modules. -### 6.4 Identity management +### 6.3 Identity management An agent that can only use pre-configured keys is half a tool. "Make me a throwaway account for this project" and "stop using that key" are natural requests, so the keystore is @@ -283,7 +283,7 @@ than a rule someone has to remember. - Per-identity relay sets are supported, because a throwaway identity often belongs on different relays than a main one. -### 6.5 Resources and prompts +### 6.4 Resources and prompts - **Resources**: `nostr://identity/{alias}` (public key, npub, configured relays), `nostr://relay/{name}` (NIP-11 document), and `nostr://subscription/{id}` (buffered @@ -291,7 +291,7 @@ than a rule someone has to remember. - **Prompts**: a small set of guided templates, e.g. `compose-note`, `catch-up-feed`, and `watch-mentions`, that teach the host how to sequence the tools. -### 6.6 Argument conventions +### 6.5 Argument conventions - Public keys accept hex or `npub`; event ids accept hex or `note`/`nevent`. Decoding is centralised in one `NostrIdentifier` value type — the tools never parse bech32 inline. @@ -365,10 +365,10 @@ Regardless of backend: - `Identity` objects are never handed to the tool layer. Tools pass an alias to a signing service, which returns a signed event. Signing is the only capability that crosses the vault boundary. -- A **generate** path (`nostr_create_identity`, §6.4) creates a key inside the keystore so a +- A **generate** path (`nostr_create_identity`, §6.3) creates a key inside the keystore so a user never has to paste an `nsec` into a shell or into a chat window. - Import reads the key from a location the server resolves itself; at rest it is always - encrypted (§6.4). + encrypted (§6.3). **Deferred: NIP-46 remote signing.** The strongest answer is for the server to hold no key at all and delegate signing to a bunker (Amber, nsec.app). It is out of scope for v1 because @@ -400,8 +400,8 @@ guarded action. - `write-policy: allow` — writes proceed directly, for trusted automation. - Rate limits per identity and per relay, enforced in `WriteGuard`. - Identity removal and backup export are guarded by the same two-step confirmation, and - neither is registered under `write-policy: deny` (§6.4). -- No tool accepts private key material as an argument, and no tool returns it (§6.4, §7.1). + neither is registered under `write-policy: deny` (§6.3). +- No tool accepts private key material as an argument, and no tool returns it (§6.3, §7.1). - Every write is logged with event id, kind, identity pubkey, and target relays. Every keystore mutation is logged with the alias and public key. - DM decryption is opt-in per identity, since it exposes private correspondence to the @@ -442,7 +442,7 @@ success with a per-relay result list, not an error. separately; blocks phase 5 only, so the rest can proceed in parallel. 1. Module skeleton, POM, BOM entry, official MCP SDK on stdio, `IdentityVault` and `IdentityStore` with the `encrypted-file` keystore, the full identity lifecycle tools - (§6.4), and `nostr_list_relays` — proves the wiring end to end and makes the server + (§6.3), and `nostr_list_relays` — proves the wiring end to end and makes the server usable from a cold start with no hand-written config. 2. Read path: `nostr_query_events`, `nostr_get_profile`, `nostr_relay_info`. 3. Write path behind `WriteGuard`: `nostr_publish_note`, `nostr_publish_event`, From 59802aa4c3de086644414df57fdb6405ab6cc766 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Sun, 30 Aug 2026 09:55:25 +0100 Subject: [PATCH 06/15] docs: add single-identity mode and process isolation to mcp spec Bind one server process to one identity for hard key isolation, deployed as one process per identity. Documents why threads do not provide this, and relocates key administration to a CLI on the same jar. --- docs/explanation/nostr-java-mcp-spec.md | 138 +++++++++++++++++++++--- 1 file changed, 123 insertions(+), 15 deletions(-) diff --git a/docs/explanation/nostr-java-mcp-spec.md b/docs/explanation/nostr-java-mcp-spec.md index fe29101a..6d954a06 100644 --- a/docs/explanation/nostr-java-mcp-spec.md +++ b/docs/explanation/nostr-java-mcp-spec.md @@ -25,6 +25,8 @@ the existing modules. introspection. - Keep signing keys inside the server process. The agent names an identity; it never sees or supplies a private key. +- Offer a **process-per-identity** deployment for users who want hard isolation between + accounts, without forcing it on users who want one server across all of theirs. - Support both stdio (local desktop agents) and streamable HTTP (remote/hosted) transports. - Be strictly additive: no changes required in `core`, `event`, `identity`, or `client`. - Make every destructive or public-facing action (anything that writes to a relay) opt-in @@ -48,6 +50,7 @@ the existing modules. | Subscriptions | Long-lived streaming, surfaced as MCP resources with change notifications. | | Packaging | Executable jar plus `Dockerfile` and `docker-compose.yml`. | | Direct messages | NIP-17 gift-wrapped DMs. Requires NIP-17 support to be added to `nostr-java-event` first. | +| Identity isolation | Optional **single-identity mode** binding one server process to one identity, deployed as one process per identity (§6.3.1). Not one thread per identity. | ## 4. Position in the module graph @@ -106,6 +109,10 @@ do not control. Names are namespaced `nostr_*` so they read clearly in an agent's tool list. Every tool returns structured JSON plus a short human-readable summary. +The surface is **not fixed**: `write-policy` (§8) and single-identity mode (§6.3.1) both +unregister tools rather than rejecting calls at runtime. A tool an agent cannot see is a +tool it cannot misuse, and the tool list itself tells the agent what this server is for. + | Tool | Purpose | Key arguments | | --- | --- | --- | | `nostr_publish_note` | Publish a kind-1 text note | `content`, `identity?`, `relays?`, `replyTo?`, `mentions?` | @@ -283,6 +290,87 @@ than a rule someone has to remember. - Per-identity relay sets are supported, because a throwaway identity often belongs on different relays than a main one. +#### 6.3.1 Single-identity mode and process isolation + +The multi-identity server above is the general case, but it has a real weakness: an agent +holding several aliases can post as the wrong one. The `IDENTITY_AMBIGUOUS` guard reduces +that risk without removing it, because once a default exists the agent can still name any +alias it likes. + +**Single-identity mode** removes the risk instead of guarding it. Setting +`nostr.mcp.identity: ` binds the whole process to exactly one identity: + +- The `identity` argument disappears from every signing tool's schema. There is nothing to + name, so nothing to name wrongly, and `IDENTITY_AMBIGUOUS` cannot occur. +- `nostr_list_identities` returns the single bound identity. +- The lifecycle tools (create, import, rename, export, remove) are **not registered**. A + bound server operates a key; it does not administer the keystore. +- Only that alias's entry is unlocked. The other keystore entries are never decrypted, so + they are absent from the process's heap entirely. + +##### Why a process, not a thread + +The isolation people usually want here is "identity A's key cannot reach identity B's +operation", and it is worth being precise about what does and does not deliver that. + +**Threads do not.** Threads in a JVM share one heap, so every thread can read every other +thread's `Identity` object. A thread-per-identity design would give the *appearance* of +separation with none of the substance. It is also the wrong concurrency shape: relay work is +I/O-bound and `NostrRelayClient` is already `CompletableFuture`-based, so a dedicated thread +per identity would idle almost always while capping each identity at one in-flight +operation. And it would not remove the lifecycle question, since a thread neither generates +a keypair nor forgets one. + +**Processes do.** A separate process has its own address space, its own file handles, and +its own OS-level permissions. A compromise or a bug in the process signing for `project-bot` +cannot reach the `personal` key, because that key was never decrypted in that process. + +**Sessions are the middle ground.** The streamable HTTP transport has a session concept, so +a hosted deployment may bind each session to one identity and filter the tool surface +accordingly. This is a logical boundary within one heap: it guards against agent confusion, +not against a compromised process. It is offered for the hosted case, where one process per +identity per user does not scale, and its weaker guarantee is stated plainly rather than +implied. + +##### The deployment pattern + +Single-identity mode fits how MCP hosts already work: a host config lists one entry per +server, so identities become entries. + +```json +{ + "mcpServers": { + "nostr-personal": { "command": "java", "args": ["-jar", "nostr-java-mcp.jar", "--nostr.mcp.identity=personal"] }, + "nostr-project-bot": { "command": "java", "args": ["-jar", "nostr-java-mcp.jar", "--nostr.mcp.identity=project-bot"] } + } +} +``` + +Each process reads only its own key. The agent sees two clearly-named tool groups and cannot +confuse them, because the tools themselves are distinct. + +The costs are real and worth stating: N processes, N relay connection pools, N websocket +connections to the same relays, and **no cross-identity operation** — you cannot ask "which +of my accounts was mentioned this week" from a bound server. That query needs the +multi-identity server, which is exactly why both modes exist rather than one replacing the +other. + +##### Bootstrapping + +Binding a process to an alias presupposes the alias exists, so single-identity mode does not +remove the need to create and remove keys, it relocates it. Two paths, neither of which +requires a bound server to administer anything: + +- **A multi-identity server** run deliberately for administration, with the lifecycle tools + from §6.3 available. +- **A CLI** on the same jar: `java -jar nostr-java-mcp.jar keygen `, + `import `, `list`, `remove `. This is the better default for the + process-per-identity deployment, since it keeps key administration out of every agent's + reach entirely and puts it in the hands of the human who set the servers up. + +Both drive the same `IdentityStore` (§6.3), so there is one implementation of the lifecycle +and two front doors to it. + ### 6.4 Resources and prompts - **Resources**: `nostr://identity/{alias}` (public key, npub, configured relays), @@ -307,6 +395,7 @@ Spring Boot properties under `nostr.mcp.*`, overridable by environment variables nostr: mcp: transport: stdio # stdio | http + identity: personal # optional: bind to one identity (§6.3.1) keystore: type: encrypted-file # env | encrypted-file | os-keychain path: ${HOME}/.nostr-java/keys.jceks @@ -379,13 +468,15 @@ than failing confusingly. ### 7.2 Packaging -Ships as an executable Spring Boot jar, a `Dockerfile` (distroless JRE 21 base, non-root -user), and a `docker-compose.yml` that runs the server in HTTP transport mode alongside the -existing test relay container, with the keystore mounted read-only as a volume and the -passphrase supplied as a secret. Per repo convention the compose file is verified with -`docker-compose build` in CI. The stdio transport is documented as a bare `java -jar` -invocation, since an MCP host launches the process itself and containerising stdio adds -little. +Ships as an executable Spring Boot jar that is **both** the MCP server and the key-admin +CLI (§6.3.1), a `Dockerfile` (distroless JRE 21 base, non-root user), and a +`docker-compose.yml` that runs the server in HTTP transport mode alongside the existing test +relay container, with the keystore mounted read-only as a volume and the passphrase supplied +as a secret. The compose file also demonstrates the bound-container pattern: one service per +identity, each with `--nostr.mcp.identity` set and only its own key readable. Per repo +convention the compose file is verified with `docker-compose build` in CI. The stdio +transport is documented as a bare `java -jar` invocation, since an MCP host launches the +process itself and containerising stdio adds little. ## 8. Safety model @@ -400,7 +491,10 @@ guarded action. - `write-policy: allow` — writes proceed directly, for trusted automation. - Rate limits per identity and per relay, enforced in `WriteGuard`. - Identity removal and backup export are guarded by the same two-step confirmation, and - neither is registered under `write-policy: deny` (§6.3). + neither is registered under `write-policy: deny` or in single-identity mode (§6.3, + §6.3.1). +- Key isolation between identities is a **process** boundary, not a thread or a check + (§6.3.1). Deployments that need it run one bound server per identity. - No tool accepts private key material as an argument, and no tool returns it (§6.3, §7.1). - Every write is logged with event id, kind, identity pubkey, and target relays. Every keystore mutation is logged with the alias and public key. @@ -431,7 +525,12 @@ success with a per-relay result list, not an error. (reusing the existing Docker relay harness), asserting round trips for publish, query, a live subscription receiving an event published mid-test, and a NIP-17 DM round trip. - **Contract**: every registered tool's JSON schema is validated, and a golden-file test - pins the tool list so accidental surface changes are visible in review. + pins the tool list so accidental surface changes are visible in review. Separate golden + files per mode (multi-identity, single-identity, `write-policy: deny`) so tool + unregistration is asserted rather than assumed. +- **Isolation**: a single-identity server started with `identity: personal` exposes no + lifecycle tools, accepts no `identity` argument, and never decrypts another alias's + keystore entry. - **Packaging**: `docker-compose build` runs in CI. - Run with `mvn -q verify` from the repository root as usual. @@ -441,23 +540,32 @@ success with a per-relay result list, not an error. rumor, kind-13 seal, kind-1059 gift wrap over the existing NIP-44 primitives. Tracked separately; blocks phase 5 only, so the rest can proceed in parallel. 1. Module skeleton, POM, BOM entry, official MCP SDK on stdio, `IdentityVault` and - `IdentityStore` with the `encrypted-file` keystore, the full identity lifecycle tools - (§6.3), and `nostr_list_relays` — proves the wiring end to end and makes the server - usable from a cold start with no hand-written config. + `IdentityStore` with the `encrypted-file` keystore, the **CLI** lifecycle commands + (§6.3.1), single-identity mode, and `nostr_list_relays` — proves the wiring end to end + and makes the server usable from a cold start with no hand-written config. 2. Read path: `nostr_query_events`, `nostr_get_profile`, `nostr_relay_info`. 3. Write path behind `WriteGuard`: `nostr_publish_note`, `nostr_publish_event`, - `nostr_update_profile`. + `nostr_update_profile`. Identity lifecycle **tools** (§6.3) land here too, for the + multi-identity administration case. 4. Subscriptions: `SubscriptionRegistry`, the four subscription tools, resource notifications, TTL reaping. 5. Social layer: threads, contacts, NIP-17 direct messages. -6. HTTP transport, `Dockerfile` and `docker-compose.yml`, prompts, and documentation (a - how-to for wiring the server into an MCP host). +6. HTTP transport with per-session identity binding, `Dockerfile` and `docker-compose.yml` + (including a profile showing one bound container per identity), prompts, and + documentation (a how-to for wiring the server into an MCP host, covering both modes). ## 12. Open questions - Which keystore backend is the default on a fresh install: prompt-for-passphrase (`encrypted-file`) is safest but blocks unattended startup. Is a passphrase-less `os-keychain` default better for desktop users? +- With the CLI available, should the identity lifecycle **tools** exist at all, or is + agent-driven key administration a capability worth omitting entirely? +- Should single-identity mode be the documented default in the how-to, with the + multi-identity server presented as the advanced case? +- Do bound processes need a shared relay-connection broker to avoid N websocket + connections to the same relay, or is that premature for the expected handful of + identities? - Should `write-policy: confirm` tokens expire, and after how long? - Should identity mutation have its own policy switch (`identity-policy`) separate from `write-policy`, so an agent can be allowed to post but not to touch the keystore? From 4b0efebb1dfb5e55eee7dce6dbfc4d96ed649fdf Mon Sep 17 00:00:00 2001 From: tcheeric Date: Sun, 30 Aug 2026 10:01:39 +0100 Subject: [PATCH 07/15] docs: review imani-bridge wallet-nip-17 prior art in NIP-17 spec Reframes the work as a port with corrections. Documents a confirmed timestamp bug caused by GenericEvent.update(), five copies of hand-rolled JSON, and two missing security checks (seal signature verification and the NIP-17 impersonation check). --- .../nip-17-direct-messages-spec.md | 223 +++++++++++++++--- 1 file changed, 196 insertions(+), 27 deletions(-) diff --git a/docs/explanation/nip-17-direct-messages-spec.md b/docs/explanation/nip-17-direct-messages-spec.md index 20637571..09d6a104 100644 --- a/docs/explanation/nip-17-direct-messages-spec.md +++ b/docs/explanation/nip-17-direct-messages-spec.md @@ -36,17 +36,157 @@ reusable gift-wrap primitive that NIP-59 explicitly intends other protocols to b | Event id computation over an unsigned event | Present | `EventSerializer.computeEventId` | | Event JSON encode/decode | Present | `BaseEventEncoder`, `EventJsonMapper` | | Kind constants | Partial | `Kinds` has `ENCRYPTED_DIRECT_MESSAGE = 4`; 13/14/15/1059/10050 missing | -| Rumor (unsigned event) as a first-class concept | **Missing** | — | -| Seal, gift wrap, unwrapping | **Missing** | — | -| DM relay list (kind 10050) | **Missing** | — | +| Rumor (unsigned event) as a first-class concept | **Missing** — but implemented in imani-bridge (§3) | — | +| Seal, gift wrap, unwrapping | **Missing** — but implemented in imani-bridge (§3) | — | +| DM relay list (kind 10050) | **Missing** everywhere | — | -The cryptography is done. What is missing is the event-composition layer above it. +The cryptography is done. What is missing is the event-composition layer above it — and a +working version of that layer already exists in a sibling repository, which §3 reviews. -## 3. Constraints in the current codebase +## 3. Prior art: `wallet-nip-17` in imani-bridge + +A working NIP-17 implementation already exists at +`imani-bridge/wallet-plugin/wallet-nips/wallet-nip-17` (~1,240 lines across eight classes). +It is in production use for gift-wrapped token transfers, so it is the reference for *what +works*, and reviewing it is cheaper than rediscovering its lessons. + +Its shape maps almost one-to-one onto §5 below: + +| imani-bridge | This spec | Verdict | +| --- | --- | --- | +| `Rumor` (record) | `Rumor` | **Adopt.** A record with no `sig` field is exactly the "cannot be signed" property §5.1 wants. | +| `Seal`, `GiftWrap` (records) | Internal to `GiftWrapper` | Adopt the modelling, hide the types. | +| `Nip17Kinds` | `Kinds` additions | Fold into the existing `Kinds` class rather than a parallel one. | +| `SealService` | `Nip59GiftWrapper` (inner half) | Adopt the flow, replace the JSON handling. | +| `GiftWrapBuilder` | `Nip59GiftWrapper` (outer half) | Adopt the flow, replace key generation and JSON. | +| `Nip17DmService` | `DirectMessageService` | Adopt the orchestration and the sender-copy handling. | +| `DmCrypto44` + `DmCryptoNip44` | `MessageCipher44` directly | Drop. It is an adapter around a class we own. | + +The three-layer flow, the sender-copy publish, the "skip undecryptable events" subscription +handler, and the `p`-tag recipient extraction are all correct and worth carrying over +directly. What follows is what must **not** be carried over. + +### 3.1 A confirmed bug: `update()` destroys the randomised timestamp + +`SealService.createSeal` does this: + +```java +Instant createdAt = randomizeTimestamp(rumor.createdAt()); +sealEvent.setCreatedAt(createdAt.getEpochSecond()); +... +sealEvent.update(); // <-- overwrites createdAt with Instant.now() +``` + +And `GenericEvent.update()` begins: + +```java +this.createdAt = Instant.now().getEpochSecond(); +``` + +So the randomisation is computed, assigned, and then thrown away on the next line. Both +`SealService` and `GiftWrapBuilder` have this bug. Every seal and gift wrap those services +produce is stamped with the true send time, which defeats the specific NIP-59 requirement +that timestamps be randomised to prevent time-correlation analysis. The privacy property is +silently absent while the code reads as though it is present. + +This is the strongest possible evidence for §4.1's proposed `update(long createdAt)`: the +missing seam did not merely make the correct behaviour awkward, it made the incorrect +behaviour invisible. Fixing `nostr-java` fixes imani-bridge too. +### 3.2 Hand-rolled JSON must not be carried over + +`Rumor`, `Seal`, `GiftWrap`, `SealService`, and `GiftWrapBuilder` each contain their own +`serializeTags`, `escapeJson`, `unescapeJson`, `extractJsonString`, `extractJsonLong`, and +`extractJsonTags`. That is five copies of a hand-written JSON serialiser and two copies of a +hand-written parser, in a module that already depends on Jackson through `nostr-java-event`. +One of them carries the comment `// Simple JSON parsing - in production, use a proper JSON +library`. + +Beyond the duplication, this is a **correctness risk in the one place correctness matters +most**. The event id is a SHA-256 over the canonical serialisation, so any deviation from +NIP-01's canonical form produces a wrong id, and any escaping mismatch between the serialiser +and the parser corrupts a message. `escapeJson` handles `\`, `"`, `\n`, `\r`, `\t` but not +other control characters, which NIP-01 requires escaped. A message containing one produces +an event whose id does not match its content. + +`nostr-java` already has `EventSerializer.serializeToBytes`, `computeEventId`, and +`EventJsonMapper`. The port uses them for encoding and decoding, and the hand-rolled JSON is +deleted rather than moved. + +### 3.3 Ephemeral key generation should use existing SDK code + +`GiftWrapBuilder` pulls in a direct BouncyCastle dependency and reimplements secp256k1 key +generation with `SECNamedCurves`, `ECDomainParameters`, and `BigInteger` arithmetic, roughly +20 lines including a fixed-width hex helper. + +`nostr-java` already has `PrivateKey.generateRandomPrivKey()` and `Schnorr.genPubKey`, used +by `Identity.generateRandomIdentity()`. One line replaces all of it, and the BouncyCastle +dependency leaves the module's POM. + +### 3.4 Timestamp randomisation is wrong in two further ways + +```java +long offsetSeconds = (long) ((Math.random() - 0.5) * 2 * 2 * 24 * 60 * 60); +return original.plusSeconds(offsetSeconds); +``` + +- It uses `Math.random()`, not `SecureRandom`. A predictable offset is a weak offset when the + offset is the privacy mechanism. +- It randomises **±2 days**, so half of all timestamps land in the *future*. NIP-59 says "up + to two days in the past" and warns that some relays refuse future-dated events. The port + uses `[now - 2 days, now]`. + +### 3.5 Missing security checks + +Two of §6's invariants are absent from the imani-bridge code: + +- **The seal signature is never verified** on the receive path. `SealService.unseal` + decrypts and parses, but nothing checks the signature, so an unauthenticated message is + accepted as authentic. +- **The impersonation check is missing.** Nothing compares `rumor.pubkey` to `seal.pubkey`. + NIP-17 calls this out explicitly: without it, anyone can forge a message from anyone by + editing one field of the rumor before sealing. `Nip17DmService.onEvent` then reports + `seal.pubkey()` as the sender, so the forgery would be attributed to whoever the attacker + chose. + +Both are cheap to add and both are load-bearing. They are the reason §6 states the +invariants as named, individually-tested rules rather than as prose. + +### 3.6 Structural notes + +- **Keys are passed as hex `String`s** throughout (`senderPrivKeyHex`, `recipientPrivHex`). + Strings cannot be zeroed and may be interned, and `nostr-java` already has `PrivateKey`, + `PublicKey`, and `Identity` for this. The port takes an `Identity` and never sees raw key + material, which also aligns with the MCP module's vault boundary. +- **`DmCrypto44` is an unnecessary adapter.** It exists to invert the dependency on + `MessageCipher44`, but in `nostr-java` that class is ours, one module away. Depending on it + directly removes an interface, an implementation, and a BouncyCastle `Hex` round trip. +- **`Nip17DmService` mixes four responsibilities**: composing messages, publishing, + subscribing, and holding an in-memory inbox with mutable identity state + (`setIdentity`). For the SDK, composition and parsing belong in + `DirectMessageService`; publishing and subscribing belong to the caller, who already has + `NostrRelayClient`; and the inbox belongs to the application. The SDK version is a pure + function of its inputs, which is also what makes it testable against the NIP vectors. +- **`wallet-nip-17` has no test directory** (`src/main` only). Coverage comes from + integration tests elsewhere in imani-bridge. Given §3.1 and §3.5, this is the likeliest + reason those defects survived, and it is why §9 leads with the published NIP vectors. + +### 3.7 What the port is, in one line + +Take the structure and the flow from `wallet-nip-17`; replace the JSON, the key generation, +the randomisation, and the key-handling types with facilities `nostr-java` already has; and +add the two missing security checks. The result should be well under half the line count, +because most of what was written was substituting for library code that already existed one +module away. + +Once merged, `wallet-nip-17` becomes a thin adapter over `nostr-java`'s implementation, or +is deleted in favour of it. That migration is out of scope here but is the point of doing +this work in the SDK rather than again. + +## 4. Constraints in the current codebase Three facts about the existing code shape the design, and each needs an explicit decision. -### 3.1 `GenericEvent.update()` forces `created_at` to now +### 4.1 `GenericEvent.update()` forces `created_at` to now ```java public void update() { @@ -65,7 +205,10 @@ the clock. This is additive, changes no existing call site's behaviour, and give gift-wrap code the seam it needs. The alternative, a `Clock`/`Supplier` injected into `GenericEvent`, is a larger change to a widely-used class for no extra benefit here. -### 3.2 `MessageCipher44` takes raw key bytes and prefixes `02` +This is not hypothetical: the absence of this seam is the direct cause of the timestamp bug +found in the imani-bridge implementation (§3.1). + +### 4.2 `MessageCipher44` takes raw key bytes and prefixes `02` ```java EncryptedPayloads.getConversationKey( @@ -77,7 +220,7 @@ pairs per message (sender↔recipient for the seal, ephemeral↔recipient for th is just two `MessageCipher44` instances. No change required, but the gift-wrap code must construct ciphers rather than assume one conversation. -### 3.3 A rumor is an event that must never be signed +### 4.3 A rumor is an event that must never be signed `GenericEvent` implements `ISignable` and `validate()` requires a signature. A rumor has an `id` and no `sig`, and encoding it must emit `"sig"` absent (or empty) without the encoder @@ -88,17 +231,17 @@ half-built state (see §4.1). Making illegal states unrepresentable is worth mor reusing the class, because the whole security property of NIP-59 rests on a rumor never acquiring a signature. -## 4. Design +## 5. Design All new code lands in `nostr-java-event` and `nostr-java-identity`. Nothing in `core` changes; nothing in `client` changes. -### 4.1 New types in `nostr-java-event` +### 5.1 New types in `nostr-java-event` **`Rumor`** — an unsigned event. Holds `pubkey`, `createdAt`, `kind`, `tags`, `content`, and a derived `id`. It is deliberately **not** an `ISignable` and exposes no signature field, so a rumor cannot be signed by accident. It serialises to the same JSON shape as an event with -`"sig": ""`. +`"sig": ""`. A Java `record`, following imani-bridge's modelling (§3), which got this right. **`Kinds` additions**: @@ -120,7 +263,7 @@ NIP-17 states clients MUST only publish DMs to relays in the recipient's kind-10 and MUST NOT send at all when no list is found. Publishing a DM to an arbitrary relay is a protocol violation, so this is not optional. -### 4.2 New capability in `nostr-java-identity`: `GiftWrapper` +### 5.2 New capability in `nostr-java-identity`: `GiftWrapper` Signing lives in `identity`, and gift wrapping is fundamentally a signing operation with two keys, so the wrap/unwrap logic belongs there alongside `MessageCipher`. @@ -132,7 +275,7 @@ public interface GiftWrapper { } ``` -Two methods, one responsibility: hide a rumor, and reveal it. Everything in §5 is an +Two methods, one responsibility: hide a rumor, and reveal it. Everything in §6 is an implementation detail behind this interface, which is what makes it a deep module — a large amount of protocol behaviour behind a surface an application author can hold in their head. @@ -140,7 +283,7 @@ amount of protocol behaviour behind a surface an application author can hold in implementation for `kind:21059` ephemeral wraps differs only in the outer kind, so the wrap kind is a constructor parameter rather than a flag argument on the method. -### 4.3 The DM service +### 5.3 The DM service `DirectMessageService`, also in `identity`, is the NIP-17-specific layer above the NIP-59-generic `GiftWrapper`: @@ -155,7 +298,7 @@ recipient *and* one addressed to the sender, since the sender cannot otherwise r own history. Making that plurality visible in the signature stops callers from publishing one event and silently losing their outbox. -## 5. The algorithm +## 6. The algorithm ### Sending @@ -203,7 +346,7 @@ its own test: wraps addressed to you but also, potentially, garbage. Undecryptable wraps are skipped, not thrown, or one malformed event stops a whole inbox from loading. -## 6. Public API sketch +## 7. Public API sketch ```java Identity alice = Identity.create(alicePrivateKey); @@ -225,7 +368,7 @@ ChatMessage received = messages.read(incomingGiftWrap); The application author never sees a seal, an ephemeral key, or a conversation key. That is the point. -## 7. Scope +## 8. Scope **In scope for this work:** kind-14 chat messages, NIP-59 seal and gift wrap (kinds 13, 1059, 21059), unwrapping with full verification, kind-10050 DM relay lists, and the @@ -242,7 +385,7 @@ allow. NIP-04 is left in place, deprecated in Javadoc, and not removed. Removing it is a breaking change and a separate decision. -## 8. Testing strategy +## 9. Testing strategy - **Vectors from the NIPs.** NIP-59 §"An Example" gives an author key, recipient key, ephemeral key, and the exact resulting seal and gift wrap. With the ephemeral key and both @@ -253,7 +396,14 @@ change and a separate decision. empty strings, and the NIP-44 maximum plaintext size. - **The impersonation attack**: hand-build a wrap whose rumor pubkey differs from the seal pubkey and assert it is rejected. This test is the reason the check exists, so it must - fail loudly if the check is ever removed. + fail loudly if the check is ever removed. **Absent from imani-bridge (§3.5).** +- **An unsigned or badly-signed seal is rejected.** Also absent from imani-bridge (§3.5). +- **Timestamps**: every seal and wrap carries a `created_at` in `[now - 2 days, now]`, never + in the future, and never equal to the rumor's. A regression test for §3.1, which is the + defect this port exists to avoid repeating. +- **Canonical serialisation**: content containing quotes, backslashes, newlines, control + characters, and astral-plane Unicode round-trips with a matching event id. This is where + the hand-rolled JSON of §3.2 would fail. - **Adversarial inputs**: a wrap encrypted to someone else, a corrupted ciphertext, a kind-13 with a bad signature, a kind-13 carrying tags, a rumor with a mismatched id, and a future-dated wrap. Each has a defined outcome. @@ -266,21 +416,32 @@ change and a separate decision. and read the message back. - Run with `mvn -q verify` from the repository root. -## 9. Delivery plan +## 10. Delivery plan + +The plan is a **port with corrections**, not a greenfield build. Each phase starts from the +corresponding imani-bridge class and applies the substitutions in §3.7. -1. `Kinds` constants, `Rumor` type and its JSON codec, `GenericEvent.update(long)`. -2. `Nip59GiftWrapper`: wrap and unwrap with all §5 invariants, validated against the NIP-59 - vectors. +1. `Kinds` constants, `Rumor` type and its Jackson codec, `GenericEvent.update(long)`. + Source: `Nip17Kinds`, `Rumor`, minus the hand-rolled serialisation. +2. `Nip59GiftWrapper`: wrap and unwrap with all §6 invariants, validated against the NIP-59 + vectors. Source: `SealService` + `GiftWrapBuilder`, with `EventSerializer` for JSON, + `PrivateKey.generateRandomPrivKey()` for ephemeral keys, `SecureRandom` past-only + timestamps, and the two missing security checks added. 3. `ChatMessage` (kind 14) and `Nip17DirectMessageService`, validated against the NIP-17 - vectors. -4. `DirectMessageRelayList` (kind 10050) and relay-selection on publish. + vectors. Source: `Nip17DmService`, keeping the sender-copy logic and discarding the + gateway, inbox, and mutable identity state. +4. `DirectMessageRelayList` (kind 10050) and relay-selection on publish. No prior art; + written fresh. 5. Documentation: a how-to for sending and reading private messages, and a `CHANGELOG.md` entry under `Added`. +6. **Follow-up, separate PR in imani-bridge**: replace `wallet-nip-17`'s internals with a + delegation to `nostr-java`, or delete the module. This is what closes out the timestamp + and impersonation defects in the code that is actually running today. Phases 1–3 are what the MCP module's DM tools need; phase 4 is required before any real network use, since publishing without a recipient's relay list violates the NIP. -## 10. Open questions +## 11. Open questions - Should `GiftWrapper` live in `nostr-java-identity` (with signing, as proposed) or in `nostr-java-event` (with event construction)? It genuinely needs both, and the deciding @@ -293,7 +454,14 @@ network use, since publishing without a recipient's relay list violates the NIP. next major version? - NIP-42 AUTH in `nostr-java-client` is a hard dependency for real-world DM delivery, since relays are told to gate kind-1059 behind it. Should it be pulled into this work or tracked - as its own? + as its own? Note imani-bridge already has a `wallet-nip-42` module worth reviewing the + same way. +- Should the timestamp and impersonation defects in `wallet-nip-17` (§3.1, §3.5) be patched + in place **now**, ahead of this SDK work, since that code is in production? The fixes are + small and independent of the port. +- Does `GiftWrapper` need to expose seal and wrap as separate steps for callers who want + NIP-59 without NIP-17, or is `wrap`/`unwrap` sufficient? imani-bridge split them into two + classes; this spec merges them behind one interface. ## Related documents @@ -301,3 +469,4 @@ network use, since publishing without a recipient's relay list violates the NIP. - [extending-events.md](extending-events.md) — how custom events and tags are modelled - [architecture.md](architecture.md) — module boundaries this design respects - [../howto/custom-events.md](../howto/custom-events.md) — working with custom event kinds +- `imani-bridge/wallet-plugin/wallet-nips/wallet-nip-17` — the prior implementation reviewed in §3 From 22524f0c6091c1d94ba6ccfa0253ed0a49605cf9 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Sun, 30 Aug 2026 10:23:33 +0100 Subject: [PATCH 08/15] feat(event): add Rumor type and timestamp-preserving event update Introduces the NIP-59 rumor: an unsigned event that carries direct message content before it is sealed and gift wrapped. Rumor deliberately does not implement ISignable and holds no signature field, so the deniability property NIP-59 depends on is enforced by the type system rather than by convention. Ids are derived through EventSerializer, and JSON is handled by Jackson, so canonical serialization has a single implementation. Adds GenericEvent.update(long createdAt), which recomputes the id without consulting the clock. The existing no-arg update() delegates to it, so no call site changes behaviour. Without this seam a caller cannot set the randomised past timestamp NIP-59 requires on seals and gift wraps, because computing the id silently resets it to now. Adds the NIP-17 and NIP-59 kind constants. Verified against the worked example published in NIP-59: the derived rumor id matches the specification exactly. Refs #540 --- .../src/main/java/nostr/base/Kinds.java | 36 +++ .../java/nostr/event/impl/GenericEvent.java | 27 ++- .../src/main/java/nostr/event/impl/Rumor.java | 199 +++++++++++++++ .../nostr/event/json/RumorJsonCodecTest.java | 150 ++++++++++++ .../event/unit/GenericEventUpdateTest.java | 136 +++++++++++ .../test/java/nostr/event/unit/RumorTest.java | 227 ++++++++++++++++++ 6 files changed, 772 insertions(+), 3 deletions(-) create mode 100644 nostr-java-event/src/main/java/nostr/event/impl/Rumor.java create mode 100644 nostr-java-event/src/test/java/nostr/event/json/RumorJsonCodecTest.java create mode 100644 nostr-java-event/src/test/java/nostr/event/unit/GenericEventUpdateTest.java create mode 100644 nostr-java-event/src/test/java/nostr/event/unit/RumorTest.java diff --git a/nostr-java-event/src/main/java/nostr/base/Kinds.java b/nostr-java-event/src/main/java/nostr/base/Kinds.java index c16f411d..5cee1413 100644 --- a/nostr-java-event/src/main/java/nostr/base/Kinds.java +++ b/nostr-java-event/src/main/java/nostr/base/Kinds.java @@ -20,6 +20,24 @@ 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 NIP-59 + */ + public static final int SEAL = 13; + /** + * Chat message (NIP-17): the rumor kind carrying private direct message content. + * + * @see NIP-17 + */ + public static final int CHAT_MESSAGE = 14; + /** + * File message (NIP-17): a rumor kind carrying an encrypted file reference. + * + * @see NIP-17 + */ + 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; @@ -27,6 +45,12 @@ private Kinds() {} 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 NIP-59 + */ + 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; @@ -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 NIP-17 + */ + 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 NIP-59 + */ + 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; diff --git a/nostr-java-event/src/main/java/nostr/event/impl/GenericEvent.java b/nostr-java-event/src/main/java/nostr/event/impl/GenericEvent.java index 7962ddb9..5d9c1ae9 100644 --- a/nostr-java-event/src/main/java/nostr/event/impl/GenericEvent.java +++ b/nostr-java-event/src/main/java/nostr/event/impl/GenericEvent.java @@ -146,13 +146,34 @@ public void addTag(BaseTag tag) { } } + /** + * Stamps the event with the current time, then recomputes its serialization and id. + * + *

Use {@link #update(long)} when the timestamp is significant, such as the randomised + * {@code created_at} that NIP-59 requires on seals and gift wraps. + */ public void update() { + update(Instant.now().getEpochSecond()); + } + + /** + * Recomputes this event's serialization and id against the supplied creation time. + * + *

Unlike {@link #update()} this does not consult the clock, so a deliberately chosen + * {@code created_at} survives id computation. NIP-59 requires seals and gift wraps to carry + * timestamps randomised into the past to defeat time-correlation analysis, which is + * impossible if computing the id resets the timestamp. + * + * @param createdAt Unix timestamp, in seconds, to stamp the event with + * @see NIP-59 + */ + public void update(long createdAt) { try { - this.createdAt = Instant.now().getEpochSecond(); + this.createdAt = createdAt; this._serializedEvent = - nostr.event.serializer.EventSerializer.serializeToBytes( + EventSerializer.serializeToBytes( this.pubKey, this.createdAt, this.kind, this.tags, this.content); - this.id = nostr.event.serializer.EventSerializer.computeEventId(this._serializedEvent); + this.id = EventSerializer.computeEventId(this._serializedEvent); } catch (NostrException ex) { log.warn("Failed to update event during serialization: {}", ex.getMessage(), ex); throw new RuntimeException("Event update failed", ex); diff --git a/nostr-java-event/src/main/java/nostr/event/impl/Rumor.java b/nostr-java-event/src/main/java/nostr/event/impl/Rumor.java new file mode 100644 index 00000000..8f961f74 --- /dev/null +++ b/nostr-java-event/src/main/java/nostr/event/impl/Rumor.java @@ -0,0 +1,199 @@ +package nostr.event.impl; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import lombok.NonNull; +import nostr.base.PublicKey; +import nostr.event.BaseTag; +import nostr.event.json.deserializer.PublicKeyDeserializer; +import nostr.event.serializer.EventSerializer; +import nostr.event.tag.GenericTag; +import nostr.util.NostrException; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * An unsigned Nostr event, as defined by NIP-59. + * + *

A rumor carries content and identifies its author, but carries no signature. That absence is + * the point: a leaked rumor cannot be authenticated, which gives its author deniability, and + * relays reject it. Rumors travel only inside a seal, which is signed and encrypted. + * + *

This type deliberately does not extend {@link GenericEvent} and does not implement {@code + * ISignable}. A rumor that acquired a signature would defeat NIP-59, so the type system forbids + * it rather than relying on callers to remember. + * + *

Instances are immutable. The {@code id} is derived from the remaining fields using the + * NIP-01 canonical serialization, so it is computed rather than supplied. + * + * @see NIP-59 + * @see NIP-17 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class Rumor { + + @JsonProperty("id") + private final String id; + + @JsonProperty("pubkey") + @JsonDeserialize(using = PublicKeyDeserializer.class) + private final PublicKey pubKey; + + @JsonProperty("created_at") + private final Long createdAt; + + @JsonProperty("kind") + private final Integer kind; + + @JsonProperty("tags") + private final List tags; + + @JsonProperty("content") + private final String content; + + /** + * Reconstructs a rumor from its parts, typically after decrypting a seal. + * + *

The supplied id is retained as-is so that a received rumor can be checked against a + * recomputed id; use {@link #hasValidId()} to perform that check. + * + * @param id the event id carried by the rumor, or {@code null} to derive one + * @param pubKey the author's public key + * @param createdAt Unix timestamp in seconds + * @param kind the event kind, typically {@link nostr.base.Kinds#CHAT_MESSAGE} + * @param tags the event tags; may be empty but not null + * @param content the message content + */ + @JsonCreator + public Rumor( + @JsonProperty("id") String id, + @JsonProperty("pubkey") @NonNull PublicKey pubKey, + @JsonProperty("created_at") @NonNull Long createdAt, + @JsonProperty("kind") @NonNull Integer kind, + @JsonProperty("tags") @NonNull List tags, + @JsonProperty("content") @NonNull String content) { + this.pubKey = pubKey; + this.createdAt = createdAt; + this.kind = kind; + this.tags = List.copyOf(tags); + this.content = content; + this.id = id != null ? id : computeId(pubKey, createdAt, kind, this.tags, content); + } + + /** + * Creates a rumor stamped with the current time and a derived id. + * + * @param pubKey the author's public key + * @param kind the event kind, typically {@link nostr.base.Kinds#CHAT_MESSAGE} + * @param tags the event tags; may be empty but not null + * @param content the message content + * @return a new rumor whose id is derived from its contents + */ + public static Rumor create( + @NonNull PublicKey pubKey, + @NonNull Integer kind, + @NonNull List tags, + @NonNull String content) { + return new Rumor(null, pubKey, Instant.now().getEpochSecond(), kind, tags, content); + } + + public String getId() { + return id; + } + + public PublicKey getPubKey() { + return pubKey; + } + + public Long getCreatedAt() { + return createdAt; + } + + public Integer getKind() { + return kind; + } + + public List getTags() { + return Collections.unmodifiableList(tags); + } + + public String getContent() { + return content; + } + + /** + * Reports whether this rumor's id matches the id derived from its contents. + * + *

A received rumor arrives with an id chosen by whoever sealed it. Recomputing that id + * detects a rumor whose contents were altered after its id was set. + * + * @return true when the carried id matches the derived id + */ + @JsonIgnore + public boolean hasValidId() { + return computeId(pubKey, createdAt, kind, tags, content).equals(id); + } + + /** + * Returns the public keys named by this rumor's {@code p} tags, in order. + * + *

For a NIP-17 chat message these are the recipients of the conversation. + * + * @return the referenced public keys; empty when the rumor has no {@code p} tags + */ + @JsonIgnore + public List getReferencedPublicKeys() { + List referenced = new ArrayList<>(); + for (BaseTag tag : tags) { + if (tag instanceof GenericTag generic + && "p".equals(generic.getCode()) + && !generic.getParams().isEmpty()) { + referenced.add(new PublicKey(generic.getParams().get(0))); + } + } + return referenced; + } + + private static String computeId( + PublicKey pubKey, Long createdAt, Integer kind, List tags, String content) { + try { + return EventSerializer.computeEventId( + EventSerializer.serializeToBytes(pubKey, createdAt, kind, tags, content)); + } catch (NostrException ex) { + throw new IllegalStateException("Failed to compute rumor id", ex); + } + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof Rumor rumor)) { + return false; + } + return Objects.equals(id, rumor.id) + && Objects.equals(pubKey, rumor.pubKey) + && Objects.equals(createdAt, rumor.createdAt) + && Objects.equals(kind, rumor.kind) + && Objects.equals(tags, rumor.tags) + && Objects.equals(content, rumor.content); + } + + @Override + public int hashCode() { + return Objects.hash(id, pubKey, createdAt, kind, tags, content); + } + + @Override + public String toString() { + return "Rumor(id=" + id + ", pubKey=" + pubKey + ", kind=" + kind + ")"; + } +} diff --git a/nostr-java-event/src/test/java/nostr/event/json/RumorJsonCodecTest.java b/nostr-java-event/src/test/java/nostr/event/json/RumorJsonCodecTest.java new file mode 100644 index 00000000..c8ee9f00 --- /dev/null +++ b/nostr-java-event/src/test/java/nostr/event/json/RumorJsonCodecTest.java @@ -0,0 +1,150 @@ +package nostr.event.json; + +import com.fasterxml.jackson.databind.ObjectMapper; +import nostr.base.Kinds; +import nostr.base.PublicKey; +import nostr.event.BaseTag; +import nostr.event.impl.Rumor; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that a rumor survives a JSON round trip with its id intact. + * + *

A rumor is serialized to JSON before being encrypted into a seal and parsed back out after + * decryption, so any escaping mismatch between the two directions corrupts a message or breaks + * its id. These tests exercise the content that a hand-written escaper typically mishandles. + * + * @see NIP-59 + */ +class RumorJsonCodecTest { + + private static final ObjectMapper MAPPER = EventJsonMapper.getMapper(); + + private static final PublicKey AUTHOR = + new PublicKey("611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9"); + + private static Rumor roundTrip(Rumor rumor) throws Exception { + return MAPPER.readValue(MAPPER.writeValueAsString(rumor), Rumor.class); + } + + /** A plain rumor survives serialization and deserialization unchanged. */ + @Test + @DisplayName("round-trips a plain rumor unchanged") + void roundTripsPlainRumor() throws Exception { + Rumor original = + Rumor.create( + AUTHOR, + Kinds.CHAT_MESSAGE, + List.of(BaseTag.create("p", AUTHOR.toString())), + "Are you going to the party tonight?"); + + Rumor parsed = roundTrip(original); + + assertEquals(original, parsed); + assertTrue(parsed.hasValidId()); + } + + /** + * Content containing quotes, backslashes, newlines, control characters, and astral-plane + * Unicode round-trips with a matching id. This is precisely where a hand-written escaper + * fails, producing an event whose id does not match its content. + */ + @Test + @DisplayName("round-trips content that a naive escaper would corrupt") + void roundTripsContentNeedingEscaping() throws Exception { + String awkward = + "quote\" backslash\\ slash/ newline\n carriage\r tab\t " + + "control\u0001 null-ish\u0000 unicode\u00e9 astral\uD83D\uDE80"; + + Rumor original = Rumor.create(AUTHOR, Kinds.CHAT_MESSAGE, List.of(), awkward); + Rumor parsed = roundTrip(original); + + assertEquals(awkward, parsed.getContent()); + assertEquals(original.getId(), parsed.getId()); + assertTrue(parsed.hasValidId(), "id must still verify after a JSON round trip"); + } + + /** Tag values needing escaping survive the round trip, since tags contribute to the id. */ + @Test + @DisplayName("round-trips tag values that need escaping") + void roundTripsTagValuesNeedingEscaping() throws Exception { + Rumor original = + Rumor.create( + AUTHOR, + Kinds.CHAT_MESSAGE, + List.of(BaseTag.create("subject", "re: \"dinner\"\tand\ndrinks")), + "hello"); + + Rumor parsed = roundTrip(original); + + assertEquals(original.getId(), parsed.getId()); + assertTrue(parsed.hasValidId()); + } + + /** An empty message body is valid and keeps a verifiable id. */ + @Test + @DisplayName("round-trips empty content") + void roundTripsEmptyContent() throws Exception { + Rumor original = Rumor.create(AUTHOR, Kinds.CHAT_MESSAGE, List.of(), ""); + + Rumor parsed = roundTrip(original); + + assertEquals("", parsed.getContent()); + assertTrue(parsed.hasValidId()); + } + + /** A large message, up to the NIP-44 maximum plaintext size, round-trips intact. */ + @Test + @DisplayName("round-trips content at the NIP-44 maximum plaintext size") + void roundTripsMaximumSizeContent() throws Exception { + String large = "x".repeat(65_535); + + Rumor original = Rumor.create(AUTHOR, Kinds.CHAT_MESSAGE, List.of(), large); + Rumor parsed = roundTrip(original); + + assertEquals(large.length(), parsed.getContent().length()); + assertTrue(parsed.hasValidId()); + } + + /** + * The serialized form uses the wire field names from NIP-01 and carries no signature field, + * since a rumor has none to carry. + */ + @Test + @DisplayName("serializes NIP-01 wire field names and no signature") + void serializesWireFieldNames() throws Exception { + Rumor rumor = Rumor.create(AUTHOR, Kinds.CHAT_MESSAGE, List.of(), "hello"); + + String json = MAPPER.writeValueAsString(rumor); + + assertTrue(json.contains("\"pubkey\"")); + assertTrue(json.contains("\"created_at\"")); + assertTrue(json.contains("\"kind\"")); + assertTrue(json.contains("\"tags\"")); + assertTrue(json.contains("\"content\"")); + assertFalse(json.contains("\"sig\""), "a rumor must never carry a signature"); + } + + /** A rumor arriving without an id gets one derived, so incoming events are always identified. */ + @Test + @DisplayName("derives an id when the incoming JSON omits one") + void derivesIdWhenAbsentFromJson() throws Exception { + String json = + "{\"pubkey\":\"" + + AUTHOR + + "\",\"created_at\":1691518405,\"kind\":1,\"tags\":[]," + + "\"content\":\"Are you going to the party tonight?\"}"; + + Rumor parsed = MAPPER.readValue(json, Rumor.class); + + assertEquals( + "9dd003c6d3b73b74a85a9ab099469ce251653a7af76f523671ab828acd2a0ef9", parsed.getId()); + } +} diff --git a/nostr-java-event/src/test/java/nostr/event/unit/GenericEventUpdateTest.java b/nostr-java-event/src/test/java/nostr/event/unit/GenericEventUpdateTest.java new file mode 100644 index 00000000..e9a4ba98 --- /dev/null +++ b/nostr-java-event/src/test/java/nostr/event/unit/GenericEventUpdateTest.java @@ -0,0 +1,136 @@ +package nostr.event.unit; + +import nostr.base.Kinds; +import nostr.base.PublicKey; +import nostr.event.BaseTag; +import nostr.event.impl.GenericEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that computing an event id can preserve a deliberately chosen creation time. + * + *

NIP-59 requires seals and gift wraps to carry timestamps randomised into the past. That is + * impossible if computing the id resets the timestamp to the current time, so {@code + * update(long)} exists to keep the two concerns separate. + * + * @see NIP-59 + */ +class GenericEventUpdateTest { + + private static final PublicKey AUTHOR = + new PublicKey("611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9"); + + private static GenericEvent anEvent() { + GenericEvent event = new GenericEvent(AUTHOR, Kinds.SEAL); + event.setContent("encrypted-payload"); + return event; + } + + /** A supplied timestamp survives id computation, which is what gift wrapping depends on. */ + @Test + @DisplayName("keeps the supplied created_at when computing the id") + void keepsSuppliedCreatedAt() { + long twoDaysAgo = Instant.now().minusSeconds(2 * 24 * 60 * 60).getEpochSecond(); + GenericEvent event = anEvent(); + + event.update(twoDaysAgo); + + assertEquals(twoDaysAgo, event.getCreatedAt()); + } + + /** The id computed against a supplied timestamp is a real id, derived from that timestamp. */ + @Test + @DisplayName("derives an id that reflects the supplied created_at") + void derivesIdFromSuppliedCreatedAt() { + GenericEvent earlier = anEvent(); + GenericEvent later = anEvent(); + + earlier.update(1_700_000_000L); + later.update(1_700_000_001L); + + assertNotNull(earlier.getId()); + assertEquals(64, earlier.getId().length()); + assertNotEquals(earlier.getId(), later.getId()); + } + + /** Updating twice with the same timestamp is deterministic, so ids are reproducible. */ + @Test + @DisplayName("computes the same id for the same created_at") + void computesSameIdForSameCreatedAt() { + GenericEvent first = anEvent(); + GenericEvent second = anEvent(); + + first.update(1_700_000_000L); + second.update(1_700_000_000L); + + assertEquals(first.getId(), second.getId()); + } + + /** + * The no-argument overload still stamps the event with the current time, so existing callers + * are unaffected by the new seam. + */ + @Test + @DisplayName("still stamps the current time when no timestamp is supplied") + void stampsCurrentTimeWithoutArgument() { + long before = Instant.now().getEpochSecond(); + GenericEvent event = anEvent(); + + event.update(); + + long after = Instant.now().getEpochSecond(); + assertTrue(event.getCreatedAt() >= before && event.getCreatedAt() <= after); + } + + /** + * A previously chosen timestamp is discarded by the no-argument overload. This is the + * behaviour that silently defeats gift-wrap privacy, so it is pinned here to document why + * {@code update(long)} must be used for seals and wraps. + */ + @Test + @DisplayName("overwrites a preset created_at when no timestamp is supplied") + void overwritesPresetCreatedAtWithoutArgument() { + long twoDaysAgo = Instant.now().minusSeconds(2 * 24 * 60 * 60).getEpochSecond(); + GenericEvent event = anEvent(); + event.setCreatedAt(twoDaysAgo); + + event.update(); + + assertNotEquals(twoDaysAgo, event.getCreatedAt()); + } + + /** Tags are included in the serialization that backs the id. */ + @Test + @DisplayName("includes tags in the id computed against a supplied created_at") + void includesTagsInId() { + GenericEvent untagged = anEvent(); + GenericEvent tagged = anEvent(); + tagged.setTags(List.of(BaseTag.create("p", AUTHOR.toString()))); + + untagged.update(1_700_000_000L); + tagged.update(1_700_000_000L); + + assertNotEquals(untagged.getId(), tagged.getId()); + } + + /** The cached serialization is refreshed alongside the id, so signing sees the new bytes. */ + @Test + @DisplayName("refreshes the cached serialization") + void refreshesCachedSerialization() { + GenericEvent event = anEvent(); + + event.update(1_700_000_000L); + + assertNotNull(event.getSerializedEventCache()); + assertTrue(new String(event.getSerializedEventCache()).contains("1700000000")); + } +} diff --git a/nostr-java-event/src/test/java/nostr/event/unit/RumorTest.java b/nostr-java-event/src/test/java/nostr/event/unit/RumorTest.java new file mode 100644 index 00000000..b526bcd3 --- /dev/null +++ b/nostr-java-event/src/test/java/nostr/event/unit/RumorTest.java @@ -0,0 +1,227 @@ +package nostr.event.unit; + +import nostr.base.ISignable; +import nostr.base.Kinds; +import nostr.base.PublicKey; +import nostr.event.BaseTag; +import nostr.event.impl.Rumor; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the unsigned rumor type introduced for NIP-59 gift wrapping. + * + * @see NIP-59 + */ +class RumorTest { + + /** The worked example published in NIP-59, section "An Example". */ + private static final PublicKey NIP59_AUTHOR = + new PublicKey("611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9"); + + private static final long NIP59_CREATED_AT = 1691518405L; + private static final String NIP59_CONTENT = "Are you going to the party tonight?"; + private static final String NIP59_EXPECTED_ID = + "9dd003c6d3b73b74a85a9ab099469ce251653a7af76f523671ab828acd2a0ef9"; + + /** + * The id derived for the rumor published in NIP-59 matches the id the spec states, which pins + * canonical serialization to the specification rather than to our reading of it. + */ + @Test + @DisplayName("derives the exact event id published in the NIP-59 example") + void derivesIdFromNip59PublishedVector() { + Rumor rumor = + new Rumor(null, NIP59_AUTHOR, NIP59_CREATED_AT, Kinds.TEXT_NOTE, List.of(), NIP59_CONTENT); + + assertEquals(NIP59_EXPECTED_ID, rumor.getId()); + } + + /** A rumor reconstructed with the id the spec publishes reports that id as valid. */ + @Test + @DisplayName("accepts a rumor whose carried id matches its contents") + void acceptsMatchingId() { + Rumor rumor = + new Rumor( + NIP59_EXPECTED_ID, + NIP59_AUTHOR, + NIP59_CREATED_AT, + Kinds.TEXT_NOTE, + List.of(), + NIP59_CONTENT); + + assertTrue(rumor.hasValidId()); + } + + /** + * A rumor whose content was altered after its id was set is detected, which is what stops a + * tampered rumor being accepted on the receive path. + */ + @Test + @DisplayName("rejects a rumor whose carried id does not match its contents") + void rejectsTamperedId() { + Rumor tampered = + new Rumor( + NIP59_EXPECTED_ID, + NIP59_AUTHOR, + NIP59_CREATED_AT, + Kinds.TEXT_NOTE, + List.of(), + "Are you going to the party tomorrow?"); + + assertFalse(tampered.hasValidId()); + } + + /** + * Content containing characters that a naive JSON escaper mishandles still produces an id that + * validates, since the id is a hash over the escaped form. + */ + @Test + @DisplayName("derives a stable id for content needing JSON escaping") + void derivesStableIdForContentNeedingEscaping() { + String awkward = "quote\" backslash\\ newline\n tab\t control\u0001 astral\uD83D\uDE80"; + + Rumor rumor = Rumor.create(NIP59_AUTHOR, Kinds.CHAT_MESSAGE, List.of(), awkward); + Rumor reconstructed = + new Rumor( + rumor.getId(), + NIP59_AUTHOR, + rumor.getCreatedAt(), + Kinds.CHAT_MESSAGE, + List.of(), + awkward); + + assertTrue(reconstructed.hasValidId()); + assertEquals(rumor.getId(), reconstructed.getId()); + } + + /** Tags participate in the id, so two rumors differing only by tags get different ids. */ + @Test + @DisplayName("includes tags in the derived id") + void includesTagsInDerivedId() { + List withRecipient = + List.of(BaseTag.create("p", NIP59_AUTHOR.toString())); + + Rumor untagged = + new Rumor(null, NIP59_AUTHOR, NIP59_CREATED_AT, Kinds.CHAT_MESSAGE, List.of(), "hello"); + Rumor tagged = + new Rumor( + null, NIP59_AUTHOR, NIP59_CREATED_AT, Kinds.CHAT_MESSAGE, withRecipient, "hello"); + + assertNotEquals(untagged.getId(), tagged.getId()); + } + + /** The recipients of a chat message are read back from its {@code p} tags, in order. */ + @Test + @DisplayName("reads recipients from p tags in order") + void readsRecipientsFromPTags() { + PublicKey first = + new PublicKey("918e2da906df4ccd12c8ac672d8335add131a4cf9d27ce42b3bb3625755f0788"); + PublicKey second = + new PublicKey("166bf3765ebd1fc55decfe395beff2ea3b2a4e0a8946e7eb578512b555737c99"); + + Rumor rumor = + Rumor.create( + NIP59_AUTHOR, + Kinds.CHAT_MESSAGE, + List.of( + BaseTag.create("p", first.toString()), + BaseTag.create("subject", "Dinner"), + BaseTag.create("p", second.toString())), + "hello"); + + assertEquals(List.of(first, second), rumor.getReferencedPublicKeys()); + } + + /** A rumor without p tags reports no recipients rather than failing. */ + @Test + @DisplayName("reports no recipients when the rumor has no p tags") + void reportsNoRecipientsWithoutPTags() { + Rumor rumor = Rumor.create(NIP59_AUTHOR, Kinds.CHAT_MESSAGE, List.of(), "hello"); + + assertTrue(rumor.getReferencedPublicKeys().isEmpty()); + } + + /** + * The tag list handed to a rumor is copied, so a caller mutating their list afterwards cannot + * change the rumor's contents behind its already-computed id. + */ + @Test + @DisplayName("copies the supplied tags so later caller mutation cannot invalidate the id") + void copiesSuppliedTags() { + List mutable = new ArrayList<>(); + mutable.add(BaseTag.create("p", NIP59_AUTHOR.toString())); + + Rumor rumor = Rumor.create(NIP59_AUTHOR, Kinds.CHAT_MESSAGE, mutable, "hello"); + mutable.clear(); + + assertEquals(1, rumor.getTags().size()); + assertTrue(rumor.hasValidId()); + } + + /** The tag list a rumor hands out cannot be modified, keeping the instance immutable. */ + @Test + @DisplayName("exposes tags as an unmodifiable list") + void exposesUnmodifiableTags() { + Rumor rumor = + Rumor.create( + NIP59_AUTHOR, Kinds.CHAT_MESSAGE, List.of(BaseTag.create("p", "x")), "hello"); + + assertThrows( + UnsupportedOperationException.class, () -> rumor.getTags().add(BaseTag.create("e", "y"))); + } + + /** + * A rumor exposes no signature accessor. NIP-59 depends on rumors being unsignable, so this + * asserts the absence is a property of the type rather than a convention. + */ + @Test + @DisplayName("has no signature member and is not signable") + void hasNoSignatureMember() { + boolean declaresSignature = + Arrays.stream(Rumor.class.getDeclaredFields()) + .anyMatch(field -> field.getType().getSimpleName().contains("Signature")); + boolean exposesSignatureAccessor = + Arrays.stream(Rumor.class.getMethods()) + .anyMatch(method -> method.getName().toLowerCase().contains("sign")); + + assertFalse(declaresSignature, "Rumor must not hold a signature"); + assertFalse(exposesSignatureAccessor, "Rumor must not expose a signing or signature method"); + assertFalse( + ISignable.class.isAssignableFrom(Rumor.class), + "Rumor must not be signable"); + } + + /** Two rumors built from identical inputs are equal, so they can be compared after a round trip. */ + @Test + @DisplayName("treats rumors with identical contents as equal") + void treatsIdenticalRumorsAsEqual() { + Rumor first = + new Rumor(null, NIP59_AUTHOR, NIP59_CREATED_AT, Kinds.CHAT_MESSAGE, List.of(), "hello"); + Rumor second = + new Rumor(null, NIP59_AUTHOR, NIP59_CREATED_AT, Kinds.CHAT_MESSAGE, List.of(), "hello"); + + assertEquals(first, second); + assertEquals(first.hashCode(), second.hashCode()); + } + + /** A rumor's string form must not disclose its content, which travels encrypted. */ + @Test + @DisplayName("omits content from toString") + void omitsContentFromToString() { + Rumor rumor = + Rumor.create(NIP59_AUTHOR, Kinds.CHAT_MESSAGE, List.of(), "meet me at the usual place"); + + assertFalse(rumor.toString().contains("usual place")); + } +} From b66f13954360608fecc7e80002da5773066999ca Mon Sep 17 00:00:00 2001 From: tcheeric Date: Sun, 30 Aug 2026 10:29:54 +0100 Subject: [PATCH 09/15] feat(identity): implement NIP-59 gift wrapping Adds GiftWrapper, whose two methods hide a rumor and reveal it, and Nip59GiftWrapper, which seals a rumor to its recipient and wraps the seal under a key generated for that one event and then discarded. The receive path authenticates before it returns. A rumor carries no signature, so the seal's signature is the only evidence of authorship: it is verified, and the rumor's author is compared against the sealing key. Without that comparison anyone could attribute a message to anyone by editing an unsigned field, which NIP-17 calls out explicitly. The rumor id is recomputed as a third check. Timestamps are drawn from SecureRandom over the two days preceding the event and never move into the future, so wraps cannot be correlated by time and relays will not drop them for being future-dated. Also fixes GenericEvent.getByteArraySupplier, which called update() and so reset created_at during signing. That silently discarded any deliberately chosen timestamp, making correct gift wrapping impossible even for a caller using update(long). Verified against the worked example published in NIP-59: wrapping its rumor with the published ephemeral key reproduces the published wrap author, and the resulting event opens to the exact rumor id the specification states. Refs #541 --- .../java/nostr/event/impl/GenericEvent.java | 14 +- .../nostr/encryption/GiftWrapException.java | 17 + .../java/nostr/encryption/GiftWrapper.java | 55 +++ .../nostr/encryption/Nip59GiftWrapper.java | 272 ++++++++++++ .../encryption/Nip59GiftWrapperTest.java | 390 ++++++++++++++++++ 5 files changed, 747 insertions(+), 1 deletion(-) create mode 100644 nostr-java-identity/src/main/java/nostr/encryption/GiftWrapException.java create mode 100644 nostr-java-identity/src/main/java/nostr/encryption/GiftWrapper.java create mode 100644 nostr-java-identity/src/main/java/nostr/encryption/Nip59GiftWrapper.java create mode 100644 nostr-java-identity/src/test/java/nostr/encryption/Nip59GiftWrapperTest.java diff --git a/nostr-java-event/src/main/java/nostr/event/impl/GenericEvent.java b/nostr-java-event/src/main/java/nostr/event/impl/GenericEvent.java index 5d9c1ae9..7adf106f 100644 --- a/nostr-java-event/src/main/java/nostr/event/impl/GenericEvent.java +++ b/nostr-java-event/src/main/java/nostr/event/impl/GenericEvent.java @@ -265,10 +265,22 @@ public Consumer getSignatureConsumer() { return this::setSignature; } + /** + * Supplies the canonical bytes that a signature is computed over. + * + *

Serialization is refreshed first so the signature covers the event's current contents. + * An event that already carries a creation time keeps it, because signing must not silently + * move an event in time: NIP-59 seals and gift wraps depend on their randomised timestamps + * surviving all the way to the wire. + */ @Transient @Override public Supplier getByteArraySupplier() { - this.update(); + if (this.createdAt != null) { + this.update(this.createdAt); + } else { + this.update(); + } if (log.isTraceEnabled()) { log.trace("Serialized event: {}", new String(this.get_serializedEvent())); } diff --git a/nostr-java-identity/src/main/java/nostr/encryption/GiftWrapException.java b/nostr-java-identity/src/main/java/nostr/encryption/GiftWrapException.java new file mode 100644 index 00000000..61a26ed6 --- /dev/null +++ b/nostr-java-identity/src/main/java/nostr/encryption/GiftWrapException.java @@ -0,0 +1,17 @@ +package nostr.encryption; + +import lombok.experimental.StandardException; +import nostr.util.exception.NostrCryptoException; + +/** + * Signals that an event could not be gift wrapped, or that an incoming gift wrap was rejected. + * + *

Rejection is not always a fault. A subscription for kind-1059 events delivers wraps + * addressed to other recipients, and those cannot be opened by design. Callers reading an inbox + * should skip a wrap that raises this rather than abandoning the batch, so that one unopenable + * or malicious event cannot stall an entire conversation. + * + * @see NIP-59 + */ +@StandardException +public class GiftWrapException extends NostrCryptoException {} diff --git a/nostr-java-identity/src/main/java/nostr/encryption/GiftWrapper.java b/nostr-java-identity/src/main/java/nostr/encryption/GiftWrapper.java new file mode 100644 index 00000000..597696be --- /dev/null +++ b/nostr-java-identity/src/main/java/nostr/encryption/GiftWrapper.java @@ -0,0 +1,55 @@ +package nostr.encryption; + +import nostr.base.PublicKey; +import nostr.event.impl.GenericEvent; +import nostr.event.impl.Rumor; + +/** + * Hides an event's author, content, and metadata behind the NIP-59 gift wrap. + * + *

A gift wrap conceals a message in three layers. The innermost is an unsigned {@link Rumor} + * carrying the content. It is encrypted into a kind-13 seal signed by its real author, which + * proves authorship without revealing the recipient. The seal is encrypted again into a + * kind-1059 gift wrap signed by a single-use key, which reveals nothing about the author. An + * observer sees only that some random key addressed some event to a recipient. + * + *

Callers never handle a seal, an ephemeral key, or a conversation key. Two methods hide a + * rumor and reveal it; everything between is an implementation concern. + * + *

NIP-59 defines the envelope, not what travels inside it. Any event kind may be wrapped, so + * this interface is useful beyond the private direct messages of NIP-17. + * + * @see NIP-59 + */ +public interface GiftWrapper { + + /** + * Seals a rumor and wraps it for one recipient. + * + *

The returned event is signed by a freshly generated key that is used once and discarded, + * so two wraps of the same rumor cannot be linked to each other or to their author. Both the + * seal and the wrap carry timestamps randomised into the past. + * + *

To reach several recipients, call this once per recipient. Each call produces an + * independently encrypted event, which is what keeps the recipient list private. + * + * @param rumor the unsigned event to conceal + * @param recipient the public key that will be able to open the wrap + * @return a signed gift wrap ready to publish + * @throws GiftWrapException if the rumor cannot be sealed or wrapped + */ + GenericEvent wrap(Rumor rumor, PublicKey recipient); + + /** + * Opens a gift wrap addressed to this identity and returns the rumor inside. + * + *

The seal's signature is verified and its author is checked against the rumor's author + * before the rumor is returned, so a rumor obtained here has been authenticated. A rumor is + * unsigned, which means the seal's signature is the only evidence of who wrote it. + * + * @param giftWrap a kind-1059 or kind-21059 event addressed to this identity + * @return the authenticated rumor + * @throws GiftWrapException if the wrap cannot be opened, or if it fails authentication + */ + Rumor unwrap(GenericEvent giftWrap); +} diff --git a/nostr-java-identity/src/main/java/nostr/encryption/Nip59GiftWrapper.java b/nostr-java-identity/src/main/java/nostr/encryption/Nip59GiftWrapper.java new file mode 100644 index 00000000..f71844e1 --- /dev/null +++ b/nostr-java-identity/src/main/java/nostr/encryption/Nip59GiftWrapper.java @@ -0,0 +1,272 @@ +package nostr.encryption; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.NonNull; +import nostr.base.Kinds; +import nostr.base.PrivateKey; +import nostr.base.PublicKey; +import nostr.base.Signature; +import nostr.crypto.schnorr.Schnorr; +import nostr.crypto.schnorr.SchnorrException; +import nostr.event.BaseTag; +import nostr.event.impl.GenericEvent; +import nostr.event.impl.Rumor; +import nostr.event.json.EventJsonMapper; +import nostr.event.serializer.EventSerializer; +import nostr.id.Identity; +import nostr.util.NostrException; +import nostr.util.NostrUtil; + +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.List; + +/** + * Gift wraps events according to NIP-59. + * + *

Each call to {@link #wrap} generates a key that signs exactly one event and is then + * discarded. Reusing such a key across recipients would let an observer link the wraps back + * together and identify a conversation, which is the very thing the wrap exists to prevent. + * + *

Timestamps on the seal and the wrap are drawn independently from the two days preceding + * now. NIP-59 requires them to be randomised so that events cannot be correlated by time, and + * requires them to be in the past because relays commonly reject future-dated events. + * + * @see NIP-59 + */ +public class Nip59GiftWrapper implements GiftWrapper { + + private static final long TWO_DAYS_IN_SECONDS = 2 * 24 * 60 * 60L; + private static final ObjectMapper MAPPER = EventJsonMapper.getMapper(); + + private final Identity identity; + private final int wrapKind; + private final EphemeralKeySource ephemeralKeySource; + private final TimestampRandomizer timestampRandomizer; + + /** + * Creates a wrapper that produces stored gift wraps for the given identity. + * + * @param identity the identity that seals rumors and opens wraps addressed to it + */ + public Nip59GiftWrapper(@NonNull Identity identity) { + this(identity, Kinds.GIFT_WRAP); + } + + /** + * Creates a wrapper that produces gift wraps of a chosen kind. + * + *

Use {@link Kinds#GIFT_WRAP} for messages that should be stored and delivered later, and + * {@link Kinds#EPHEMERAL_GIFT_WRAP} for real-time exchanges that relays must not retain. + * + * @param identity the identity that seals rumors and opens wraps addressed to it + * @param wrapKind the kind to stamp on the outer event + */ + public Nip59GiftWrapper(@NonNull Identity identity, int wrapKind) { + this(identity, wrapKind, Identity::generateRandomIdentity, new PastTwoDaysRandomizer()); + } + + /** + * Creates a wrapper with explicit sources of randomness. + * + *

Intended for tests that reproduce the worked examples published in NIP-17 and NIP-59, + * which fix the ephemeral key and both timestamps. + * + * @param identity the identity that seals rumors and opens wraps addressed to it + * @param wrapKind the kind to stamp on the outer event + * @param ephemeralKeySource supplies the single-use key that signs each wrap + * @param timestampRandomizer supplies the randomised timestamps for the seal and the wrap + */ + public Nip59GiftWrapper( + @NonNull Identity identity, + int wrapKind, + @NonNull EphemeralKeySource ephemeralKeySource, + @NonNull TimestampRandomizer timestampRandomizer) { + this.identity = identity; + this.wrapKind = wrapKind; + this.ephemeralKeySource = ephemeralKeySource; + this.timestampRandomizer = timestampRandomizer; + } + + @Override + public GenericEvent wrap(@NonNull Rumor rumor, @NonNull PublicKey recipient) { + GenericEvent seal = seal(rumor, recipient); + Identity ephemeral = ephemeralKeySource.generate(); + + String encryptedSeal = encrypt(toJson(seal), ephemeral.getPrivateKey(), recipient); + + GenericEvent giftWrap = new GenericEvent(ephemeral.getPublicKey(), wrapKind); + giftWrap.setContent(encryptedSeal); + giftWrap.setTags(List.of(BaseTag.create("p", recipient.toString()))); + giftWrap.update(timestampRandomizer.randomizeFrom(Instant.now().getEpochSecond())); + ephemeral.sign(giftWrap); + + return giftWrap; + } + + @Override + public Rumor unwrap(@NonNull GenericEvent giftWrap) { + GenericEvent seal = openSeal(giftWrap); + verifySealSignature(seal); + + Rumor rumor = decryptRumor(seal); + verifyAuthorMatchesSeal(rumor, seal); + verifyRumorId(rumor); + + return rumor; + } + + /** + * Encrypts a rumor to its recipient and signs the result as a kind-13 seal. + * + *

The seal carries no tags. Anything placed on it is visible to an observer who holds the + * gift wrap, so tags here would leak the very metadata the wrap conceals. + */ + private GenericEvent seal(Rumor rumor, PublicKey recipient) { + String encryptedRumor = encrypt(toJson(rumor), identity.getPrivateKey(), recipient); + + GenericEvent seal = new GenericEvent(identity.getPublicKey(), Kinds.SEAL); + seal.setContent(encryptedRumor); + seal.setTags(List.of()); + seal.update(timestampRandomizer.randomizeFrom(rumor.getCreatedAt())); + identity.sign(seal); + + return seal; + } + + private GenericEvent openSeal(GenericEvent giftWrap) { + String sealJson = + decrypt(giftWrap.getContent(), identity.getPrivateKey(), giftWrap.getPubKey()); + GenericEvent seal = parse(sealJson, GenericEvent.class, "seal"); + + if (!Integer.valueOf(Kinds.SEAL).equals(seal.getKind())) { + throw new GiftWrapException("Gift wrap did not contain a kind-13 seal"); + } + return seal; + } + + private Rumor decryptRumor(GenericEvent seal) { + String rumorJson = decrypt(seal.getContent(), identity.getPrivateKey(), seal.getPubKey()); + return parse(rumorJson, Rumor.class, "rumor"); + } + + /** + * Confirms the seal was signed by the key it claims. + * + *

A rumor carries no signature, so the seal's signature is the only evidence of who wrote + * the message. An unverified seal is an unauthenticated message. + */ + private void verifySealSignature(GenericEvent seal) { + Signature signature = seal.getSignature(); + if (signature == null) { + throw new GiftWrapException("Seal carried no signature"); + } + + try { + byte[] serialized = + EventSerializer.serializeToBytes( + seal.getPubKey(), + seal.getCreatedAt(), + seal.getKind(), + seal.getTags(), + seal.getContent()); + boolean valid = + Schnorr.verify( + NostrUtil.sha256(serialized), + seal.getPubKey().getRawData(), + signature.getRawData()); + if (!valid) { + throw new GiftWrapException("Seal signature did not verify"); + } + } catch (SchnorrException | NoSuchAlgorithmException | NostrException ex) { + throw new GiftWrapException("Could not verify seal signature", ex); + } + } + + /** + * Confirms the rumor names the same author as the seal that carried it. + * + *

Without this check anyone could attribute a message to anyone else by editing the + * rumor's author before sealing it, because only the seal is signed. NIP-17 requires the + * comparison for exactly this reason. + */ + private void verifyAuthorMatchesSeal(Rumor rumor, GenericEvent seal) { + if (!rumor.getPubKey().equals(seal.getPubKey())) { + throw new GiftWrapException( + "Rumor author does not match the sealing key; the message is forged"); + } + } + + /** Confirms the rumor's contents still hash to the id it carries. */ + private void verifyRumorId(Rumor rumor) { + if (!rumor.hasValidId()) { + throw new GiftWrapException("Rumor id does not match its contents"); + } + } + + private String encrypt(String plaintext, PrivateKey sender, PublicKey recipient) { + return new MessageCipher44(sender.getRawData(), recipient.getRawData()).encrypt(plaintext); + } + + private String decrypt(String payload, PrivateKey self, PublicKey other) { + try { + return new MessageCipher44(self.getRawData(), other.getRawData()).decrypt(payload); + } catch (RuntimeException ex) { + throw new GiftWrapException("Could not decrypt payload; it is not addressed to us", ex); + } + } + + private static String toJson(Object value) { + try { + return MAPPER.writeValueAsString(value); + } catch (JsonProcessingException ex) { + throw new GiftWrapException("Failed to serialize event for wrapping", ex); + } + } + + private static T parse(String json, Class type, String description) { + try { + return MAPPER.readValue(json, type); + } catch (JsonProcessingException ex) { + throw new GiftWrapException("Decrypted payload was not a valid " + description, ex); + } + } + + /** Supplies the single-use identity that signs one gift wrap. */ + @FunctionalInterface + public interface EphemeralKeySource { + Identity generate(); + } + + /** Chooses the randomised timestamp carried by a seal or a gift wrap. */ + @FunctionalInterface + public interface TimestampRandomizer { + + /** + * Returns a timestamp at or before {@code referenceEpochSeconds}, within two days of it. + * + * @param referenceEpochSeconds the true time being obscured + * @return the timestamp to publish + */ + long randomizeFrom(long referenceEpochSeconds); + } + + /** + * Draws a timestamp uniformly from the two days preceding the reference time. + * + *

The offset comes from {@link SecureRandom}: a predictable offset would be no protection + * when the offset is what conceals the true send time. Timestamps never move into the future, + * which NIP-59 requires and which keeps relays from dropping the event. + */ + public static final class PastTwoDaysRandomizer implements TimestampRandomizer { + + private final SecureRandom random = new SecureRandom(); + + @Override + public long randomizeFrom(long referenceEpochSeconds) { + return referenceEpochSeconds - random.nextLong(TWO_DAYS_IN_SECONDS + 1); + } + } +} diff --git a/nostr-java-identity/src/test/java/nostr/encryption/Nip59GiftWrapperTest.java b/nostr-java-identity/src/test/java/nostr/encryption/Nip59GiftWrapperTest.java new file mode 100644 index 00000000..e48d75fc --- /dev/null +++ b/nostr-java-identity/src/test/java/nostr/encryption/Nip59GiftWrapperTest.java @@ -0,0 +1,390 @@ +package nostr.encryption; + +import com.fasterxml.jackson.core.JsonProcessingException; +import nostr.base.Kinds; +import nostr.base.PublicKey; +import nostr.event.BaseTag; +import nostr.event.impl.GenericEvent; +import nostr.event.impl.Rumor; +import nostr.event.json.EventJsonMapper; +import nostr.id.Identity; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies NIP-59 gift wrapping, including the checks that authenticate an incoming message. + * + * @see NIP-59 + */ +class Nip59GiftWrapperTest { + + /** Keys from the worked example published in NIP-59, section "An Example". */ + private static final String AUTHOR_PRIVATE_KEY = + "0beebd062ec8735f4243466049d7747ef5d6594ee838de147f8aab842b15e273"; + + private static final String RECIPIENT_PRIVATE_KEY = + "e108399bd8424357a710b606ae0c13166d853d327e47a6e5e038197346bdbf45"; + + private static final String EPHEMERAL_PRIVATE_KEY = + "4f02eac59266002db5801adc5270700ca69d5b8f761d8732fab2fbf233c90cbd"; + + private static final long SEAL_CREATED_AT = 1703015180L; + private static final long WRAP_CREATED_AT = 1703021488L; + private static final long RUMOR_CREATED_AT = 1691518405L; + + private static final Identity AUTHOR = Identity.create(AUTHOR_PRIVATE_KEY); + private static final Identity RECIPIENT = Identity.create(RECIPIENT_PRIVATE_KEY); + + /** A wrapper whose ephemeral key and timestamps are fixed, so output is reproducible. */ + private static Nip59GiftWrapper deterministicWrapper(Identity identity) { + return new Nip59GiftWrapper( + identity, + Kinds.GIFT_WRAP, + () -> Identity.create(EPHEMERAL_PRIVATE_KEY), + reference -> reference == RUMOR_CREATED_AT ? SEAL_CREATED_AT : WRAP_CREATED_AT); + } + + private static Rumor nip59ExampleRumor() { + return new Rumor( + null, + AUTHOR.getPublicKey(), + RUMOR_CREATED_AT, + Kinds.TEXT_NOTE, + List.of(), + "Are you going to the party tonight?"); + } + + private static Rumor aChatMessage(Identity from, PublicKey to, String content) { + return Rumor.create( + from.getPublicKey(), Kinds.CHAT_MESSAGE, List.of(BaseTag.create("p", to.toString())), content); + } + + /** + * The gift wrap produced for the NIP-59 worked example matches the published event: same + * ephemeral author, same kind, same recipient tag, and same timestamp. This pins the + * implementation to the specification rather than to our reading of it. + */ + @Test + @DisplayName("reproduces the gift wrap published in the NIP-59 example") + void reproducesPublishedGiftWrap() { + GenericEvent wrap = + deterministicWrapper(AUTHOR).wrap(nip59ExampleRumor(), RECIPIENT.getPublicKey()); + + assertEquals( + "18b1a75918f1f2c90c23da616bce317d36e348bcf5f7ba55e75949319210c87c", + wrap.getPubKey().toString(), + "wrap must be signed by the ephemeral key, not the author"); + assertEquals(Kinds.GIFT_WRAP, wrap.getKind()); + assertEquals(WRAP_CREATED_AT, wrap.getCreatedAt()); + assertEquals(1, wrap.getTags().size()); + } + + /** + * The recipient of the published example can open our wrap and recover the exact rumor, which + * confirms our ciphertext is readable by an independent reading of the spec. + */ + @Test + @DisplayName("produces a wrap the NIP-59 example recipient can open") + void producesWrapTheExampleRecipientCanOpen() { + GenericEvent wrap = + deterministicWrapper(AUTHOR).wrap(nip59ExampleRumor(), RECIPIENT.getPublicKey()); + + Rumor opened = new Nip59GiftWrapper(RECIPIENT).unwrap(wrap); + + assertEquals("Are you going to the party tonight?", opened.getContent()); + assertEquals(AUTHOR.getPublicKey(), opened.getPubKey()); + assertEquals( + "9dd003c6d3b73b74a85a9ab099469ce251653a7af76f523671ab828acd2a0ef9", opened.getId()); + } + + /** A rumor survives a wrap and unwrap unchanged. */ + @Test + @DisplayName("round-trips a rumor through wrap and unwrap") + void roundTripsRumor() { + Rumor original = aChatMessage(AUTHOR, RECIPIENT.getPublicKey(), "Hola, que tal?"); + + GenericEvent wrap = new Nip59GiftWrapper(AUTHOR).wrap(original, RECIPIENT.getPublicKey()); + Rumor opened = new Nip59GiftWrapper(RECIPIENT).unwrap(wrap); + + assertEquals(original, opened); + } + + /** Content that stresses JSON escaping survives the round trip intact. */ + @Test + @DisplayName("round-trips content that needs JSON escaping") + void roundTripsAwkwardContent() { + String awkward = "quote\" backslash\\ newline\n control\u0001 astral\uD83D\uDE80"; + Rumor original = aChatMessage(AUTHOR, RECIPIENT.getPublicKey(), awkward); + + GenericEvent wrap = new Nip59GiftWrapper(AUTHOR).wrap(original, RECIPIENT.getPublicKey()); + Rumor opened = new Nip59GiftWrapper(RECIPIENT).unwrap(wrap); + + assertEquals(awkward, opened.getContent()); + } + + /** + * A long message survives the round trip. The NIP-44 payload caps at 65,535 bytes and each + * layer wraps the one below in JSON, so the usable message is meaningfully smaller than the + * cap; this exercises a message large enough to cross several NIP-44 padding buckets. + */ + @Test + @DisplayName("round-trips a long message") + void roundTripsLargeMessage() { + Rumor original = aChatMessage(AUTHOR, RECIPIENT.getPublicKey(), "x".repeat(20_000)); + + GenericEvent wrap = new Nip59GiftWrapper(AUTHOR).wrap(original, RECIPIENT.getPublicKey()); + Rumor opened = new Nip59GiftWrapper(RECIPIENT).unwrap(wrap); + + assertEquals(original.getContent(), opened.getContent()); + } + + /** + * A forged message, where the rumor names an author other than the key that sealed it, is + * rejected. Without this check anyone could attribute a message to anyone, because the rumor + * itself is unsigned. NIP-17 requires the comparison explicitly. + */ + @Test + @DisplayName("rejects a rumor whose author differs from the sealing key") + void rejectsImpersonatedRumor() { + Identity attacker = Identity.generateRandomIdentity(); + Rumor forged = + Rumor.create( + AUTHOR.getPublicKey(), + Kinds.CHAT_MESSAGE, + List.of(), + "Transfer the funds, this is definitely me"); + + GenericEvent wrap = new Nip59GiftWrapper(attacker).wrap(forged, RECIPIENT.getPublicKey()); + + GiftWrapException rejection = + assertThrows(GiftWrapException.class, () -> new Nip59GiftWrapper(RECIPIENT).unwrap(wrap)); + assertTrue(rejection.getMessage().contains("forged")); + } + + /** + * A seal whose contents were altered after signing is rejected, because its signature no + * longer matches. Accepting it would mean accepting an unauthenticated message. + */ + @Test + @DisplayName("rejects a seal whose signature does not verify") + void rejectsSealWithInvalidSignature() { + Identity ephemeral = Identity.create(EPHEMERAL_PRIVATE_KEY); + + GenericEvent forgedSeal = new GenericEvent(AUTHOR.getPublicKey(), Kinds.SEAL); + forgedSeal.setContent("not-the-content-that-was-signed"); + forgedSeal.setTags(List.of()); + forgedSeal.update(SEAL_CREATED_AT); + // Signed by a different key than the seal claims, so verification must fail. + ephemeral.sign(forgedSeal); + + GenericEvent wrap = wrapRaw(forgedSeal, ephemeral, RECIPIENT.getPublicKey()); + + GiftWrapException rejection = + assertThrows(GiftWrapException.class, () -> new Nip59GiftWrapper(RECIPIENT).unwrap(wrap)); + assertTrue(rejection.getMessage().contains("signature")); + } + + /** A seal carrying no signature at all is rejected. */ + @Test + @DisplayName("rejects a seal carrying no signature") + void rejectsUnsignedSeal() { + Identity ephemeral = Identity.generateRandomIdentity(); + + GenericEvent unsignedSeal = new GenericEvent(AUTHOR.getPublicKey(), Kinds.SEAL); + unsignedSeal.setContent("anything"); + unsignedSeal.setTags(List.of()); + unsignedSeal.update(SEAL_CREATED_AT); + + GenericEvent wrap = wrapRaw(unsignedSeal, ephemeral, RECIPIENT.getPublicKey()); + + assertThrows(GiftWrapException.class, () -> new Nip59GiftWrapper(RECIPIENT).unwrap(wrap)); + } + + /** An inner event that is not a kind-13 seal is rejected. */ + @Test + @DisplayName("rejects a wrap that does not contain a seal") + void rejectsWrapWithoutSeal() { + Identity ephemeral = Identity.generateRandomIdentity(); + + GenericEvent notASeal = new GenericEvent(AUTHOR.getPublicKey(), Kinds.TEXT_NOTE); + notASeal.setContent("a plain note, not a seal"); + notASeal.setTags(List.of()); + notASeal.update(SEAL_CREATED_AT); + AUTHOR.sign(notASeal); + + GenericEvent wrap = wrapRaw(notASeal, ephemeral, RECIPIENT.getPublicKey()); + + assertThrows(GiftWrapException.class, () -> new Nip59GiftWrapper(RECIPIENT).unwrap(wrap)); + } + + /** A wrap addressed to somebody else cannot be opened, and says so rather than failing oddly. */ + @Test + @DisplayName("rejects a wrap addressed to a different recipient") + void rejectsWrapForAnotherRecipient() { + Identity stranger = Identity.generateRandomIdentity(); + Rumor rumor = aChatMessage(AUTHOR, stranger.getPublicKey(), "not for you"); + + GenericEvent wrap = new Nip59GiftWrapper(AUTHOR).wrap(rumor, stranger.getPublicKey()); + + assertThrows(GiftWrapException.class, () -> new Nip59GiftWrapper(RECIPIENT).unwrap(wrap)); + } + + /** A wrap whose ciphertext was corrupted in transit is rejected. */ + @Test + @DisplayName("rejects a wrap with corrupted ciphertext") + void rejectsCorruptedCiphertext() { + Rumor rumor = aChatMessage(AUTHOR, RECIPIENT.getPublicKey(), "hello"); + GenericEvent wrap = new Nip59GiftWrapper(AUTHOR).wrap(rumor, RECIPIENT.getPublicKey()); + + GenericEvent corrupted = new GenericEvent(wrap.getPubKey(), Kinds.GIFT_WRAP); + corrupted.setContent(wrap.getContent().substring(0, wrap.getContent().length() - 8) + "AAAAAAAA"); + corrupted.setTags(wrap.getTags()); + corrupted.update(wrap.getCreatedAt()); + + assertThrows(GiftWrapException.class, () -> new Nip59GiftWrapper(RECIPIENT).unwrap(corrupted)); + } + + /** + * Each wrap is signed by a different key, so an observer cannot link two messages from the + * same author. Reusing an ephemeral key would undo the whole scheme. + */ + @Test + @DisplayName("signs every wrap with a distinct single-use key") + void usesDistinctEphemeralKeyPerWrap() { + Rumor rumor = aChatMessage(AUTHOR, RECIPIENT.getPublicKey(), "hello"); + Nip59GiftWrapper wrapper = new Nip59GiftWrapper(AUTHOR); + + GenericEvent first = wrapper.wrap(rumor, RECIPIENT.getPublicKey()); + GenericEvent second = wrapper.wrap(rumor, RECIPIENT.getPublicKey()); + + assertNotEquals(first.getPubKey(), second.getPubKey()); + assertNotEquals(first.getContent(), second.getContent()); + assertNotEquals(first.getId(), second.getId()); + } + + /** The wrap never carries the author's key, which is the point of the outer layer. */ + @Test + @DisplayName("never exposes the author's key on the wrap") + void hidesAuthorOnTheWrap() { + Rumor rumor = aChatMessage(AUTHOR, RECIPIENT.getPublicKey(), "hello"); + + GenericEvent wrap = new Nip59GiftWrapper(AUTHOR).wrap(rumor, RECIPIENT.getPublicKey()); + + assertNotEquals(AUTHOR.getPublicKey(), wrap.getPubKey()); + assertFalse(wrap.getContent().contains(AUTHOR.getPublicKey().toString())); + } + + /** + * Timestamps are randomised into the past and never into the future, since NIP-59 requires it + * and relays commonly drop future-dated events. + */ + @Test + @DisplayName("randomises timestamps into the past, never the future") + void randomisesTimestampsIntoThePast() { + Rumor rumor = aChatMessage(AUTHOR, RECIPIENT.getPublicKey(), "hello"); + Nip59GiftWrapper wrapper = new Nip59GiftWrapper(AUTHOR); + long twoDays = 2 * 24 * 60 * 60L; + + boolean anyDiffer = false; + long previous = -1; + for (int attempt = 0; attempt < 20; attempt++) { + long now = Instant.now().getEpochSecond(); + GenericEvent wrap = wrapper.wrap(rumor, RECIPIENT.getPublicKey()); + + assertTrue(wrap.getCreatedAt() <= now, "wrap must never be dated in the future"); + assertTrue(wrap.getCreatedAt() >= now - twoDays, "wrap must stay within the two-day window"); + + anyDiffer |= previous != -1 && previous != wrap.getCreatedAt(); + previous = wrap.getCreatedAt(); + } + assertTrue(anyDiffer, "timestamps must vary between wraps"); + } + + /** The true send time of the rumor is not disclosed by the wrap. */ + @Test + @DisplayName("does not reuse the rumor timestamp on the wrap") + void doesNotLeakRumorTimestamp() { + Rumor rumor = nip59ExampleRumor(); + + GenericEvent wrap = new Nip59GiftWrapper(AUTHOR).wrap(rumor, RECIPIENT.getPublicKey()); + + assertNotEquals(rumor.getCreatedAt(), wrap.getCreatedAt()); + } + + /** Ephemeral wraps carry kind 21059, so relays know not to store them. */ + @Test + @DisplayName("stamps ephemeral wraps with kind 21059") + void stampsEphemeralWrapKind() { + Rumor rumor = aChatMessage(AUTHOR, RECIPIENT.getPublicKey(), "hello"); + + GenericEvent wrap = + new Nip59GiftWrapper(AUTHOR, Kinds.EPHEMERAL_GIFT_WRAP) + .wrap(rumor, RECIPIENT.getPublicKey()); + + assertEquals(Kinds.EPHEMERAL_GIFT_WRAP, wrap.getKind()); + assertEquals(rumor, new Nip59GiftWrapper(RECIPIENT).unwrap(wrap)); + } + + /** The recipient is named on the wrap so relays can route it, and nothing else is. */ + @Test + @DisplayName("tags only the recipient on the wrap") + void tagsOnlyTheRecipient() { + Rumor rumor = aChatMessage(AUTHOR, RECIPIENT.getPublicKey(), "hello"); + + GenericEvent wrap = new Nip59GiftWrapper(AUTHOR).wrap(rumor, RECIPIENT.getPublicKey()); + + assertEquals(1, wrap.getTags().size()); + assertEquals("p", wrap.getTags().get(0).getCode()); + } + + /** Round trips hold across many random identities and messages. */ + @Test + @DisplayName("round-trips across many random identities") + void roundTripsAcrossManyIdentities() { + for (int attempt = 0; attempt < 15; attempt++) { + Identity sender = Identity.generateRandomIdentity(); + Identity receiver = Identity.generateRandomIdentity(); + Rumor rumor = aChatMessage(sender, receiver.getPublicKey(), "message " + attempt); + + GenericEvent wrap = new Nip59GiftWrapper(sender).wrap(rumor, receiver.getPublicKey()); + + assertEquals(rumor, new Nip59GiftWrapper(receiver).unwrap(wrap)); + } + } + + /** + * Builds a gift wrap around an arbitrary inner event, the way an attacker would, bypassing + * the seal construction that {@link Nip59GiftWrapper#wrap} performs. This is what lets the + * tests present malformed and forged seals to the receive path. + */ + private static GenericEvent wrapRaw( + GenericEvent innerEvent, Identity ephemeral, PublicKey recipient) { + String innerJson; + try { + innerJson = EventJsonMapper.getMapper().writeValueAsString(innerEvent); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("Failed to serialize test event", ex); + } + + String ciphertext = + new MessageCipher44(ephemeral.getPrivateKey().getRawData(), recipient.getRawData()) + .encrypt(innerJson); + + GenericEvent wrap = new GenericEvent(ephemeral.getPublicKey(), Kinds.GIFT_WRAP); + wrap.setContent(ciphertext); + wrap.setTags(List.of(BaseTag.create("p", recipient.toString()))); + wrap.update(WRAP_CREATED_AT); + ephemeral.sign(wrap); + + return wrap; + } +} From b6c53d5c0fa394218c4f3d2f4c5f21c92f494116 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Sun, 30 Aug 2026 11:14:43 +0100 Subject: [PATCH 10/15] feat: add NIP-17 chat messages and direct message service Adds ChatMessage, the kind-14 rumor an application author actually works with, carrying recipients, an optional conversation subject, and an optional parent for replies. Recipients plus sender define a conversation, so a participant added or removed starts a different one. Adds DirectMessageService and its NIP-17 implementation. Composing a message yields one gift wrap per participant rather than a single event, because there is no shared envelope: each copy is separately encrypted, which is what keeps a conversation's membership private. Making that plurality visible in the signature stops a caller publishing one event and silently dropping the rest. The sender is a participant too. A sender who published only their recipients' copies could never read the conversation back, since they cannot decrypt a wrap addressed to someone else. composeByRecipient keeps each participant paired with their own event, which phase 4 needs to publish to the relays each recipient nominated. Sending a message attributed to another identity is refused up front, since the recipient would reject it as forged. The service neither publishes nor retains anything, so a message can be composed and verified without a relay. Verified with the identities from the worked example published in NIP-17. Refs #542 --- .../java/nostr/event/impl/ChatMessage.java | 248 ++++++++++++++++ .../nostr/event/unit/ChatMessageTest.java | 171 ++++++++++++ .../encryption/DirectMessageService.java | 67 +++++ .../encryption/Nip17DirectMessageService.java | 98 +++++++ .../Nip17DirectMessageServiceTest.java | 264 ++++++++++++++++++ 5 files changed, 848 insertions(+) create mode 100644 nostr-java-event/src/main/java/nostr/event/impl/ChatMessage.java create mode 100644 nostr-java-event/src/test/java/nostr/event/unit/ChatMessageTest.java create mode 100644 nostr-java-identity/src/main/java/nostr/encryption/DirectMessageService.java create mode 100644 nostr-java-identity/src/main/java/nostr/encryption/Nip17DirectMessageService.java create mode 100644 nostr-java-identity/src/test/java/nostr/encryption/Nip17DirectMessageServiceTest.java diff --git a/nostr-java-event/src/main/java/nostr/event/impl/ChatMessage.java b/nostr-java-event/src/main/java/nostr/event/impl/ChatMessage.java new file mode 100644 index 00000000..2365cc41 --- /dev/null +++ b/nostr-java-event/src/main/java/nostr/event/impl/ChatMessage.java @@ -0,0 +1,248 @@ +package nostr.event.impl; + +import lombok.NonNull; +import nostr.base.Kinds; +import nostr.base.PublicKey; +import nostr.event.BaseTag; +import nostr.event.tag.GenericTag; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * A private chat message, as defined by NIP-17. + * + *

The recipients plus the sender define a conversation. Adding or removing a participant + * starts a different conversation with its own history, so the recipient list is not a delivery + * detail but part of the message's identity. + * + *

A chat message never travels on its own. It becomes an unsigned rumor, which is sealed and + * gift wrapped once for each participant before publication. + * + * @see NIP-17 + */ +public final class ChatMessage { + + private static final String RECIPIENT_TAG = "p"; + private static final String REPLY_TAG = "e"; + private static final String SUBJECT_TAG = "subject"; + + private final PublicKey sender; + private final List recipients; + private final String content; + private final String subject; + private final String replyTo; + private final Long createdAt; + + private ChatMessage(Builder builder) { + this.sender = builder.sender; + this.recipients = List.copyOf(builder.recipients); + this.content = builder.content; + this.subject = builder.subject; + this.replyTo = builder.replyTo; + this.createdAt = builder.createdAt; + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Recovers a chat message from a decrypted rumor. + * + * @param rumor a kind-14 rumor obtained by unwrapping a gift wrap + * @return the message it carries + * @throws IllegalArgumentException if the rumor is not a chat message + */ + public static ChatMessage from(@NonNull Rumor rumor) { + if (!Integer.valueOf(Kinds.CHAT_MESSAGE).equals(rumor.getKind())) { + throw new IllegalArgumentException( + "Expected a kind-" + Kinds.CHAT_MESSAGE + " chat message but found kind " + rumor.getKind()); + } + + Builder builder = + builder() + .from(rumor.getPubKey()) + .content(rumor.getContent()) + .at(rumor.getCreatedAt()); + + rumor.getReferencedPublicKeys().forEach(builder::to); + firstTagValue(rumor, SUBJECT_TAG).ifPresent(builder::subject); + firstTagValue(rumor, REPLY_TAG).ifPresent(builder::inReplyTo); + + return builder.build(); + } + + /** + * Renders this message as the unsigned rumor that gets sealed and wrapped. + * + * @return a kind-14 rumor carrying this message's content and participants + */ + public Rumor toRumor() { + List tags = new ArrayList<>(); + recipients.forEach(recipient -> tags.add(BaseTag.create(RECIPIENT_TAG, recipient.toString()))); + if (subject != null) { + tags.add(BaseTag.create(SUBJECT_TAG, subject)); + } + if (replyTo != null) { + tags.add(BaseTag.create(REPLY_TAG, replyTo)); + } + + return new Rumor(null, sender, createdAt, Kinds.CHAT_MESSAGE, tags, content); + } + + /** + * Returns everyone in this conversation: the recipients and the sender. + * + *

NIP-17 requires a copy addressed to the sender as well, since a sender who wrapped only + * for their recipients could never read their own history back. + * + * @return each participant once, recipients first + */ + public List getParticipants() { + Set participants = new LinkedHashSet<>(recipients); + participants.add(sender); + return List.copyOf(participants); + } + + public PublicKey getSender() { + return sender; + } + + public List getRecipients() { + return Collections.unmodifiableList(recipients); + } + + public String getContent() { + return content; + } + + public Optional getSubject() { + return Optional.ofNullable(subject); + } + + public Optional getReplyTo() { + return Optional.ofNullable(replyTo); + } + + public Long getCreatedAt() { + return createdAt; + } + + private static Optional firstTagValue(Rumor rumor, String code) { + return rumor.getTags().stream() + .filter(GenericTag.class::isInstance) + .map(GenericTag.class::cast) + .filter(tag -> code.equals(tag.getCode())) + .filter(tag -> !tag.getParams().isEmpty()) + .map(tag -> tag.getParams().get(0)) + .findFirst(); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof ChatMessage message)) { + return false; + } + return Objects.equals(sender, message.sender) + && Objects.equals(recipients, message.recipients) + && Objects.equals(content, message.content) + && Objects.equals(subject, message.subject) + && Objects.equals(replyTo, message.replyTo) + && Objects.equals(createdAt, message.createdAt); + } + + @Override + public int hashCode() { + return Objects.hash(sender, recipients, content, subject, replyTo, createdAt); + } + + @Override + public String toString() { + return "ChatMessage(sender=" + sender + ", recipients=" + recipients.size() + ")"; + } + + /** Assembles a chat message, defaulting the timestamp to now. */ + public static final class Builder { + + private final List recipients = new ArrayList<>(); + private PublicKey sender; + private String content = ""; + private String subject; + private String replyTo; + private Long createdAt; + + private Builder() {} + + /** + * Names the author. The service supplies this from its identity, so callers rarely set it. + */ + public Builder from(@NonNull PublicKey sender) { + this.sender = sender; + return this; + } + + /** Adds a recipient. Call more than once for a group conversation. */ + public Builder to(@NonNull PublicKey recipient) { + if (!recipients.contains(recipient)) { + recipients.add(recipient); + } + return this; + } + + /** Adds several recipients at once. */ + public Builder to(@NonNull List newRecipients) { + newRecipients.forEach(this::to); + return this; + } + + /** Sets the message body, which NIP-17 requires to be plain text. */ + public Builder content(@NonNull String content) { + this.content = content; + return this; + } + + /** + * Titles the conversation. The most recent subject sent to a conversation is its title, so + * this need not be repeated on every message. + */ + public Builder subject(String subject) { + this.subject = subject; + return this; + } + + /** Marks this message as a reply to another chat message. */ + public Builder inReplyTo(String parentEventId) { + this.replyTo = parentEventId; + return this; + } + + /** Overrides the creation time, which otherwise defaults to now. */ + public Builder at(Long createdAt) { + this.createdAt = createdAt; + return this; + } + + public ChatMessage build() { + if (sender == null) { + throw new IllegalStateException("A chat message needs a sender"); + } + if (recipients.isEmpty()) { + throw new IllegalStateException("A chat message needs at least one recipient"); + } + if (createdAt == null) { + createdAt = Instant.now().getEpochSecond(); + } + return new ChatMessage(this); + } + } +} diff --git a/nostr-java-event/src/test/java/nostr/event/unit/ChatMessageTest.java b/nostr-java-event/src/test/java/nostr/event/unit/ChatMessageTest.java new file mode 100644 index 00000000..4d70a7d1 --- /dev/null +++ b/nostr-java-event/src/test/java/nostr/event/unit/ChatMessageTest.java @@ -0,0 +1,171 @@ +package nostr.event.unit; + +import nostr.base.Kinds; +import nostr.base.PublicKey; +import nostr.event.BaseTag; +import nostr.event.impl.ChatMessage; +import nostr.event.impl.Rumor; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the NIP-17 chat message and its translation to and from a rumor. + * + * @see NIP-17 + */ +class ChatMessageTest { + + private static final PublicKey ALICE = + new PublicKey("611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9"); + + private static final PublicKey BOB = + new PublicKey("918e2da906df4ccd12c8ac672d8335add131a4cf9d27ce42b3bb3625755f0788"); + + private static final PublicKey CAROL = + new PublicKey("166bf3765ebd1fc55decfe395beff2ea3b2a4e0a8946e7eb578512b555737c99"); + + private static ChatMessage.Builder aMessageFromAlice() { + return ChatMessage.builder().from(ALICE).to(BOB).content("Hola, que tal?"); + } + + /** A chat message becomes the kind-14 rumor that NIP-17 specifies. */ + @Test + @DisplayName("renders as a kind-14 rumor") + void rendersAsChatMessageRumor() { + Rumor rumor = aMessageFromAlice().build().toRumor(); + + assertEquals(Kinds.CHAT_MESSAGE, rumor.getKind()); + assertEquals(ALICE, rumor.getPubKey()); + assertEquals("Hola, que tal?", rumor.getContent()); + assertTrue(rumor.hasValidId()); + } + + /** Recipients are carried as p tags, which is how NIP-17 identifies a conversation. */ + @Test + @DisplayName("carries recipients as p tags") + void carriesRecipientsAsPTags() { + Rumor rumor = aMessageFromAlice().to(CAROL).build().toRumor(); + + assertEquals(List.of(BOB, CAROL), rumor.getReferencedPublicKeys()); + } + + /** A message survives the round trip through a rumor unchanged. */ + @Test + @DisplayName("round-trips through a rumor") + void roundTripsThroughRumor() { + ChatMessage original = + aMessageFromAlice().subject("Dinner").inReplyTo("a".repeat(64)).at(1691518405L).build(); + + ChatMessage restored = ChatMessage.from(original.toRumor()); + + assertEquals(original, restored); + } + + /** The subject titles the conversation and survives the round trip. */ + @Test + @DisplayName("carries a subject") + void carriesSubject() { + ChatMessage restored = + ChatMessage.from(aMessageFromAlice().subject("Dinner").build().toRumor()); + + assertEquals("Dinner", restored.getSubject().orElseThrow()); + } + + /** A reply names its parent through an e tag. */ + @Test + @DisplayName("carries a reply reference as an e tag") + void carriesReplyReference() { + String parentId = "b".repeat(64); + + ChatMessage restored = + ChatMessage.from(aMessageFromAlice().inReplyTo(parentId).build().toRumor()); + + assertEquals(parentId, restored.getReplyTo().orElseThrow()); + } + + /** + * Participants include the sender as well as the recipients, because NIP-17 requires a copy + * addressed to the sender so they retain their own history. + */ + @Test + @DisplayName("counts the sender among the participants") + void countsSenderAmongParticipants() { + ChatMessage message = aMessageFromAlice().to(CAROL).build(); + + assertEquals(List.of(BOB, CAROL, ALICE), message.getParticipants()); + } + + /** A sender messaging only themselves appears once, not twice. */ + @Test + @DisplayName("lists a self-addressed sender only once") + void listsSelfAddressedSenderOnce() { + ChatMessage note = ChatMessage.builder().from(ALICE).to(ALICE).content("note to self").build(); + + assertEquals(List.of(ALICE), note.getParticipants()); + } + + /** A recipient named twice is carried once, so a conversation is not accidentally redefined. */ + @Test + @DisplayName("ignores a duplicate recipient") + void ignoresDuplicateRecipient() { + ChatMessage message = aMessageFromAlice().to(BOB).build(); + + assertEquals(List.of(BOB), message.getRecipients()); + } + + /** A message without a sender cannot be built, since it could not be sealed. */ + @Test + @DisplayName("refuses to build without a sender") + void refusesToBuildWithoutSender() { + ChatMessage.Builder builder = ChatMessage.builder().to(BOB).content("hello"); + + assertThrows(IllegalStateException.class, builder::build); + } + + /** A message without a recipient cannot be built, since it would reach nobody. */ + @Test + @DisplayName("refuses to build without a recipient") + void refusesToBuildWithoutRecipient() { + ChatMessage.Builder builder = ChatMessage.builder().from(ALICE).content("hello"); + + assertThrows(IllegalStateException.class, builder::build); + } + + /** A rumor of another kind is not a chat message and is refused. */ + @Test + @DisplayName("refuses to read a rumor that is not a chat message") + void refusesNonChatMessageRumor() { + Rumor note = Rumor.create(ALICE, Kinds.TEXT_NOTE, List.of(BaseTag.create("p", BOB.toString())), "hi"); + + assertThrows(IllegalArgumentException.class, () -> ChatMessage.from(note)); + } + + /** A message stamps itself with the current time when none is given. */ + @Test + @DisplayName("defaults the timestamp to now") + void defaultsTimestampToNow() { + long before = Instant.now().getEpochSecond(); + + ChatMessage message = aMessageFromAlice().build(); + + assertTrue(message.getCreatedAt() >= before); + } + + /** The string form must not disclose message content. */ + @Test + @DisplayName("omits content from toString") + void omitsContentFromToString() { + ChatMessage message = + ChatMessage.builder().from(ALICE).to(BOB).content("meet me at the usual place").build(); + + assertFalse(message.toString().contains("usual place")); + } +} diff --git a/nostr-java-identity/src/main/java/nostr/encryption/DirectMessageService.java b/nostr-java-identity/src/main/java/nostr/encryption/DirectMessageService.java new file mode 100644 index 00000000..72377515 --- /dev/null +++ b/nostr-java-identity/src/main/java/nostr/encryption/DirectMessageService.java @@ -0,0 +1,67 @@ +package nostr.encryption; + +import nostr.base.PublicKey; +import nostr.event.impl.ChatMessage; +import nostr.event.impl.GenericEvent; + +import java.util.List; +import java.util.Map; + +/** + * Composes and reads NIP-17 private direct messages. + * + *

A direct message is published as one gift wrap per participant, each encrypted separately. + * There is no shared envelope and no group identifier, which is what keeps a conversation's + * membership private, and it is why composing a message yields several events rather than one. + * + *

This service is a pure function of its inputs. It does not publish, subscribe, or retain + * messages, leaving the caller to route the events it produces and to decide what to keep. That + * separation is what allows a message to be composed and verified without a relay. + * + * @see NIP-17 + */ +public interface DirectMessageService { + + /** + * Seals and wraps a message for every participant, including its sender. + * + *

The sender's own copy is not a courtesy. A sender who published only their recipients' + * copies would be unable to read the conversation back on another device, because they cannot + * decrypt a wrap addressed to someone else. + * + *

Each returned event is addressed to exactly one participant. Publishing them all to one + * relay is possible but wasteful; prefer {@link #composeByRecipient} and route each event to + * the relays its recipient reads. + * + * @param message the message to send + * @return one signed gift wrap per participant + * @throws GiftWrapException if the message cannot be sealed or wrapped + */ + List compose(ChatMessage message); + + /** + * Seals and wraps a message, keeping each participant paired with their own event. + * + *

NIP-17 requires that a message reach a participant only through the relays that + * participant nominated, so a caller publishing to the network needs to know which event + * belongs to whom. + * + * @param message the message to send + * @return each participant's public key mapped to the wrap addressed to them + * @throws GiftWrapException if the message cannot be sealed or wrapped + */ + Map composeByRecipient(ChatMessage message); + + /** + * Opens a gift wrap addressed to this identity and returns the message inside. + * + *

The message is authenticated before it is returned: the seal's signature is verified and + * its author is checked against the rumor's, so the sender reported here is the real one. + * + * @param giftWrap a kind-1059 event addressed to this identity + * @return the authenticated message + * @throws GiftWrapException if the wrap cannot be opened or fails authentication + * @throws IllegalArgumentException if the wrap does not contain a chat message + */ + ChatMessage read(GenericEvent giftWrap); +} diff --git a/nostr-java-identity/src/main/java/nostr/encryption/Nip17DirectMessageService.java b/nostr-java-identity/src/main/java/nostr/encryption/Nip17DirectMessageService.java new file mode 100644 index 00000000..f2cd6fa0 --- /dev/null +++ b/nostr-java-identity/src/main/java/nostr/encryption/Nip17DirectMessageService.java @@ -0,0 +1,98 @@ +package nostr.encryption; + +import lombok.NonNull; +import nostr.base.PublicKey; +import nostr.event.impl.ChatMessage; +import nostr.event.impl.GenericEvent; +import nostr.event.impl.Rumor; +import nostr.id.Identity; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Sends and reads private direct messages by gift wrapping them per NIP-17. + * + *

Every message is sealed once and wrapped separately for each participant, so the events + * published for one message share no key, no ciphertext, and no timestamp. An observer holding + * all of them learns only that several unrelated-looking events were addressed to several + * people. + * + * @see NIP-17 + */ +public class Nip17DirectMessageService implements DirectMessageService { + + private final Identity identity; + private final GiftWrapper giftWrapper; + + /** + * Creates a service that sends as, and reads for, the given identity. + * + * @param identity the identity that signs outgoing seals and opens incoming wraps + */ + public Nip17DirectMessageService(@NonNull Identity identity) { + this(identity, new Nip59GiftWrapper(identity)); + } + + /** + * Creates a service with an explicit wrapper. + * + *

Useful for ephemeral conversations, which need a wrapper configured for kind 21059, and + * for tests that fix the randomness a wrap would otherwise draw. + * + * @param identity the identity that signs outgoing seals and opens incoming wraps + * @param giftWrapper the wrapper used to conceal and reveal messages + */ + public Nip17DirectMessageService(@NonNull Identity identity, @NonNull GiftWrapper giftWrapper) { + this.identity = identity; + this.giftWrapper = giftWrapper; + } + + /** + * Starts a message authored by this service's identity. + * + *

Saves the caller naming a sender that must, in any case, match the signing identity. + * + * @return a builder with the sender already set + */ + public ChatMessage.Builder message() { + return ChatMessage.builder().from(identity.getPublicKey()); + } + + @Override + public List compose(@NonNull ChatMessage message) { + return List.copyOf(composeByRecipient(message).values()); + } + + @Override + public Map composeByRecipient(@NonNull ChatMessage message) { + Rumor rumor = senderVerifiedRumor(message); + + Map wrapsByRecipient = new LinkedHashMap<>(); + for (PublicKey participant : message.getParticipants()) { + wrapsByRecipient.put(participant, giftWrapper.wrap(rumor, participant)); + } + return wrapsByRecipient; + } + + @Override + public ChatMessage read(@NonNull GenericEvent giftWrap) { + return ChatMessage.from(giftWrapper.unwrap(giftWrap)); + } + + /** + * Builds the rumor to seal, refusing to send a message attributed to somebody else. + * + *

Sealing a rumor that names another author produces an event the recipient will reject as + * forged, so catching it here turns a confusing delivery failure into a clear programming + * error. + */ + private Rumor senderVerifiedRumor(ChatMessage message) { + if (!identity.getPublicKey().equals(message.getSender())) { + throw new GiftWrapException( + "Cannot send a message authored by another identity; this would be rejected as forged"); + } + return message.toRumor(); + } +} diff --git a/nostr-java-identity/src/test/java/nostr/encryption/Nip17DirectMessageServiceTest.java b/nostr-java-identity/src/test/java/nostr/encryption/Nip17DirectMessageServiceTest.java new file mode 100644 index 00000000..fff1e40a --- /dev/null +++ b/nostr-java-identity/src/test/java/nostr/encryption/Nip17DirectMessageServiceTest.java @@ -0,0 +1,264 @@ +package nostr.encryption; + +import nostr.base.Kinds; +import nostr.base.PublicKey; +import nostr.event.impl.ChatMessage; +import nostr.event.impl.GenericEvent; +import nostr.event.impl.Rumor; +import nostr.id.Identity; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies sending and reading NIP-17 private direct messages. + * + * @see NIP-17 + */ +class Nip17DirectMessageServiceTest { + + /** + * Keys from the worked example published in NIP-17, section "Examples", where Alice sends + * "Hola, que tal?" to Bob. Given here in hex, since the spec quotes them as nsec. + */ + private static final Identity ALICE = + Identity.create("71f8de50a46c9996a21123280c6217c48f67d1378ff4fb14d4f7612181a1ebde"); + + private static final Identity BOB = + Identity.create("511cbb07ec2028bd2dcd039c447581a7f754df9d9a0e5c16b19a5422ab391563"); + + private static Nip17DirectMessageService serviceFor(Identity identity) { + return new Nip17DirectMessageService(identity); + } + + /** + * A message reaches its recipient with content, sender, and subject intact, which is the whole + * point of the feature. + */ + @Test + @DisplayName("delivers a message to its recipient") + void deliversMessageToRecipient() { + ChatMessage sent = + serviceFor(ALICE) + .message() + .to(BOB.getPublicKey()) + .subject("Dinner") + .content("Hola, que tal?") + .build(); + + Map wraps = serviceFor(ALICE).composeByRecipient(sent); + ChatMessage received = serviceFor(BOB).read(wraps.get(BOB.getPublicKey())); + + assertEquals("Hola, que tal?", received.getContent()); + assertEquals(ALICE.getPublicKey(), received.getSender()); + assertEquals("Dinner", received.getSubject().orElseThrow()); + } + + /** + * A copy is addressed to the sender as well. Without it a sender could not read their own + * conversation back, since they cannot decrypt a wrap addressed to someone else. + */ + @Test + @DisplayName("wraps a copy for the sender so they keep their own history") + void wrapsCopyForSender() { + ChatMessage sent = + serviceFor(ALICE).message().to(BOB.getPublicKey()).content("Hola, que tal?").build(); + + Map wraps = serviceFor(ALICE).composeByRecipient(sent); + + assertTrue(wraps.containsKey(ALICE.getPublicKey()), "sender must receive their own copy"); + ChatMessage ownCopy = serviceFor(ALICE).read(wraps.get(ALICE.getPublicKey())); + assertEquals("Hola, que tal?", ownCopy.getContent()); + } + + /** A two-party message produces exactly two events: one for the recipient, one for the sender. */ + @Test + @DisplayName("produces one wrap per participant") + void producesOneWrapPerParticipant() { + ChatMessage sent = + serviceFor(ALICE).message().to(BOB.getPublicKey()).content("hello").build(); + + List wraps = serviceFor(ALICE).compose(sent); + + assertEquals(2, wraps.size()); + } + + /** A group message reaches every participant, and each of them sees the whole recipient list. */ + @Test + @DisplayName("delivers a group message to every participant") + void deliversGroupMessage() { + Identity carol = Identity.generateRandomIdentity(); + ChatMessage sent = + serviceFor(ALICE) + .message() + .to(BOB.getPublicKey()) + .to(carol.getPublicKey()) + .content("dinner at eight?") + .build(); + + Map wraps = serviceFor(ALICE).composeByRecipient(sent); + + assertEquals(3, wraps.size()); + ChatMessage asBob = serviceFor(BOB).read(wraps.get(BOB.getPublicKey())); + ChatMessage asCarol = serviceFor(carol).read(wraps.get(carol.getPublicKey())); + assertEquals("dinner at eight?", asBob.getContent()); + assertEquals("dinner at eight?", asCarol.getContent()); + assertEquals(List.of(BOB.getPublicKey(), carol.getPublicKey()), asBob.getRecipients()); + } + + /** + * Each participant's event is independently encrypted and signed, so an observer cannot tell + * that two wraps belong to the same conversation. + */ + @Test + @DisplayName("makes each participant's wrap unlinkable to the others") + void makesWrapsUnlinkable() { + ChatMessage sent = + serviceFor(ALICE).message().to(BOB.getPublicKey()).content("hello").build(); + + List wraps = serviceFor(ALICE).compose(sent); + GenericEvent first = wraps.get(0); + GenericEvent second = wraps.get(1); + + assertNotEquals(first.getPubKey(), second.getPubKey()); + assertNotEquals(first.getContent(), second.getContent()); + assertNotEquals(first.getId(), second.getId()); + } + + /** A participant cannot open a wrap addressed to a different participant. */ + @Test + @DisplayName("keeps a wrap unreadable by anyone but its addressee") + void keepsWrapUnreadableByOthers() { + Identity eavesdropper = Identity.generateRandomIdentity(); + ChatMessage sent = + serviceFor(ALICE).message().to(BOB.getPublicKey()).content("private").build(); + + GenericEvent bobsWrap = + serviceFor(ALICE).composeByRecipient(sent).get(BOB.getPublicKey()); + + assertThrows(GiftWrapException.class, () -> serviceFor(eavesdropper).read(bobsWrap)); + } + + /** A reply carries a reference to the message it answers. */ + @Test + @DisplayName("preserves the parent reference on a reply") + void preservesReplyReference() { + ChatMessage original = + serviceFor(ALICE).message().to(BOB.getPublicKey()).content("dinner?").build(); + String parentId = original.toRumor().getId(); + + ChatMessage reply = + serviceFor(BOB) + .message() + .to(ALICE.getPublicKey()) + .inReplyTo(parentId) + .content("yes, eight o'clock") + .build(); + + GenericEvent wrap = serviceFor(BOB).composeByRecipient(reply).get(ALICE.getPublicKey()); + ChatMessage received = serviceFor(ALICE).read(wrap); + + assertEquals(parentId, received.getReplyTo().orElseThrow()); + } + + /** A message with no subject reports none rather than an empty one. */ + @Test + @DisplayName("reports no subject when none was set") + void reportsNoSubjectWhenUnset() { + ChatMessage sent = + serviceFor(ALICE).message().to(BOB.getPublicKey()).content("hello").build(); + + GenericEvent wrap = serviceFor(ALICE).composeByRecipient(sent).get(BOB.getPublicKey()); + + assertTrue(serviceFor(BOB).read(wrap).getSubject().isEmpty()); + } + + /** Content needing JSON escaping arrives intact. */ + @Test + @DisplayName("delivers content that needs JSON escaping") + void deliversAwkwardContent() { + String awkward = "quote\" backslash\\ newline\n control\u0001 astral\uD83D\uDE80"; + ChatMessage sent = + serviceFor(ALICE).message().to(BOB.getPublicKey()).content(awkward).build(); + + GenericEvent wrap = serviceFor(ALICE).composeByRecipient(sent).get(BOB.getPublicKey()); + + assertEquals(awkward, serviceFor(BOB).read(wrap).getContent()); + } + + /** + * Sending a message attributed to another identity is refused, since the recipient would + * reject it as forged. Failing here turns a confusing delivery failure into a clear error. + */ + @Test + @DisplayName("refuses to send a message authored by another identity") + void refusesToSendOnBehalfOfAnother() { + ChatMessage notMine = + ChatMessage.builder() + .from(BOB.getPublicKey()) + .to(ALICE.getPublicKey()) + .content("pretending to be Bob") + .build(); + + assertThrows(GiftWrapException.class, () -> serviceFor(ALICE).compose(notMine)); + } + + /** The published events never disclose the sender's key. */ + @Test + @DisplayName("never exposes the sender on a published event") + void neverExposesSender() { + ChatMessage sent = + serviceFor(ALICE).message().to(BOB.getPublicKey()).content("hello").build(); + + for (GenericEvent wrap : serviceFor(ALICE).compose(sent)) { + assertNotEquals(ALICE.getPublicKey(), wrap.getPubKey()); + assertFalse(wrap.getContent().contains(ALICE.getPublicKey().toString())); + assertEquals(Kinds.GIFT_WRAP, wrap.getKind()); + } + } + + /** A message needs a recipient, since a conversation is defined by its participants. */ + @Test + @DisplayName("refuses to build a message with no recipient") + void refusesMessageWithoutRecipient() { + ChatMessage.Builder builder = serviceFor(ALICE).message().content("into the void"); + + assertThrows(IllegalStateException.class, builder::build); + } + + /** Reading a wrap that carries something other than a chat message is refused. */ + @Test + @DisplayName("refuses to read a wrap that is not a chat message") + void refusesNonChatMessage() { + Rumor note = + Rumor.create(ALICE.getPublicKey(), Kinds.TEXT_NOTE, List.of(), "just a note"); + GenericEvent wrap = new Nip59GiftWrapper(ALICE).wrap(note, BOB.getPublicKey()); + + assertThrows(IllegalArgumentException.class, () -> serviceFor(BOB).read(wrap)); + } + + /** A conversation survives a full exchange in both directions. */ + @Test + @DisplayName("carries a conversation in both directions") + void carriesConversationBothWays() { + ChatMessage question = + serviceFor(ALICE).message().to(BOB.getPublicKey()).content("Hola, que tal?").build(); + GenericEvent toBob = serviceFor(ALICE).composeByRecipient(question).get(BOB.getPublicKey()); + + ChatMessage asRead = serviceFor(BOB).read(toBob); + ChatMessage answer = + serviceFor(BOB).message().to(asRead.getSender()).content("Muy bien, gracias").build(); + GenericEvent toAlice = serviceFor(BOB).composeByRecipient(answer).get(ALICE.getPublicKey()); + + assertEquals("Muy bien, gracias", serviceFor(ALICE).read(toAlice).getContent()); + assertEquals(BOB.getPublicKey(), serviceFor(ALICE).read(toAlice).getSender()); + } +} From 3568f698d51657fb03529edb5e1f75e12d1df62e Mon Sep 17 00:00:00 2001 From: tcheeric Date: Sun, 30 Aug 2026 11:18:19 +0100 Subject: [PATCH 11/15] feat: route direct messages to each recipient's nominated relays Adds DirectMessageRelayList, the kind-10050 event naming where someone receives private messages, and planDelivery, which pairs each participant's copy with the relays that should carry it. NIP-17 permits delivery only to the relays a recipient nominated, and forbids sending at all to someone who published no list. Publishing elsewhere is not merely ineffective: it scatters a metadata-bearing event across relays the recipient never chose while the one they actually read may never see it. Phases 1 to 3 produced correct messages that were still not safe to put on the network; this closes that gap. No wrap is built for an unreachable recipient, so an event that must not be sent cannot later be published by mistake. Such a recipient still appears in the plan carrying no event, because silently omitting them is how a message goes half-delivered without anyone noticing. That also separates 'this person does not accept private messages' from 'the relay was down'. Relay lists are resolved through DirectMessageRelayLookup rather than by querying relays directly, keeping message composition a pure function that can be verified without a network. Refs #543 --- .../event/impl/DirectMessageRelayList.java | 146 +++++++++++++ .../unit/DirectMessageRelayListTest.java | 136 ++++++++++++ .../encryption/DirectMessageRelayLookup.java | 31 +++ .../encryption/DirectMessageService.java | 18 ++ .../nostr/encryption/MessageDelivery.java | 70 ++++++ .../encryption/Nip17DirectMessageService.java | 30 +++ .../encryption/MessageDeliveryPlanTest.java | 206 ++++++++++++++++++ 7 files changed, 637 insertions(+) create mode 100644 nostr-java-event/src/main/java/nostr/event/impl/DirectMessageRelayList.java create mode 100644 nostr-java-event/src/test/java/nostr/event/unit/DirectMessageRelayListTest.java create mode 100644 nostr-java-identity/src/main/java/nostr/encryption/DirectMessageRelayLookup.java create mode 100644 nostr-java-identity/src/main/java/nostr/encryption/MessageDelivery.java create mode 100644 nostr-java-identity/src/test/java/nostr/encryption/MessageDeliveryPlanTest.java diff --git a/nostr-java-event/src/main/java/nostr/event/impl/DirectMessageRelayList.java b/nostr-java-event/src/main/java/nostr/event/impl/DirectMessageRelayList.java new file mode 100644 index 00000000..d5fb1a7e --- /dev/null +++ b/nostr-java-event/src/main/java/nostr/event/impl/DirectMessageRelayList.java @@ -0,0 +1,146 @@ +package nostr.event.impl; + +import lombok.NonNull; +import nostr.base.Kinds; +import nostr.base.PublicKey; +import nostr.base.Relay; +import nostr.event.BaseTag; +import nostr.event.tag.GenericTag; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; + +/** + * The relays on which someone receives private direct messages, as defined by NIP-17. + * + *

NIP-17 requires a sender to deliver a gift wrap only to the relays its recipient nominated + * here. Publishing elsewhere does not merely risk non-delivery: it scatters a metadata-bearing + * event across relays the recipient never chose, while the one they actually read may never see + * it. + * + *

A recipient who has published no such list is signalling that they are not ready to + * receive private messages, and NIP-17 says not to send to them at all. + * + *

The specification advises keeping this list short, one to three relays, and publishing it + * widely so senders can find it. + * + * @see NIP-17 + */ +public final class DirectMessageRelayList { + + private static final String RELAY_TAG = "relay"; + + private final PublicKey owner; + private final List relays; + private final Long createdAt; + + /** + * Records the relays on which the given key receives private messages. + * + * @param owner the key this list belongs to + * @param relays the relays to nominate, in preference order; duplicates are ignored + * @param createdAt Unix timestamp in seconds + */ + public DirectMessageRelayList( + @NonNull PublicKey owner, @NonNull List relays, @NonNull Long createdAt) { + this.owner = owner; + this.relays = List.copyOf(new LinkedHashSet<>(relays)); + this.createdAt = createdAt; + } + + /** + * Reads a relay list from a kind-10050 event. + * + * @param event the event to read + * @return the relays it nominates + * @throws IllegalArgumentException if the event is not a DM relay list + */ + public static DirectMessageRelayList from(@NonNull GenericEvent event) { + if (!Integer.valueOf(Kinds.DM_RELAY_LIST).equals(event.getKind())) { + throw new IllegalArgumentException( + "Expected a kind-" + + Kinds.DM_RELAY_LIST + + " direct message relay list but found kind " + + event.getKind()); + } + + List relays = new ArrayList<>(); + for (BaseTag tag : event.getTags()) { + if (tag instanceof GenericTag generic + && RELAY_TAG.equals(generic.getCode()) + && !generic.getParams().isEmpty()) { + relays.add(new Relay(generic.getParams().get(0))); + } + } + + return new DirectMessageRelayList(event.getPubKey(), relays, event.getCreatedAt()); + } + + /** + * Renders this list as the kind-10050 event to publish. + * + *

The returned event is unsigned; sign it with the owner's identity before publishing. + * + * @return the event carrying this list + */ + public GenericEvent toEvent() { + List tags = new ArrayList<>(); + relays.forEach(relay -> tags.add(BaseTag.create(RELAY_TAG, relay.getUri()))); + + GenericEvent event = new GenericEvent(owner, Kinds.DM_RELAY_LIST); + event.setTags(tags); + event.setContent(""); + event.update(createdAt); + return event; + } + + public PublicKey getOwner() { + return owner; + } + + public List getRelays() { + return Collections.unmodifiableList(relays); + } + + public Long getCreatedAt() { + return createdAt; + } + + /** + * Reports whether this list nominates anywhere to deliver a message. + * + *

An empty list means the owner is not accepting private messages, which is a different + * situation from a relay being unreachable and should be reported differently. + * + * @return true when no relay is nominated + */ + public boolean isEmpty() { + return relays.isEmpty(); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof DirectMessageRelayList list)) { + return false; + } + return Objects.equals(owner, list.owner) + && Objects.equals(relays, list.relays) + && Objects.equals(createdAt, list.createdAt); + } + + @Override + public int hashCode() { + return Objects.hash(owner, relays, createdAt); + } + + @Override + public String toString() { + return "DirectMessageRelayList(owner=" + owner + ", relays=" + relays + ")"; + } +} diff --git a/nostr-java-event/src/test/java/nostr/event/unit/DirectMessageRelayListTest.java b/nostr-java-event/src/test/java/nostr/event/unit/DirectMessageRelayListTest.java new file mode 100644 index 00000000..8b1eabab --- /dev/null +++ b/nostr-java-event/src/test/java/nostr/event/unit/DirectMessageRelayListTest.java @@ -0,0 +1,136 @@ +package nostr.event.unit; + +import nostr.base.Kinds; +import nostr.base.PublicKey; +import nostr.base.Relay; +import nostr.event.BaseTag; +import nostr.event.impl.DirectMessageRelayList; +import nostr.event.impl.GenericEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the kind-10050 list naming where someone receives private direct messages. + * + * @see NIP-17 + */ +class DirectMessageRelayListTest { + + private static final PublicKey OWNER = + new PublicKey("611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9"); + + private static final long CREATED_AT = 1691518405L; + + /** The relays published in the NIP-17 example. */ + private static final Relay INBOX = new Relay("wss://inbox.nostr.wine"); + + private static final Relay MY_RELAY = new Relay("wss://myrelay.nostr1.com"); + + private static DirectMessageRelayList aListOf(Relay... relays) { + return new DirectMessageRelayList(OWNER, List.of(relays), CREATED_AT); + } + + /** A relay list renders as the kind-10050 event NIP-17 specifies, with empty content. */ + @Test + @DisplayName("renders as a kind-10050 event") + void rendersAsDirectMessageRelayListEvent() { + GenericEvent event = aListOf(INBOX, MY_RELAY).toEvent(); + + assertEquals(Kinds.DM_RELAY_LIST, event.getKind()); + assertEquals(OWNER, event.getPubKey()); + assertEquals("", event.getContent()); + assertEquals(CREATED_AT, event.getCreatedAt()); + } + + /** Each relay is carried as a relay tag, in the order it was nominated. */ + @Test + @DisplayName("carries each relay as a relay tag in order") + void carriesRelaysAsTagsInOrder() { + GenericEvent event = aListOf(INBOX, MY_RELAY).toEvent(); + + assertEquals(2, event.getTags().size()); + assertEquals("relay", event.getTags().get(0).getCode()); + assertEquals(List.of(INBOX, MY_RELAY), DirectMessageRelayList.from(event).getRelays()); + } + + /** A relay list survives the round trip through its event. */ + @Test + @DisplayName("round-trips through its event") + void roundTripsThroughEvent() { + DirectMessageRelayList original = aListOf(INBOX, MY_RELAY); + + assertEquals(original, DirectMessageRelayList.from(original.toEvent())); + } + + /** + * An owner who nominates no relay is declining private messages, which callers must be able + * to distinguish from a relay being unreachable. + */ + @Test + @DisplayName("reports an empty list as empty") + void reportsEmptyList() { + assertTrue(aListOf().isEmpty()); + assertFalse(aListOf(INBOX).isEmpty()); + } + + /** An empty list round-trips, so "declines messages" is not confused with "no list found". */ + @Test + @DisplayName("round-trips an empty list") + void roundTripsEmptyList() { + DirectMessageRelayList empty = aListOf(); + + DirectMessageRelayList restored = DirectMessageRelayList.from(empty.toEvent()); + + assertTrue(restored.isEmpty()); + } + + /** A relay nominated twice is kept once, since duplicates would double-publish a message. */ + @Test + @DisplayName("keeps a duplicated relay only once") + void keepsDuplicateRelayOnce() { + DirectMessageRelayList list = aListOf(INBOX, MY_RELAY, INBOX); + + assertEquals(List.of(INBOX, MY_RELAY), list.getRelays()); + } + + /** An event of another kind is not a relay list and is refused. */ + @Test + @DisplayName("refuses to read an event that is not a relay list") + void refusesWrongKind() { + GenericEvent note = new GenericEvent(OWNER, Kinds.TEXT_NOTE); + note.setContent("not a relay list"); + note.update(CREATED_AT); + + assertThrows(IllegalArgumentException.class, () -> DirectMessageRelayList.from(note)); + } + + /** Tags that are not relay tags are ignored rather than misread as relays. */ + @Test + @DisplayName("ignores tags that do not name a relay") + void ignoresUnrelatedTags() { + GenericEvent event = aListOf(INBOX).toEvent(); + List mixed = + List.of( + BaseTag.create("relay", INBOX.getUri()), BaseTag.create("p", OWNER.toString())); + event.setTags(mixed); + event.update(CREATED_AT); + + assertEquals(List.of(INBOX), DirectMessageRelayList.from(event).getRelays()); + } + + /** The list a caller receives cannot be modified, keeping the instance immutable. */ + @Test + @DisplayName("exposes relays as an unmodifiable list") + void exposesUnmodifiableRelays() { + DirectMessageRelayList list = aListOf(INBOX); + + assertThrows(UnsupportedOperationException.class, () -> list.getRelays().add(MY_RELAY)); + } +} diff --git a/nostr-java-identity/src/main/java/nostr/encryption/DirectMessageRelayLookup.java b/nostr-java-identity/src/main/java/nostr/encryption/DirectMessageRelayLookup.java new file mode 100644 index 00000000..b97cb303 --- /dev/null +++ b/nostr-java-identity/src/main/java/nostr/encryption/DirectMessageRelayLookup.java @@ -0,0 +1,31 @@ +package nostr.encryption; + +import nostr.base.PublicKey; +import nostr.event.impl.DirectMessageRelayList; + +import java.util.Optional; + +/** + * Finds where someone receives private direct messages. + * + *

Resolving a kind-10050 list means querying relays, which the SDK's messaging types + * deliberately do not do. This interface is the seam: a caller supplies the lookup, backed by a + * relay query, a local cache, or fixed configuration, and message composition stays a pure + * function that can be verified without a network. + * + * @see NIP-17 + */ +@FunctionalInterface +public interface DirectMessageRelayLookup { + + /** + * Returns the relay list published by the given key, if there is one. + * + *

An empty result means the key has published no list, which NIP-17 treats as declining + * private messages rather than as a lookup failure. + * + * @param owner the key whose relay list is wanted + * @return their relay list, or empty when they have published none + */ + Optional findFor(PublicKey owner); +} diff --git a/nostr-java-identity/src/main/java/nostr/encryption/DirectMessageService.java b/nostr-java-identity/src/main/java/nostr/encryption/DirectMessageService.java index 72377515..60b43005 100644 --- a/nostr-java-identity/src/main/java/nostr/encryption/DirectMessageService.java +++ b/nostr-java-identity/src/main/java/nostr/encryption/DirectMessageService.java @@ -52,6 +52,24 @@ public interface DirectMessageService { */ Map composeByRecipient(ChatMessage message); + /** + * Seals and wraps a message, pairing each copy with the relays that should carry it. + * + *

NIP-17 permits delivery only to the relays a recipient nominated in their kind-10050 + * list, and forbids sending at all to a recipient who published none. Both rules are applied + * here, so a caller can publish the result without consulting the specification again. + * + *

An unreachable participant appears in the result carrying no event, rather than being + * omitted. Silence about a recipient who cannot be reached is how messages get lost without + * anyone noticing. + * + * @param message the message to send + * @param relayLists where to look up each participant's nominated relays + * @return one entry per participant, deliverable or not, in participant order + * @throws GiftWrapException if the message cannot be sealed or wrapped + */ + List planDelivery(ChatMessage message, DirectMessageRelayLookup relayLists); + /** * Opens a gift wrap addressed to this identity and returns the message inside. * diff --git a/nostr-java-identity/src/main/java/nostr/encryption/MessageDelivery.java b/nostr-java-identity/src/main/java/nostr/encryption/MessageDelivery.java new file mode 100644 index 00000000..c4fafdd9 --- /dev/null +++ b/nostr-java-identity/src/main/java/nostr/encryption/MessageDelivery.java @@ -0,0 +1,70 @@ +package nostr.encryption; + +import lombok.NonNull; +import nostr.base.PublicKey; +import nostr.base.Relay; +import nostr.event.impl.DirectMessageRelayList; +import nostr.event.impl.GenericEvent; + +import java.util.List; +import java.util.Objects; + +/** + * Where one participant's copy of a message should be delivered. + * + *

NIP-17 requires each copy to reach only the relays its recipient nominated, so a caller + * publishing a message needs the events and their destinations paired up. + * + *

A participant who has published no relay list is not reachable. That is a deliberate + * signal, not a transient failure, and it is reported separately so a caller can tell "this + * person does not accept private messages" from "the relay was down". + * + * @param recipient the participant this copy is addressed to + * @param giftWrap the event to publish, or {@code null} when the recipient is unreachable + * @param relays the relays to publish to, empty when the recipient is unreachable + * @see NIP-17 + */ +public record MessageDelivery(PublicKey recipient, GenericEvent giftWrap, List relays) { + + public MessageDelivery { + Objects.requireNonNull(recipient, "recipient is required"); + relays = relays == null ? List.of() : List.copyOf(relays); + } + + /** + * Records a copy that can be delivered. + * + * @param recipient the participant this copy is addressed to + * @param giftWrap the event to publish + * @param relayList the recipient's nominated relays + * @return a deliverable copy + */ + public static MessageDelivery to( + @NonNull PublicKey recipient, + @NonNull GenericEvent giftWrap, + @NonNull DirectMessageRelayList relayList) { + return new MessageDelivery(recipient, giftWrap, relayList.getRelays()); + } + + /** + * Records a participant who cannot receive private messages. + * + *

NIP-17 says not to attempt delivery when a recipient has published no relay list, so no + * event is produced for them at all: an unsent wrap cannot leak. + * + * @param recipient the unreachable participant + * @return an undeliverable entry carrying no event + */ + public static MessageDelivery unreachable(@NonNull PublicKey recipient) { + return new MessageDelivery(recipient, null, List.of()); + } + + /** + * Reports whether this copy can be delivered. + * + * @return true when there is an event and somewhere to publish it + */ + public boolean isDeliverable() { + return giftWrap != null && !relays.isEmpty(); + } +} diff --git a/nostr-java-identity/src/main/java/nostr/encryption/Nip17DirectMessageService.java b/nostr-java-identity/src/main/java/nostr/encryption/Nip17DirectMessageService.java index f2cd6fa0..32ad548c 100644 --- a/nostr-java-identity/src/main/java/nostr/encryption/Nip17DirectMessageService.java +++ b/nostr-java-identity/src/main/java/nostr/encryption/Nip17DirectMessageService.java @@ -7,6 +7,7 @@ import nostr.event.impl.Rumor; import nostr.id.Identity; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -76,11 +77,40 @@ public Map composeByRecipient(@NonNull ChatMessage mess return wrapsByRecipient; } + @Override + public List planDelivery( + @NonNull ChatMessage message, @NonNull DirectMessageRelayLookup relayLists) { + Rumor rumor = senderVerifiedRumor(message); + + List plan = new ArrayList<>(); + for (PublicKey participant : message.getParticipants()) { + plan.add(deliveryFor(rumor, participant, relayLists)); + } + return List.copyOf(plan); + } + @Override public ChatMessage read(@NonNull GenericEvent giftWrap) { return ChatMessage.from(giftWrapper.unwrap(giftWrap)); } + /** + * Wraps a message for one participant, or reports them unreachable. + * + *

Nothing is wrapped for a participant who nominated no relays. NIP-17 forbids sending to + * them, and an event that is never created cannot later be published by mistake. + */ + private MessageDelivery deliveryFor( + Rumor rumor, PublicKey participant, DirectMessageRelayLookup relayLists) { + return relayLists + .findFor(participant) + .filter(relayList -> !relayList.isEmpty()) + .map( + relayList -> + MessageDelivery.to(participant, giftWrapper.wrap(rumor, participant), relayList)) + .orElseGet(() -> MessageDelivery.unreachable(participant)); + } + /** * Builds the rumor to seal, refusing to send a message attributed to somebody else. * diff --git a/nostr-java-identity/src/test/java/nostr/encryption/MessageDeliveryPlanTest.java b/nostr-java-identity/src/test/java/nostr/encryption/MessageDeliveryPlanTest.java new file mode 100644 index 00000000..b18dd53d --- /dev/null +++ b/nostr-java-identity/src/test/java/nostr/encryption/MessageDeliveryPlanTest.java @@ -0,0 +1,206 @@ +package nostr.encryption; + +import nostr.base.PublicKey; +import nostr.base.Relay; +import nostr.event.impl.ChatMessage; +import nostr.event.impl.DirectMessageRelayList; +import nostr.id.Identity; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that a message is routed only to the relays each recipient nominated. + * + * @see NIP-17 + */ +class MessageDeliveryPlanTest { + + private static final Identity ALICE = + Identity.create("71f8de50a46c9996a21123280c6217c48f67d1378ff4fb14d4f7612181a1ebde"); + + private static final Identity BOB = + Identity.create("511cbb07ec2028bd2dcd039c447581a7f754df9d9a0e5c16b19a5422ab391563"); + + private static final Relay ALICE_INBOX = new Relay("wss://alice.example"); + private static final Relay BOB_INBOX = new Relay("wss://inbox.nostr.wine"); + private static final Relay BOB_SECOND_INBOX = new Relay("wss://myrelay.nostr1.com"); + + /** A lookup backed by a map, standing in for a relay query. */ + private static final class StubRelayLookup implements DirectMessageRelayLookup { + + private final Map lists = new HashMap<>(); + + StubRelayLookup publishes(Identity owner, Relay... relays) { + lists.put( + owner.getPublicKey(), + new DirectMessageRelayList(owner.getPublicKey(), List.of(relays), 1691518405L)); + return this; + } + + @Override + public Optional findFor(PublicKey owner) { + return Optional.ofNullable(lists.get(owner)); + } + } + + private static ChatMessage aMessageToBob() { + return new Nip17DirectMessageService(ALICE) + .message() + .to(BOB.getPublicKey()) + .content("Hola, que tal?") + .build(); + } + + private static MessageDelivery deliveryFor(List plan, Identity recipient) { + return plan.stream() + .filter(delivery -> delivery.recipient().equals(recipient.getPublicKey())) + .findFirst() + .orElseThrow(); + } + + /** Each participant's copy is routed only to the relays that participant nominated. */ + @Test + @DisplayName("routes each copy to its own recipient's relays") + void routesToEachRecipientsOwnRelays() { + StubRelayLookup relayLists = + new StubRelayLookup() + .publishes(ALICE, ALICE_INBOX) + .publishes(BOB, BOB_INBOX, BOB_SECOND_INBOX); + + List plan = + new Nip17DirectMessageService(ALICE).planDelivery(aMessageToBob(), relayLists); + + assertEquals(List.of(BOB_INBOX, BOB_SECOND_INBOX), deliveryFor(plan, BOB).relays()); + assertEquals(List.of(ALICE_INBOX), deliveryFor(plan, ALICE).relays()); + } + + /** Every participant appears in the plan, including the sender's own copy. */ + @Test + @DisplayName("plans a delivery for every participant") + void plansDeliveryForEveryParticipant() { + StubRelayLookup relayLists = + new StubRelayLookup().publishes(ALICE, ALICE_INBOX).publishes(BOB, BOB_INBOX); + + List plan = + new Nip17DirectMessageService(ALICE).planDelivery(aMessageToBob(), relayLists); + + assertEquals(2, plan.size()); + assertTrue(plan.stream().allMatch(MessageDelivery::isDeliverable)); + } + + /** + * A recipient who published no relay list is reported unreachable and no event is created for + * them. NIP-17 forbids sending in this case, and a wrap that is never built cannot later be + * published by mistake. + */ + @Test + @DisplayName("creates no event for a recipient who published no relay list") + void createsNoEventForUnreachableRecipient() { + StubRelayLookup relayLists = new StubRelayLookup().publishes(ALICE, ALICE_INBOX); + + List plan = + new Nip17DirectMessageService(ALICE).planDelivery(aMessageToBob(), relayLists); + + MessageDelivery toBob = deliveryFor(plan, BOB); + assertFalse(toBob.isDeliverable()); + assertNull(toBob.giftWrap(), "no wrap may exist for a recipient we must not send to"); + assertTrue(toBob.relays().isEmpty()); + } + + /** + * An unreachable recipient still appears in the plan. Omitting them would let a message go + * partly undelivered without the caller ever noticing. + */ + @Test + @DisplayName("still reports an unreachable recipient") + void stillReportsUnreachableRecipient() { + StubRelayLookup relayLists = new StubRelayLookup().publishes(ALICE, ALICE_INBOX); + + List plan = + new Nip17DirectMessageService(ALICE).planDelivery(aMessageToBob(), relayLists); + + assertEquals(2, plan.size()); + assertEquals(BOB.getPublicKey(), deliveryFor(plan, BOB).recipient()); + } + + /** A recipient whose published list nominates no relay is unreachable, same as having none. */ + @Test + @DisplayName("treats an empty relay list as unreachable") + void treatsEmptyRelayListAsUnreachable() { + StubRelayLookup relayLists = + new StubRelayLookup().publishes(ALICE, ALICE_INBOX).publishes(BOB); + + List plan = + new Nip17DirectMessageService(ALICE).planDelivery(aMessageToBob(), relayLists); + + assertFalse(deliveryFor(plan, BOB).isDeliverable()); + } + + /** A deliverable copy carries an openable wrap addressed to that recipient. */ + @Test + @DisplayName("produces a wrap the recipient can open") + void producesOpenableWrap() { + StubRelayLookup relayLists = + new StubRelayLookup().publishes(ALICE, ALICE_INBOX).publishes(BOB, BOB_INBOX); + + List plan = + new Nip17DirectMessageService(ALICE).planDelivery(aMessageToBob(), relayLists); + + ChatMessage received = + new Nip17DirectMessageService(BOB).read(deliveryFor(plan, BOB).giftWrap()); + + assertEquals("Hola, que tal?", received.getContent()); + assertEquals(ALICE.getPublicKey(), received.getSender()); + } + + /** A group message routes each participant's copy independently. */ + @Test + @DisplayName("routes a group message per participant") + void routesGroupMessagePerParticipant() { + Identity carol = Identity.generateRandomIdentity(); + Relay carolInbox = new Relay("wss://carol.example"); + StubRelayLookup relayLists = + new StubRelayLookup() + .publishes(ALICE, ALICE_INBOX) + .publishes(BOB, BOB_INBOX) + .publishes(carol, carolInbox); + + ChatMessage groupMessage = + new Nip17DirectMessageService(ALICE) + .message() + .to(BOB.getPublicKey()) + .to(carol.getPublicKey()) + .content("dinner at eight?") + .build(); + + List plan = + new Nip17DirectMessageService(ALICE).planDelivery(groupMessage, relayLists); + + assertEquals(3, plan.size()); + assertEquals(List.of(carolInbox), deliveryFor(plan, carol).relays()); + assertEquals(List.of(BOB_INBOX), deliveryFor(plan, BOB).relays()); + } + + /** A sender who published no relay list keeps no copy, and the message still reaches others. */ + @Test + @DisplayName("delivers to recipients even when the sender kept no relay list") + void deliversWhenSenderHasNoRelayList() { + StubRelayLookup relayLists = new StubRelayLookup().publishes(BOB, BOB_INBOX); + + List plan = + new Nip17DirectMessageService(ALICE).planDelivery(aMessageToBob(), relayLists); + + assertTrue(deliveryFor(plan, BOB).isDeliverable()); + assertFalse(deliveryFor(plan, ALICE).isDeliverable()); + } +} From 3fbbb637b4128a997efbb706d5cf2f8d74b79a4e Mon Sep 17 00:00:00 2001 From: tcheeric Date: Sun, 30 Aug 2026 11:20:22 +0100 Subject: [PATCH 12/15] docs: document private direct messages and deprecate NIP-04 Adds a how-to for sending and reading NIP-17 messages, covering the two things callers most often get wrong: that composing a message yields one event per participant including a copy addressed to the sender, and that a kind-1059 subscription delivers wraps you cannot open, so reading an inbox must skip failures rather than abandon the batch. Deprecates the NIP-04 types. They hide a message's text but leave the correspondents, the timing, and the message count public on every relay that carries the event. Nothing is removed: NIP-04 remains functional for reading existing conversations and for interoperating with clients that send nothing else. Refs #544 --- CHANGELOG.md | 13 ++ docs/README.md | 1 + docs/howto/private-direct-messages.md | 177 ++++++++++++++++++ .../crypto/nip04/EncryptedDirectMessage.java | 13 ++ .../java/nostr/encryption/MessageCipher.java | 5 + .../nostr/encryption/MessageCipher04.java | 8 + 6 files changed, 217 insertions(+) create mode 100644 docs/howto/private-direct-messages.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 01d57fb2..e4bdfcb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ The format is inspired by Keep a Changelog, and this project adheres to semantic ## [Unreleased] +### 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 diff --git a/docs/README.md b/docs/README.md index f0d415ba..a4b222fc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/howto/private-direct-messages.md b/docs/howto/private-direct-messages.md new file mode 100644 index 00000000..65dfbaa8 --- /dev/null +++ b/docs/howto/private-direct-messages.md @@ -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 + + xyz.tcheeric + nostr-java-identity + +``` + +## 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 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 diff --git a/nostr-java-core/src/main/java/nostr/crypto/nip04/EncryptedDirectMessage.java b/nostr-java-core/src/main/java/nostr/crypto/nip04/EncryptedDirectMessage.java index 0a8be767..494127dd 100644 --- a/nostr-java-core/src/main/java/nostr/crypto/nip04/EncryptedDirectMessage.java +++ b/nostr-java-core/src/main/java/nostr/crypto/nip04/EncryptedDirectMessage.java @@ -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 NIP-04 + * @see NIP-17 + */ +@Deprecated(since = "2.1.0") public class EncryptedDirectMessage { public static String encrypt(@NonNull String message, byte[] senderPrivKey, byte[] rcptPubKey) diff --git a/nostr-java-identity/src/main/java/nostr/encryption/MessageCipher.java b/nostr-java-identity/src/main/java/nostr/encryption/MessageCipher.java index 62993fb0..35bf97c3 100644 --- a/nostr-java-identity/src/main/java/nostr/encryption/MessageCipher.java +++ b/nostr-java-identity/src/main/java/nostr/encryption/MessageCipher.java @@ -2,6 +2,11 @@ public interface MessageCipher { + /** + * @deprecated NIP-04 leaves the correspondents and timing public. Prefer NIP-44, which + * {@link MessageCipher44} implements and which NIP-17 private messages build on. + */ + @Deprecated(since = "2.1.0") String NIP_04 = "NIP04"; String NIP_44 = "NIP44"; diff --git a/nostr-java-identity/src/main/java/nostr/encryption/MessageCipher04.java b/nostr-java-identity/src/main/java/nostr/encryption/MessageCipher04.java index d08c624d..2526f277 100644 --- a/nostr-java-identity/src/main/java/nostr/encryption/MessageCipher04.java +++ b/nostr-java-identity/src/main/java/nostr/encryption/MessageCipher04.java @@ -12,6 +12,14 @@ import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; +/** + * Encrypts and decrypts messages using NIP-04. + * + * @deprecated NIP-04 hides only the message text; the correspondents and the timing stay + * public. Prefer {@link Nip17DirectMessageService}, which conceals the metadata too. + * @see NIP-17 + */ +@Deprecated(since = "2.1.0") @Data @AllArgsConstructor public class MessageCipher04 implements MessageCipher { From 6940664caadd3f2427809089010e379f58e07ea6 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Sun, 30 Aug 2026 11:21:50 +0100 Subject: [PATCH 13/15] test: run the private direct message guide's examples Each snippet printed in docs/howto/private-direct-messages.md appears here in a form close enough to fail if the API changes under it. Documentation that has drifted from the API is worse than none. Refs #544 --- .../PrivateDirectMessagesHowToTest.java | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 nostr-java-identity/src/test/java/nostr/encryption/PrivateDirectMessagesHowToTest.java diff --git a/nostr-java-identity/src/test/java/nostr/encryption/PrivateDirectMessagesHowToTest.java b/nostr-java-identity/src/test/java/nostr/encryption/PrivateDirectMessagesHowToTest.java new file mode 100644 index 00000000..b1751299 --- /dev/null +++ b/nostr-java-identity/src/test/java/nostr/encryption/PrivateDirectMessagesHowToTest.java @@ -0,0 +1,205 @@ +package nostr.encryption; + +import nostr.base.Kinds; +import nostr.base.PublicKey; +import nostr.base.Relay; +import nostr.event.impl.ChatMessage; +import nostr.event.impl.DirectMessageRelayList; +import nostr.event.impl.GenericEvent; +import nostr.id.Identity; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Compiles and runs the examples printed in {@code docs/howto/private-direct-messages.md}. + * + *

Documentation that has drifted from the API is worse than none, so each snippet in the + * guide appears here in a form close enough to fail this test if the API changes under it. + */ +class PrivateDirectMessagesHowToTest { + + private static final Identity ALICE = + Identity.create("71f8de50a46c9996a21123280c6217c48f67d1378ff4fb14d4f7612181a1ebde"); + + private static final Identity BOB = + Identity.create("511cbb07ec2028bd2dcd039c447581a7f754df9d9a0e5c16b19a5422ab391563"); + + /** The "Publish where you receive messages" snippet. */ + @Test + @DisplayName("publishes a kind-10050 inbox list as the guide shows") + void publishesInboxList() { + Identity alice = ALICE; + + DirectMessageRelayList inbox = + new DirectMessageRelayList( + alice.getPublicKey(), + List.of(new Relay("wss://inbox.nostr.wine")), + Instant.now().getEpochSecond()); + + GenericEvent inboxEvent = inbox.toEvent(); + alice.sign(inboxEvent); + + assertEquals(Kinds.DM_RELAY_LIST, inboxEvent.getKind()); + assertTrue(inboxEvent.isSigned()); + } + + /** The "Send a message" snippet. */ + @Test + @DisplayName("composes a message as the guide shows") + void composesMessage() { + PublicKey bobPublicKey = BOB.getPublicKey(); + Nip17DirectMessageService messages = new Nip17DirectMessageService(ALICE); + + ChatMessage message = + messages + .message() + .to(bobPublicKey) + .subject("Dinner") + .content("Are you going to the party tonight?") + .build(); + + List wraps = messages.compose(message); + + assertEquals(2, wraps.size(), "one wrap per participant, including the sender's own copy"); + } + + /** The "Send to the right relays" snippet. */ + @Test + @DisplayName("plans delivery as the guide shows") + void plansDelivery() { + Relay bobInbox = new Relay("wss://inbox.nostr.wine"); + DirectMessageRelayLookup relayLists = + pubkey -> + pubkey.equals(BOB.getPublicKey()) + ? Optional.of( + new DirectMessageRelayList(pubkey, List.of(bobInbox), Instant.now().getEpochSecond())) + : Optional.empty(); + + Nip17DirectMessageService messages = new Nip17DirectMessageService(ALICE); + ChatMessage message = messages.message().to(BOB.getPublicKey()).content("hello").build(); + + List unreachable = new ArrayList<>(); + List published = new ArrayList<>(); + for (MessageDelivery delivery : messages.planDelivery(message, relayLists)) { + if (delivery.isDeliverable()) { + published.add(delivery.giftWrap()); + assertEquals(List.of(bobInbox), delivery.relays()); + } else { + unreachable.add(delivery.recipient()); + } + } + + assertEquals(1, published.size()); + assertEquals(List.of(ALICE.getPublicKey()), unreachable); + } + + /** The "Read your messages" snippet, including the skip-on-failure loop. */ + @Test + @DisplayName("reads an inbox as the guide shows, skipping wraps it cannot open") + void readsInboxSkippingUnopenableWraps() { + Nip17DirectMessageService fromAlice = new Nip17DirectMessageService(ALICE); + ChatMessage sent = fromAlice.message().to(BOB.getPublicKey()).content("Hola, que tal?").build(); + + Identity stranger = Identity.generateRandomIdentity(); + GenericEvent notForBob = + new Nip17DirectMessageService(stranger) + .composeByRecipient( + new Nip17DirectMessageService(stranger) + .message() + .to(stranger.getPublicKey()) + .content("someone else's business") + .build()) + .get(stranger.getPublicKey()); + + List incomingEvents = + List.of(notForBob, fromAlice.composeByRecipient(sent).get(BOB.getPublicKey())); + + Nip17DirectMessageService messages = new Nip17DirectMessageService(BOB); + List read = new ArrayList<>(); + for (GenericEvent giftWrap : incomingEvents) { + try { + ChatMessage received = messages.read(giftWrap); + read.add(received.getContent()); + } catch (GiftWrapException notForUs) { + // Expected: a kind-1059 subscription also delivers wraps we cannot open. + } + } + + assertEquals(List.of("Hola, que tal?"), read); + } + + /** The "Reply to a message" snippet. */ + @Test + @DisplayName("replies as the guide shows") + void replies() { + Nip17DirectMessageService messages = new Nip17DirectMessageService(BOB); + ChatMessage original = + new Nip17DirectMessageService(ALICE) + .message() + .to(BOB.getPublicKey()) + .content("dinner?") + .build(); + GenericEvent wrap = + new Nip17DirectMessageService(ALICE).composeByRecipient(original).get(BOB.getPublicKey()); + + ChatMessage received = messages.read(wrap); + String receivedEventId = original.toRumor().getId(); + + ChatMessage reply = + messages + .message() + .to(received.getSender()) + .inReplyTo(receivedEventId) + .content("Yes, see you at eight") + .build(); + + assertEquals(receivedEventId, reply.getReplyTo().orElseThrow()); + } + + /** The "Group conversations" snippet. */ + @Test + @DisplayName("sends a group message as the guide shows") + void sendsGroupMessage() { + PublicKey carolPublicKey = Identity.generateRandomIdentity().getPublicKey(); + Nip17DirectMessageService messages = new Nip17DirectMessageService(ALICE); + + ChatMessage groupMessage = + messages + .message() + .to(BOB.getPublicKey()) + .to(carolPublicKey) + .content("Dinner at eight?") + .build(); + + assertEquals(3, messages.compose(groupMessage).size()); + } + + /** The "Ephemeral messages" snippet. */ + @Test + @DisplayName("builds an ephemeral chat service as the guide shows") + void buildsEphemeralChatService() { + Identity alice = ALICE; + + DirectMessageService liveChat = + new Nip17DirectMessageService(alice, new Nip59GiftWrapper(alice, Kinds.EPHEMERAL_GIFT_WRAP)); + + ChatMessage message = + ChatMessage.builder() + .from(alice.getPublicKey()) + .to(BOB.getPublicKey()) + .content("are you there?") + .build(); + + assertTrue( + liveChat.compose(message).stream() + .allMatch(wrap -> Kinds.EPHEMERAL_GIFT_WRAP == wrap.getKind())); + } +} From 2ea893b64139580e9043bdeecae1b85b88c3cc86 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Sun, 30 Aug 2026 11:29:02 +0100 Subject: [PATCH 14/15] chore(release): bump project version to 2.1.0 --- nostr-java-client/pom.xml | 2 +- nostr-java-core/pom.xml | 2 +- nostr-java-event/pom.xml | 2 +- nostr-java-identity/pom.xml | 2 +- pom.xml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/nostr-java-client/pom.xml b/nostr-java-client/pom.xml index 85daf838..62167a81 100644 --- a/nostr-java-client/pom.xml +++ b/nostr-java-client/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.8 + 2.1.0 ../pom.xml diff --git a/nostr-java-core/pom.xml b/nostr-java-core/pom.xml index 0b2d45db..e2c263e8 100644 --- a/nostr-java-core/pom.xml +++ b/nostr-java-core/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.8 + 2.1.0 ../pom.xml diff --git a/nostr-java-event/pom.xml b/nostr-java-event/pom.xml index 23f64618..49686649 100644 --- a/nostr-java-event/pom.xml +++ b/nostr-java-event/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.8 + 2.1.0 ../pom.xml diff --git a/nostr-java-identity/pom.xml b/nostr-java-identity/pom.xml index 06945cd1..2453e40d 100644 --- a/nostr-java-identity/pom.xml +++ b/nostr-java-identity/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.8 + 2.1.0 ../pom.xml diff --git a/pom.xml b/pom.xml index 7725cccd..7f0d80f9 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ xyz.tcheeric nostr-java - 2.0.8 + 2.1.0 pom nostr-java From 6b62969f6192ee283e379ce94c95d16b5e80f161 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Sun, 30 Aug 2026 11:30:10 +0100 Subject: [PATCH 15/15] docs: record 2.1.0 in the changelog Minor bump: NIP-17 and NIP-59 support is additive, and the NIP-04 deprecation removes nothing. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4bdfcb2..f058928a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ 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.