11 KiB
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
AUTHverb." 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, viasetSingletonTag(["-"])/removeSingletonTag("-").protected(isProtected)—factories/event.ts:181: fluent builder method onEventFactory.
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):
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):
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):
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, noget_*. - Detection is a one-liner. Universally "does any tag's first element equal
"-"" — i.e.,tags.has("-")in our existingTagsvocabulary. - Construction is trivial. A constructor that returns
["-"]. rust-nostr gives it a namedTag::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, aTag::protected()constructor intags.rs, method facades onHashedEventandEvent, a newprotected.rsmodule, 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)istags.has("-")— ourTags::hasalready 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 anotherimpl Tagblock, exactly likeTag::expiration. Discoverable next toTag::new; no import fromprotectedjust 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.tomlentries, 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.