231 lines
11 KiB
Markdown
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.
|