Repository navigation
Conversation
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
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.
6 tasks done
Owner
Author
chore(release): bump project version to 2.1.0
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 onlytest- Adding or updating testsWhat changed?
Reviewers may want to start at
Nip59GiftWrapper, which is where the security-relevant logic lives.nostr-java-eventRumor— the unsigned event NIP-59 wraps. Deliberately notISignable, 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-argupdate()delegates to it, so no call site changes behaviour.nostr-java-identityGiftWrapper/Nip59GiftWrapper— two methods,wrapandunwrap. 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
composereturns 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
DirectMessageRelayLookupleaves message composition verifiable without a network.Bug fixed along the way
GenericEvent.getByteArraySupplier()calledupdate(), and it runs duringIdentity.sign()— so signing silently resetcreated_atto 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 verifypasses 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 author18b1a759…, and the result opens to the exact rumor id9dd003c6…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.
mvn testmvn verifyReview focus
Nip59GiftWrapper.unwrap. A rumor is unsigned, so the seal's signature is the only evidence of authorship. Both the signature verification and therumor.pubkey == seal.pubkeycomparison are load-bearing: without the latter, anyone can attribute a message to anyone. Both were missing from the prior implementation we reviewed.getByteArraySupplierchange. It alters behaviour for every event that already has acreated_atset before signing. I believe preserving a caller's chosen timestamp is right in all cases, but it is the widest-reaching change here.planDeliverybelongs onDirectMessageServiceor in a separate publishing-oriented type.Checklist
mvn verifyindependentlyFollow-ups
imani-bridge'swallet-nip-17onto this implementation.