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

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 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):

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, 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.