Files
coracle-rust/book/research/protected-events.md
T
2026-05-20 12:27:22 -07:00

231 lines
11 KiB
Markdown

# Research: Protected Events
## Topic Summary
NIP-70 defines an optional, valueless `["-"]` tag — the "protected" marker.
Its presence asks relays to accept the event *only* when it is published
directly by its author, where "directly by its author" means the publishing
connection has authenticated via NIP-42 and the authenticated pubkey matches
the event's `pubkey`. A relay that receives a protected event over an
unauthenticated connection should issue a NIP-42 `AUTH` challenge and reject
the event for now; a relay that receives one from an authenticated connection
whose pubkey is *not* the author should reject it outright.
Like NIP-40 expiration, this is a **behavior tag**: orthogonal to the event's
kind, it requests cooperation from the network rather than altering what the
event means. It is a hint, not a guarantee — once an event is signed and held
by anyone, no tag can claw it back.
This chapter adds tag-layer support only, mirroring the Expiring Events
chapter: a `Tag::protected()` constructor, an `is_protected(&Tags)` predicate,
and `is_protected()` method facades on `HashedEvent` and `Event`. Relay-side
enforcement (the NIP-42 auth-then-compare logic) is deferred to the Relay
Authentication chapter (ch 26). The unusual property of this tag — that it
carries no value — is the only thing distinguishing the API from expiration:
there is nothing to parse, so there is no `get_*` function, just a boolean.
## Philosophy
`ref/building-nostr` addresses NIP-70 directly and frames it the way this
chapter should.
- **Behavior tags are cooperative requests.** The book classifies tags as
data, filter, or behavior tags, and names NIP-70 explicitly: *"the NIP 70
protected tag `['-']` has no values. The presence of the tag merely indicates
that relays should only accept such event if they're published directly by
their author."* Behavior tags "determine how implementations should handle an
event, independent of the specification which defines the event's kind." This
is exactly the framing already used for expiration — and a reader arriving
from chapter 9 will recognize it.
- **Signed data cannot be unpublished.** *"Once it's published, there's no way
to un-sign or un-publish the data. Anyone who has a copy can keep it."* The
protected tag does not make an event secret; it asks relays to limit who they
will *accept* it from going forward. The chapter must be honest that this is
intent-signaling, not enforcement.
- **Access control is the relay's job, via AUTH.** *"There is one additional
responsibility that relays can't really delegate: access control. Access
control on Nostr is implemented by the `AUTH` verb."* This is why NIP-70's
enforcement story belongs to the networking layer and NIP-42, not to the
stateless tag layer. The library's job here is only to read and write the
marker.
- **Censorship resistance vs. access control.** *"An architecture optimized for
censorship resistance is necessarily not optimized for access controls or
privacy."* Protected events ask relays to *limit* distribution, a deliberate
deviation from nostr's broadcast default — useful framing for why this tag
exists at all.
- **Routing implications.** The book notes that access-controlled content (it
cites NIP-29 groups) should *not* be routed by the usual outbox heuristic,
since that would "leak" content meant to be protected. A brief forward
reference: protected events interact with relay selection, but that is a later
concern.
## Reference Implementation Analysis
### applesauce
Supported, client-side only, in `applesauce-core`:
- `isProtectedEvent(event)``helpers/event.ts:160`:
`return event.tags.some((t) => t[0] === "-");`
- `setProtected(set = true)``operations/event.ts:91`: adds the `["-"]`
singleton tag, or removes it, via `setSingletonTag(["-"])` /
`removeSingletonTag("-")`.
- `protected(isProtected)``factories/event.ts:181`: fluent builder method on
`EventFactory`.
The `["-"]` tag is treated like any other singleton tag (`expiration`, `alt`),
with no special-casing. No relay enforcement, no NIP-42 coupling. Notably,
`isProtectedEvent` is never *called* elsewhere in the library except in tests —
it is a primitive offered to consumers.
### ndk
Supported as a getter/setter on `NDKEvent` (`core/src/events/index.ts:868`):
```typescript
set isProtected(val: boolean) { this.removeTag("-"); if (val) this.tags.push(["-"]); }
get isProtected(): boolean { return this.hasTag("-"); }
```
The one place protection is *consumed* is reposts (`repost.ts:24`): when
building a NIP-18 repost, a protected original is **not** JSON-embedded in the
repost content. No relay-side enforcement; NDK is a client library, so the
relay rule is out of scope by design.
### nostr-gadgets
**Not implemented.** No references to nip70/protected/`["-"]` anywhere. The
library is a high-level client utility layer (profile loading, lists, relay
hints, outbox) that delegates event construction to `@nostr/tools` and never
touches the protected tag.
### nostrlib
The most complete implementation, including relay enforcement. Dedicated
`nip70/nip70.go`:
- `IsProtected(event) bool` — iterates tags, returns true if any tag is exactly
`["-"]`.
- `HasEmbeddedProtected(event) bool` — for reposts (kind 6/16), detects an
embedded `["-"]` in the stringified content via substring search.
Relay enforcement lives in the khatru handler (`khatru/handlers.go:192`):
```go
if nip70.IsProtected(env.Event) {
authed, is := GetAuthed(ctx)
if !is {
RequestAuth(ctx) // NIP-42 challenge
// OK:false "auth-required: must be published by authenticated event author"
} else if authed != env.Event.PubKey {
// OK:false "blocked: must be published by event author"
}
} else if nip70.HasEmbeddedProtected(env.Event) {
// OK:false "blocked: can't repost nip70 protected"
}
```
There is also an opt-in policy `OnlyAllowNIP70ProtectedEvents`. No client-side
builder — callers append `["-"]` by hand. The auth-then-compare flow is the
canonical relay logic and is what our ch 26 will implement.
### nostr-tools
Minimal: the only handling is one inline check inside NIP-18 reposts
(`nip18.ts:41`):
```typescript
content: t.content === '' || reposted.tags?.find(tag => tag[0] === '-') ? '' : JSON.stringify(reposted),
```
If a reposted event is protected, the repost content is emptied. No standalone
detector, no builder, no relay rules — consistent with the library's low-level,
minimal philosophy. A single test covers the repost-emptying behavior.
### rust-nostr
First-class and the closest analog to our target design. The `-` tag is a named
enum variant rather than a generic string tag:
- `TagKind::Protected``"-"` (`event/tag/kind.rs`).
- `TagStandard::Protected`, parsed from `["-"]` (`event/tag/standard.rs`).
- `Tag::protected() -> Self` (`event/tag/mod.rs:469`).
- `Tag::is_protected(&self) -> bool` (`mod.rs:529`) —
`matches!(self.as_standardized(), Some(TagStandard::Protected))`.
- `Event::is_protected(&self) -> bool` (`event/mod.rs:263`) —
`self.tags.find_standardized(TagKind::Protected).is_some()`.
Relay enforcement in `nostr-relay-builder` mirrors nostrlib: if protected and
not NIP-42-authenticated, send AUTH; if authed pubkey ≠ event pubkey, reject
with "this event may only be published by its author". A `test_protected_event`
round-trips `{"tags":[["-"]]}` JSON. Clean, minimal, self-contained; no separate
NIP-70 module because the feature is small enough to live in the tag system.
### welshman
**Not implemented.** No nip70/protected/`["-"]` handling anywhere. Welshman has
generic tag utilities (`getTag`, `getTagValue`, specialized `p`/`e`/`a`/`t`
helpers) and a NIP-42 auth event builder (`makeRelayAuth`), but nothing wires
them to a protected-event rule. As the sibling library to this one, its absence
is a small data point that NIP-70 is often skipped — but our chapter adds it.
## Common Patterns
- **The tag is `["-"]`, valueless.** Every implementation that handles it agrees
presence alone is the signal; there is nothing to parse. This is the single
most important fact for our API: a boolean predicate, no `get_*`.
- **Detection is a one-liner.** Universally "does any tag's first element equal
`"-"`" — i.e., `tags.has("-")` in our existing `Tags` vocabulary.
- **Construction is trivial.** A constructor that returns `["-"]`. rust-nostr
gives it a named `Tag::protected()`; applesauce/ndk inline `["-"]`.
- **Method on the event is the ergonomic surface.** rust-nostr, ndk, and
applesauce all expose detection at the event level (`is_protected` /
`isProtected`), which is where callers actually hold the data.
- **Enforcement is relay-side and needs NIP-42.** nostrlib and rust-nostr
implement the identical flow: protected + unauthenticated → AUTH challenge +
reject; protected + authed-but-wrong-pubkey → reject. Client libraries
(applesauce, ndk, nostr-tools, welshman) do *not* enforce; they only mark.
- **The NIP-18 repost interaction recurs.** applesauce, ndk, nostr-tools, and
nostrlib all special-case reposts so a protected event isn't re-broadcast by
being embedded in someone else's repost content. This is a real, named
consequence of the tag worth a forward reference, but it belongs to a reposts
chapter, not here.
- **Two libraries skip it entirely** (nostr-gadgets, welshman), confirming the
feature is small and often deferred — which is why a focused, correct
tag-layer treatment is worth having.
## Considerations for Our Implementation
- **Mirror the expiration chapter's shape.** Free function on `&Tags`, a
`Tag::protected()` constructor in `tags.rs`, method facades on `HashedEvent`
and `Event`, a new `protected.rs` module, a hand-written integration test.
Consistency with ch 9 is a feature: the reader has just seen this exact shape.
- **No `get_*`, no parsing.** The tag has no value. The whole API is a boolean.
`is_protected(&Tags)` is `tags.has("-")` — our `Tags::has` already does the
work. The chapter should acknowledge that the free function is a thin wrapper
and justify it on consistency and discoverability grounds (a named NIP-70
function the reader can find), the same justification used for the expiration
constructor.
- **No `Timestamp`/value type concerns.** Unlike expiration there is no number,
so none of expiration's parsing/`<` discussion applies. The chapter is
shorter.
- **The constructor goes in `tags.rs`,** appended as another `impl Tag` block,
exactly like `Tag::expiration`. Discoverable next to `Tag::new`; no import
from `protected` just to build a tag.
- **Defer enforcement explicitly.** State the relay rule in prose — protected +
authed-author-only — and name where it will be implemented (Relay
Authentication, ch 26, which needs the NIP-42 machinery). Do not write
acceptance logic here; the tag layer is stateless and has no notion of a
connection or an authenticated pubkey.
- **Mention the repost consequence briefly,** as a forward reference, without
implementing it: a protected event should not be embedded in a NIP-18 repost.
- **No dependencies.** Pure std + existing crate types. No new `Cargo.toml`
entries, no workspace changes.
- **The `"-"` tag name is unusual** (a single dash, not a word). Worth one
sentence: it is intentionally terse and visually distinct, and it has no
values, which is why it does not look like other tags.