Skip to content

Escape control bytes the way the rest of the network escapes them - #103

Merged
sepehr-safari merged 1 commit into
mainfrom
control-bytes-escape-the-way-the-network-does
Sep 21, 2026
Merged

sepehr-safari merged 1 commit into
mainfrom
control-bytes-escape-the-way-the-network-does

Conversation

@sepehr-safari

Copy link
Copy Markdown
Contributor

Closes #102.

Two faults, one line of code between them.

The wire form was not JSON. toJson escapes content and tag fields with the id escaper, which handles the seven characters NIP-01 names and copies every other byte through untouched. RFC 8259 forbids a raw byte below 0x20 inside a JSON string, so an event whose content carried one was a byte sequence no JSON parser would accept. Ours included: fromJson(toJson(ev)) failed on our own output, and a relay would have rejected the event on the wire.

The id did not match the network. NIP-01 says the seven escapes are the only ones and all other characters go in verbatim, and this file followed that sentence deliberately. The implementations do not. nostr-tools builds the preimage with JSON.stringify; go-nostr writes the same behaviour by hand in escapeString. Both escape every remaining control byte as \u00XX. Following the sentence produced an id nothing else reproduces, so a correctly signed event from any JS or Go client read as a bad signature here, and an id computed here was unverifiable everywhere else.

Escaping the remaining control bytes as \u00XX fixes both, because the id form and the wire form want the same bytes. Everything from 0x20 up, raw UTF-8 included, is still copied verbatim, so the warning against reaching for a general-purpose encoder still holds: escaping non-ASCII would change the id.

What moves

The id computed for content or tags containing a control byte. Nothing else. The existing vectors are untouched because none of them carry one, and all 203 tests pass.

Tests

control bytes escape the way the rest of the network escapes them pins the serialization and the id against a vector derived from an independent implementation rather than from this code, so it fails if the escaping drifts back.

an event carrying control bytes survives its own wire format asserts the wire form contains no raw control byte and that fromJson accepts it. That parse is what used to fail.

Note for the reader

This reads against the literal wording of NIP-01, which is why the doc comment now says so at length. The choice is interoperability over the sentence: an id only means something if the rest of the network computes the same one.

Two faults, one line of code between them.

The wire form was not JSON. `toJson` escapes content and tag fields with the id escaper, which handles the seven characters NIP-01 names and copies every other byte through untouched. RFC 8259 forbids a raw byte below 0x20 inside a JSON string, so an event whose content carried one was a byte sequence no JSON parser would accept. Our own parser included: `fromJson(toJson(ev))` failed on our own output, and any relay would have rejected the event on the wire.

The id did not match the network's. NIP-01 says the seven escapes are the only ones and all other characters go in verbatim, and this file followed that sentence deliberately. The implementations do not. nostr-tools builds the preimage with `JSON.stringify` and go-nostr writes the same behaviour by hand in `escapeString`, so both escape every remaining control byte as `\u00XX`. Following the sentence produced an id nothing else reproduces: a correctly signed event from any JS or Go client read as a bad signature here, and an id computed here was unverifiable everywhere else.

Escaping the remaining control bytes as `\u00XX` fixes both, because the id form and the wire form want the same bytes. Everything from 0x20 up, raw UTF-8 included, is still copied verbatim, so the warning against reaching for a general-purpose encoder still holds: escaping non-ASCII would change the id.

This changes the id computed for content or tags containing a control byte. Nothing else moves, and the existing vectors are unaffected because none of them carry one.

The new vector is derived from an independent implementation rather than from this code, so it fails if the escaping ever drifts back. The round-trip test asserts the wire form carries no raw control byte at all, which is the thing that used to make it unparseable.
@sepehr-safari
sepehr-safari merged commit 1cb6d31 into main Sep 21, 2026
2 checks passed
@sepehr-safari sepehr-safari mentioned this pull request Sep 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

toJson emits raw control bytes, so the wire form is not JSON and the id does not match the network

1 participant