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

Filter by extension

Filter by extension


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

## [Unreleased]

## [2.4.2] - 2026-10-04

### Fixed
- `GenericEvent.update()` silently overwrote a caller-set `created_at`, so the id could be
computed for a different second than the one the caller published (relays rejected these as
`invalid: bad event id`), and NIP-59 randomised timestamps were replaced by the send time.
`update()` now keeps a `created_at` that is already set, as NIP-01 requires the id to be
derived from the event's own fields, and stamps the current time only when it is unset
(null or zero). ([#559](https://github.com/tcheeric/nostr-java/issues/559))

### Added
- `GenericEvent.updateWithCurrentTime()`, for callers that deliberately want to restamp an
event with the current time.
- "See it in use" section in the MCP server how-to, pointing to the Lyrebird bot that posts
video clips and NIP-84 highlights through this server.

## [2.4.1] - 2026-09-25

### Fixed
Expand Down
6 changes: 6 additions & 0 deletions docs/explanation/nip-17-direct-messages-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,12 @@ gift-wrap code the seam it needs. The alternative, a `Clock`/`Supplier<Long>` in
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).

**Update (issue #559).** Keeping the old `update()` behaviour turned out to be a trap of its
own: callers that set `created_at` and then called `update()` still lost it, and published ids
computed for a different second. `update()` now keeps any `created_at` already set and only
consults the clock when it is unset (null or zero). Restamping is explicit, through
`updateWithCurrentTime()`.

### 4.2 `MessageCipher44` takes raw key bytes and prefixes `02`

```java
Expand Down
2 changes: 1 addition & 1 deletion docs/howto/multi-relay-publishing.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ send a private direct message.
<dependency>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java-api</artifactId>
<version>2.4.1</version>
<version>2.4.2</version>
</dependency>
```

Expand Down
9 changes: 9 additions & 0 deletions docs/howto/run-the-mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,15 @@ mvn -pl nostr-java-mcp verify -Dexcluded.it.groups= -Dgroups=model-driven
It is excluded from the ordinary build because it takes several minutes. Run it when you change
a tool's name, description or schema: those are exactly the changes nothing else can catch.

## See it in use

[Lyrebird](https://njump.me/npub12sz2fjjlfw4ydpecw5w3cvqf3ac5ca9x86ekw00lcgmq655x4taqh63tcw)
is a Nostr bot that runs on this server. It cuts clips from videos and posts them as notes with
the video hosted on Blossom, and turns passages from articles into NIP-84 highlights (kind
9802). Every post goes through the same tools described above: `nostr_blossom_upload` for the
media, then a publish under `write-policy: confirm` so a person approves each post before it
goes out.

## Related

- [Send and read NIP-17 private direct messages](private-direct-messages.md)
Expand Down
2 changes: 1 addition & 1 deletion nostr-java-api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>2.4.1</version>
<version>2.4.2</version>
<relativePath>../pom.xml</relativePath>
</parent>

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

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

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

Expand Down
35 changes: 26 additions & 9 deletions nostr-java-event/src/main/java/nostr/event/impl/GenericEvent.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;

Check warning on line 30 in nostr-java-event/src/main/java/nostr/event/impl/GenericEvent.java

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Unused import

Unused import `import java.util.Optional;`
import java.util.function.Consumer;
import java.util.function.Supplier;

Expand All @@ -42,6 +42,9 @@
@EqualsAndHashCode
public class GenericEvent implements ISignable {

/** A {@code created_at} of zero means the caller never set one, so the clock may supply it. */
private static final long UNSET_CREATED_AT = 0L;

@EqualsAndHashCode.Include private String id;

@JsonProperty("pubkey")
Expand Down Expand Up @@ -147,19 +150,37 @@
}

/**
* Stamps the event with the current time, then recomputes its serialization and id.
* Recomputes this event's serialization and id, keeping any {@code created_at} already set.
*
* <p>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.
* <p>The clock is consulted only when the event has no creation time yet. A timestamp the
* caller chose, such as the randomised {@code created_at} NIP-59 requires on seals and gift
* wraps, is never replaced, so the id always matches the {@code created_at} the caller sees.
* Use {@link #updateWithCurrentTime()} to restamp deliberately.
*/
public void update() {
if (hasCreatedAt()) {
update(this.createdAt);
} else {
updateWithCurrentTime();
}
}

/**
* Stamps the event with the current time, replacing any existing {@code created_at}, then
* recomputes its serialization and id.
*/
public void updateWithCurrentTime() {
update(Instant.now().getEpochSecond());
}

private boolean hasCreatedAt() {
return this.createdAt != null && this.createdAt != UNSET_CREATED_AT;
}

/**
* Recomputes this event's serialization and id against the supplied creation time.
*
* <p>Unlike {@link #update()} this does not consult the clock, so a deliberately chosen
* <p>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.
Expand Down Expand Up @@ -276,11 +297,7 @@
@Transient
@Override
public Supplier<ByteBuffer> getByteArraySupplier() {
if (this.createdAt != null) {
this.update(this.createdAt);
} else {
this.update();
}
this.update();
if (log.isTraceEnabled()) {
log.trace("Serialized event: {}", new String(this.get_serializedEvent()));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
Expand Down Expand Up @@ -75,13 +78,10 @@ void computesSameIdForSameCreatedAt() {
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.
*/
/** An event with no creation time is stamped with the current time. */
@Test
@DisplayName("still stamps the current time when no timestamp is supplied")
void stampsCurrentTimeWithoutArgument() {
@DisplayName("stamps the current time when no created_at is set")
void stampsCurrentTimeWhenUnset() {
long before = Instant.now().getEpochSecond();
GenericEvent event = anEvent();

Expand All @@ -91,21 +91,63 @@ void stampsCurrentTimeWithoutArgument() {
assertTrue(event.getCreatedAt() >= before && event.getCreatedAt() <= after);
}

/** A created_at of zero counts as unset, so it is replaced with the current time. */
@Test
@DisplayName("treats a zero created_at as unset")
void treatsZeroCreatedAtAsUnset() {
long before = Instant.now().getEpochSecond();
GenericEvent event = anEvent();
event.setCreatedAt(0L);

event.update();

assertTrue(event.getCreatedAt() >= before);
}

/**
* 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.
* Regression for issue #559: a created_at the caller set survives {@code update()}. Losing it
* produced ids for a different second than the one the caller published, and erased the
* randomised timestamps NIP-59 seals and gift wraps depend on.
*/
@Test
@DisplayName("overwrites a preset created_at when no timestamp is supplied")
void overwritesPresetCreatedAtWithoutArgument() {
@DisplayName("keeps a preset created_at when no timestamp is supplied")
void keepsPresetCreatedAtWithoutArgument() {
long twoDaysAgo = Instant.now().minusSeconds(2 * 24 * 60 * 60).getEpochSecond();
GenericEvent event = anEvent();
event.setCreatedAt(twoDaysAgo);

event.update();

assertNotEquals(twoDaysAgo, event.getCreatedAt());
assertEquals(twoDaysAgo, event.getCreatedAt());
}

/** The id computed by {@code update()} is the NIP-01 hash for the preset created_at. */
@Test
@DisplayName("computes the NIP-01 id for a preset created_at")
void computesNip01IdForPresetCreatedAt() throws Exception {
GenericEvent event = anEvent();
event.setCreatedAt(1_700_000_000L);

event.update();

String canonical =
"[0,\"" + AUTHOR + "\",1700000000," + Kinds.SEAL + ",[],\"encrypted-payload\"]";
byte[] digest =
MessageDigest.getInstance("SHA-256").digest(canonical.getBytes(StandardCharsets.UTF_8));
assertEquals(HexFormat.of().formatHex(digest), event.getId());
}

/** Restamping is still available, but only when asked for by name. */
@Test
@DisplayName("replaces a preset created_at when restamping explicitly")
void replacesPresetCreatedAtWhenRestampingExplicitly() {
long twoDaysAgo = Instant.now().minusSeconds(2 * 24 * 60 * 60).getEpochSecond();
GenericEvent event = anEvent();
event.setCreatedAt(twoDaysAgo);

event.updateWithCurrentTime();

assertTrue(event.getCreatedAt() >= Instant.now().getEpochSecond() - 1);
}

/** Tags are included in the serialization that backs the id. */
Expand Down
2 changes: 1 addition & 1 deletion nostr-java-identity/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>2.4.1</version>
<version>2.4.2</version>
<relativePath>../pom.xml</relativePath>
</parent>

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

Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>2.4.1</version>
<version>2.4.2</version>
<packaging>pom</packaging>

<name>nostr-java</name>
Expand Down
Loading