Add protected chapter
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user