Skip to content

feat: add NIP-17 private direct messages with NIP-59 gift wrapping - #546

Closed
tcheeric wants to merge 16 commits into
mainfrom
feat/nip-17-direct-messages
Closed

tcheeric wants to merge 16 commits into
mainfrom
feat/nip-17-direct-messages

Conversation

@tcheeric

@tcheeric tcheeric commented Aug 30, 2026 •

Copy link
Copy Markdown
Owner

Summary

Adds NIP-17 private direct messages and the NIP-59 gift wrapping they build on.

The SDK's only DM support was NIP-04, which hides a message's text but leaves the sender, the recipient, the exact time, and the message count public on every relay that carries the event. An observer learns who talks to whom and when. NIP-17 hides all of it.

The NIP-44 cryptography already existed in nostr-java-core; what was missing was the event-composition layer above it.

Closes #540, closes #541, closes #542, closes #543, closes #544

Type of change

  • fix - Bug fix (non-breaking)
  • feat - New feature (non-breaking)
  • docs - Documentation only
  • test - Adding or updating tests

What changed?

Reviewers may want to start at Nip59GiftWrapper, which is where the security-relevant logic lives.

nostr-java-event

  • Rumor — the unsigned event NIP-59 wraps. Deliberately not ISignable, with no signature field, so the deniability the scheme depends on is enforced by the type system rather than by convention.
  • ChatMessage — the kind-14 message, with recipients, an optional conversation subject, and an optional reply parent.
  • DirectMessageRelayList — the kind-10050 list naming where someone receives private messages.
  • Kinds — constants for kinds 13, 14, 15, 1059, 10050, 21059.
  • GenericEvent.update(long createdAt) — recomputes an event id without consulting the clock. The existing no-arg update() delegates to it, so no call site changes behaviour.

nostr-java-identity

  • GiftWrapper / Nip59GiftWrapper — two methods, wrap and unwrap. Callers never handle a seal, an ephemeral key, or a conversation key. Generic over event kind, so it is useful beyond messaging.
  • DirectMessageService / Nip17DirectMessageService — composes and reads messages. Pure functions of their inputs: no publishing, no subscribing, no inbox.
  • MessageDelivery / DirectMessageRelayLookup — pairs each participant's copy with the relays that participant nominated.

Design decisions worth a look

compose returns a list, not an event. NIP-17 has no shared envelope: each participant gets a separately encrypted copy, which is what keeps the conversation's membership private. Making that plurality visible in the signature stops a caller publishing one event and silently dropping the rest. The sender is a participant too — a sender who published only their recipients' copies could never read the conversation back.

An unreachable recipient is reported, not omitted. NIP-17 forbids sending to someone who published no kind-10050 list. No wrap is built for them, so an event that must not be sent cannot later be published by mistake, but they still appear in the delivery plan carrying no event. Silently omitting them is how a message goes half-delivered without anyone noticing, and it separates "this person does not accept private messages" from "the relay was down".

Relay lookup is an interface. Resolving a kind-10050 list means querying relays. Keeping that behind DirectMessageRelayLookup leaves message composition verifiable without a network.

Bug fixed along the way

GenericEvent.getByteArraySupplier() called update(), and it runs during Identity.sign() — so signing silently reset created_at to the current time. Any deliberately chosen timestamp was discarded moments after being set.

This made NIP-59's randomised past timestamps impossible to produce, defeating 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.

This is not hypothetical: it is the root cause of a live privacy defect in a downstream implementation (398ja/imani-bridge#335), where every gift wrap in production carries its true send time.

Breaking changes

None. NIP-04 (EncryptedDirectMessage, MessageCipher04) is deprecated but fully functional, for reading existing conversations and interoperating with clients that send nothing else. Removal is a separate decision.

Testing

mvn -q verify passes from the repository root, exit 0, no failures and no new warnings.

93 new tests. The decisive ones reproduce the worked examples published in the NIPs, which pins the implementation to the specification rather than to our reading of it:

  • Nip59GiftWrapperTest (18) — wrapping NIP-59's example rumor with its published ephemeral key reproduces the published wrap author 18b1a759…, and the result opens to the exact rumor id 9dd003c6… the spec states.
  • RumorTest (12) — the derived id matches NIP-59's published value.
  • Nip17DirectMessageServiceTest (14) — uses the identities from NIP-17's own example.
  • MessageDeliveryPlanTest (8), DirectMessageRelayListTest (9), ChatMessageTest (13), RumorJsonCodecTest (7), GenericEventUpdateTest (7).
  • PrivateDirectMessagesHowToTest (7) — runs every snippet printed in the new guide, so the documentation cannot drift from the API unnoticed.

Adversarial cases each have a named test with a defined outcome: a forged rumor naming another author, an invalidly signed seal, an unsigned seal, a wrap containing something other than a seal, a wrap addressed to someone else, and corrupted ciphertext.

  • Unit tests pass: mvn test
  • Integration tests pass: mvn verify

Review focus

  1. The two authentication checks in Nip59GiftWrapper.unwrap. A rumor is unsigned, so the seal's signature is the only evidence of authorship. Both the signature verification and the rumor.pubkey == seal.pubkey comparison are load-bearing: without the latter, anyone can attribute a message to anyone. Both were missing from the prior implementation we reviewed.
  2. The getByteArraySupplier change. It alters behaviour for every event that already has a created_at set before signing. I believe preserving a caller's chosen timestamp is right in all cases, but it is the widest-reaching change here.
  3. Whether planDelivery belongs on DirectMessageService or in a separate publishing-oriented type.

Checklist

  • PR title follows conventional commits
  • Changes are focused and under 300 lines — exceeded deliberately. 27 files, +3426/-4, roughly two thirds of it tests and documentation. Six sequenced commits, one per phase of the implementation spec, each reviewable on its own and each passing mvn verify independently
  • Tests added/updated for new functionality
  • No new compiler warnings introduced
  • CHANGELOG.md updated

Follow-ups

  • Kind-15 file messages, disappearing messages, and NIP-42 AUTH are out of scope (see the spec's Scope section). AUTH matters: relays are advised to serve kind-1059 only to the addressee, behind authentication.
  • #545 migrates imani-bridge's wallet-nip-17 onto this implementation.

tcheeric added 13 commits August 30, 2026 09:30
Official MCP SDK, local encrypted keystore with NIP-46 deferred, long-lived
streaming subscriptions, docker-compose packaging, and NIP-17 direct messages.
Create, import, rename, export, and remove identities as tools, with key
material never crossing the model context and irreversible operations
guarded by two-step confirmation.
Bind one server process to one identity for hard key isolation, deployed
as one process per identity. Documents why threads do not provide this,
and relocates key administration to a CLI on the same jar.
Reframes the work as a port with corrections. Documents a confirmed
timestamp bug caused by GenericEvent.update(), five copies of hand-rolled
JSON, and two missing security checks (seal signature verification and the
NIP-17 impersonation check).
Introduces the NIP-59 rumor: an unsigned event that carries direct message
content before it is sealed and gift wrapped. Rumor deliberately does not
implement ISignable and holds no signature field, so the deniability
property NIP-59 depends on is enforced by the type system rather than by
convention. Ids are derived through EventSerializer, and JSON is handled by
Jackson, so canonical serialization has a single implementation.

Adds GenericEvent.update(long createdAt), which recomputes the id without
consulting the clock. The existing no-arg update() delegates to it, so no
call site changes behaviour. Without this seam a caller cannot set the
randomised past timestamp NIP-59 requires on seals and gift wraps, because
computing the id silently resets it to now.

Adds the NIP-17 and NIP-59 kind constants.

Verified against the worked example published in NIP-59: the derived rumor
id matches the specification exactly.

Refs #540
Adds GiftWrapper, whose two methods hide a rumor and reveal it, and
Nip59GiftWrapper, which seals a rumor to its recipient and wraps the seal
under a key generated for that one event and then discarded.

The receive path authenticates before it returns. A rumor carries no
signature, so the seal's signature is the only evidence of authorship: it
is verified, and the rumor's author is compared against the sealing key.
Without that comparison anyone could attribute a message to anyone by
editing an unsigned field, which NIP-17 calls out explicitly. The rumor id
is recomputed as a third check.

Timestamps are drawn from SecureRandom over the two days preceding the
event and never move into the future, so wraps cannot be correlated by
time and relays will not drop them for being future-dated.

Also fixes GenericEvent.getByteArraySupplier, which called update() and so
reset created_at during signing. That silently discarded any deliberately
chosen timestamp, making correct gift wrapping impossible even for a
caller using update(long).

Verified against the worked example published in NIP-59: wrapping its rumor
with the published ephemeral key reproduces the published wrap author, and
the resulting event opens to the exact rumor id the specification states.

Refs #541
Adds ChatMessage, the kind-14 rumor an application author actually works
with, carrying recipients, an optional conversation subject, and an
optional parent for replies. Recipients plus sender define a conversation,
so a participant added or removed starts a different one.

Adds DirectMessageService and its NIP-17 implementation. Composing a
message yields one gift wrap per participant rather than a single event,
because there is no shared envelope: each copy is separately encrypted,
which is what keeps a conversation's membership private. Making that
plurality visible in the signature stops a caller publishing one event and
silently dropping the rest.

The sender is a participant too. A sender who published only their
recipients' copies could never read the conversation back, since they
cannot decrypt a wrap addressed to someone else. composeByRecipient keeps
each participant paired with their own event, which phase 4 needs to
publish to the relays each recipient nominated.

Sending a message attributed to another identity is refused up front, since
the recipient would reject it as forged.

The service neither publishes nor retains anything, so a message can be
composed and verified without a relay.

Verified with the identities from the worked example published in NIP-17.

Refs #542
Adds DirectMessageRelayList, the kind-10050 event naming where someone
receives private messages, and planDelivery, which pairs each participant's
copy with the relays that should carry it.

NIP-17 permits delivery only to the relays a recipient nominated, and
forbids sending at all to someone who published no list. Publishing
elsewhere is not merely ineffective: it scatters a metadata-bearing event
across relays the recipient never chose while the one they actually read
may never see it. Phases 1 to 3 produced correct messages that were still
not safe to put on the network; this closes that gap.

No wrap is built for an unreachable recipient, so an event that must not be
sent cannot later be published by mistake. Such a recipient still appears
in the plan carrying no event, because silently omitting them is how a
message goes half-delivered without anyone noticing. That also separates
'this person does not accept private messages' from 'the relay was down'.

Relay lists are resolved through DirectMessageRelayLookup rather than by
querying relays directly, keeping message composition a pure function that
can be verified without a network.

Refs #543
Adds a how-to for sending and reading NIP-17 messages, covering the two
things callers most often get wrong: that composing a message yields one
event per participant including a copy addressed to the sender, and that a
kind-1059 subscription delivers wraps you cannot open, so reading an inbox
must skip failures rather than abandon the batch.

Deprecates the NIP-04 types. They hide a message's text but leave the
correspondents, the timing, and the message count public on every relay
that carries the event. Nothing is removed: NIP-04 remains functional for
reading existing conversations and for interoperating with clients that
send nothing else.

Refs #544
Each snippet printed in docs/howto/private-direct-messages.md appears here
in a form close enough to fail if the API changes under it. Documentation
that has drifted from the API is worse than none.

Refs #544
@tcheeric tcheeric added enhancement New feature or request nip-17 NIP-17 private direct messages java Pull requests that update java code labels Aug 30, 2026
tcheeric added 2 commits August 30, 2026 11:29
Minor bump: NIP-17 and NIP-59 support is additive, and the NIP-04
deprecation removes nothing.
@tcheeric

Copy link
Copy Markdown
Owner Author

Version bump for this work is stacked on top in #547 (2.0.8 → 2.1.0, minor: this PR is additive and deprecates NIP-04 without removing it).

Merge order: this PR first, then #547.

chore(release): bump project version to 2.1.0
@tcheeric tcheeric closed this Aug 31, 2026
@tcheeric
tcheeric deleted the feat/nip-17-direct-messages branch August 31, 2026 20:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request java Pull requests that update java code nip-17 NIP-17 private direct messages

Projects

None yet

1 participant