diff --git a/CHANGELOG.md b/CHANGELOG.md index 01d57fb2..f058928a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ The format is inspired by Keep a Changelog, and this project adheres to semantic ## [Unreleased] +## [2.1.0] - 2026-08-30 + +### Added +- NIP-17 private direct messages, with the NIP-59 gift wrapping they build on. `Nip17DirectMessageService` composes a `ChatMessage` into one gift wrap per participant and reads incoming wraps back; `Nip59GiftWrapper` implements the generic three-layer envelope (unsigned kind-14 rumor, kind-13 seal signed by the real author, kind-1059 wrap signed by a single-use key) and is usable for any event kind, not just messages. Unlike NIP-04, which hides only the message text, this conceals the correspondents, the timing, and the message count. +- `Rumor`, the unsigned event NIP-59 wraps. It is deliberately not `ISignable` and holds no signature field, so the deniability the scheme depends on is enforced by the type system rather than by convention. +- `DirectMessageRelayList` (kind 10050) and `DirectMessageService.planDelivery`, which pairs each participant's copy with the relays that participant nominated. NIP-17 permits delivery only to those relays and forbids sending at all to someone who published no list; an unreachable recipient is reported explicitly rather than omitted, so a message cannot go half-delivered unnoticed. +- `GenericEvent.update(long createdAt)`, which recomputes an event's id without consulting the clock. The existing no-arg `update()` delegates to it, so no call site changes behaviour. +- [How to send private direct messages](docs/howto/private-direct-messages.md). + +### Fixed +- `GenericEvent.getByteArraySupplier()` no longer resets `created_at` to the current time. It calls `update()`, and so ran during `Identity.sign()` — meaning **signing silently moved an event in time**. Any deliberately chosen timestamp was discarded moments after being set, which made NIP-59's randomised past timestamps impossible to produce and defeated the timing-correlation defence they exist to provide, while the calling code read as though the protection were present. An event that already carries a creation time now keeps it. + +### Deprecated +- NIP-04 encrypted direct messages (`EncryptedDirectMessage`, `MessageCipher04`). They remain functional for reading existing conversations and interoperating with clients that send nothing else, but new code should use NIP-17. Nothing is removed in this release. + ## [2.0.8] - 2026-08-22 ### Fixed diff --git a/docs/README.md b/docs/README.md index 71d98618..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 @@ -30,6 +31,8 @@ Quick links to the most relevant guides and references. - [explanation/extending-events.md](explanation/extending-events.md) — Working with events and tags (GenericEvent, GenericTag, Kinds) - [explanation/architecture.md](explanation/architecture.md) — Module architecture and data flow +- [explanation/nostr-java-mcp-spec.md](explanation/nostr-java-mcp-spec.md) — Draft spec for the `nostr-java-mcp` MCP server module +- [explanation/nip-17-direct-messages-spec.md](explanation/nip-17-direct-messages-spec.md) — Draft spec for NIP-17 private direct messages and NIP-59 gift wrapping - [explanation/dependency-alignment.md](explanation/dependency-alignment.md) — How versions are aligned via BOM ## Developer 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..09d6a104 --- /dev/null +++ b/docs/explanation/nip-17-direct-messages-spec.md @@ -0,0 +1,472 @@ +# 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** — 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 — and a +working version of that layer already exists in a sibling repository, which §3 reviews. + +## 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. + +### 4.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. + +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( + 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. + +### 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 +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. + +## 5. Design + +All new code lands in `nostr-java-event` and `nostr-java-identity`. Nothing in `core` +changes; nothing in `client` changes. + +### 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": ""`. A Java `record`, following imani-bridge's modelling (§3), which got this right. + +**`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. + +### 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`. + +```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 §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. + +`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. + +### 5.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. + +## 6. 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. + +## 7. 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. + +## 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 +`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. + +## 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 + 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. **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. +- **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. + +## 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 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. 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. + +## 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 + 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? 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 + +- [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 +- `imani-bridge/wallet-plugin/wallet-nips/wallet-nip-17` — the prior implementation reviewed in §3 diff --git a/docs/explanation/nostr-java-mcp-spec.md b/docs/explanation/nostr-java-mcp-spec.md new file mode 100644 index 00000000..6d954a06 --- /dev/null +++ b/docs/explanation/nostr-java-mcp-spec.md @@ -0,0 +1,585 @@ +# 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, 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. +- 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 + 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. 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. 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. | +| 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 + +``` +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. + +## 5. Architecture + +Four layers, each with one reason to change: + +| Layer | Responsibility | Changes when | +| --- | --- | --- | +| **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`, `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. +- `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 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). + +## 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. + +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?` | +| `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` | 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.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.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` | + +### 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 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 +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.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), + `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.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. +- 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. + +## 7. Configuration + +Spring Boot properties under `nostr.mcp.*`, overridable by environment variables: + +```yaml +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 + identities: + default: + 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 + write-policy: confirm # deny | confirm | allow +``` + +Keys must never be logged. The startup banner prints public keys only. + +### 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_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.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 +the SDK has no NIP-46 support, but `KeySource`/signing-service seam above exists precisely so +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 + +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 + +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`. +- Identity removal and backup export are guarded by the same two-step confirmation, and + 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. +- 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. + +## 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`, `SUBSCRIPTION_UNKNOWN`, `SUBSCRIPTION_LIMIT_REACHED`, +`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 + +- **Unit**: each tool adapter against fake services — argument validation, bech32 decoding, + 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, 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. +- **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. 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. + +## 11. Delivery plan + +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` and + `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`. 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 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? +- 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 + upstream in the BOM's event module? + +## 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 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-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-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-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-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/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/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/main/java/nostr/event/impl/GenericEvent.java b/nostr-java-event/src/main/java/nostr/event/impl/GenericEvent.java index 7962ddb9..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 @@ -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); @@ -244,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-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/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-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-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")); + } +} 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/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 new file mode 100644 index 00000000..60b43005 --- /dev/null +++ b/nostr-java-identity/src/main/java/nostr/encryption/DirectMessageService.java @@ -0,0 +1,85 @@ +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); + + /** + * 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. + * + *

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/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/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 { 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 new file mode 100644 index 00000000..32ad548c --- /dev/null +++ b/nostr-java-identity/src/main/java/nostr/encryption/Nip17DirectMessageService.java @@ -0,0 +1,128 @@ +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.ArrayList; +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 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. + * + *

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/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/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()); + } +} 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()); + } +} 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; + } +} 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())); + } +} 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