Add protected chapter
This commit is contained in:
@@ -0,0 +1,147 @@
|
|||||||
|
# Protected Events
|
||||||
|
|
||||||
|
The previous chapter introduced behavior tags through expiration: a tag
|
||||||
|
that requests something of the network without touching what the event
|
||||||
|
itself means. NIP-70 adds another, aimed at a different concern. Where
|
||||||
|
expiration asks relays to stop serving an event after a time,
|
||||||
|
NIP-70's `protected` tag asks them to be careful about who they accept
|
||||||
|
it from in the first place. Its shape on the wire is a single, valueless
|
||||||
|
tag:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["-"]
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the whole thing — a tag whose name is a lone dash and which
|
||||||
|
carries no value. Its mere presence is the signal. An event marked this
|
||||||
|
way should be accepted by a relay only when it is published *directly by
|
||||||
|
its author*: the connection delivering it must have authenticated over
|
||||||
|
NIP-42, and the authenticated key must match the event's `pubkey`.
|
||||||
|
|
||||||
|
## What the marker asks for
|
||||||
|
|
||||||
|
A relay that honors NIP-70 runs a small decision when a protected event
|
||||||
|
arrives. If the connection has not authenticated, the relay issues a
|
||||||
|
NIP-42 `AUTH` challenge and rejects the event for now, inviting the
|
||||||
|
client to prove who it is and try again. If the connection *has*
|
||||||
|
authenticated but the authenticated key is not the event's author, the
|
||||||
|
relay rejects the event outright — someone other than the author is
|
||||||
|
trying to push it. Only an authenticated author gets through.
|
||||||
|
|
||||||
|
As with expiration, the marker is a cooperative request, not a guarantee.
|
||||||
|
A relay is free to ignore it; a relay that has already stored the event
|
||||||
|
keeps it; the signature stays valid regardless. The tag asks well-behaved
|
||||||
|
relays to gate acceptance on authorship.
|
||||||
|
|
||||||
|
## The module
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/lib.rs}
|
||||||
|
pub mod protected;
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/protected.rs}
|
||||||
|
//! NIP-70 protected events: the valueless `["-"]` marker that asks
|
||||||
|
//! relays to accept an event only from its authenticated author.
|
||||||
|
//!
|
||||||
|
//! This module is the stateless half of NIP-70 — building the tag and
|
||||||
|
//! detecting it. The relay-side enforcement it implies (a NIP-42 `AUTH`
|
||||||
|
//! challenge followed by a pubkey comparison) needs a live, authenticated
|
||||||
|
//! connection and is handled in the relay layer.
|
||||||
|
|
||||||
|
use crate::events::{Event, HashedEvent};
|
||||||
|
use crate::tags::Tags;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Detecting the marker
|
||||||
|
|
||||||
|
Because the tag has no value, detection collapses to a single question:
|
||||||
|
is a tag named `"-"` present? There is nothing to parse, so there is no
|
||||||
|
`Option` and no malformed-versus-absent distinction to draw — the answer
|
||||||
|
is a plain `bool`. `Tags::has` already answers exactly this.
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/protected.rs}
|
||||||
|
/// Whether the tag set carries the NIP-70 `["-"]` protected marker.
|
||||||
|
///
|
||||||
|
/// A protected event asks relays to accept it only from its
|
||||||
|
/// authenticated author. This function reports the marker's presence;
|
||||||
|
/// acting on it is the relay's job.
|
||||||
|
pub fn is_protected(tags: &Tags) -> bool {
|
||||||
|
tags.has("-")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building the tag
|
||||||
|
|
||||||
|
The constructor produces a tag with the name `"-"` and no values. We
|
||||||
|
build it through `Tag::new` like every other tag, passing an empty value
|
||||||
|
iterator so the result is the one-element `["-"]`.
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/tags.rs}
|
||||||
|
impl Tag {
|
||||||
|
/// Build a NIP-70 `["-"]` protected tag.
|
||||||
|
///
|
||||||
|
/// The tag has a name (`"-"`) and no values; its presence asks
|
||||||
|
/// relays to accept the event only from its authenticated author.
|
||||||
|
pub fn protected() -> Tag {
|
||||||
|
Tag::new("-", std::iter::empty::<String>())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Methods on event types
|
||||||
|
|
||||||
|
A caller holding an event wants to ask whether it is protected without
|
||||||
|
reaching for its tags directly. Both `HashedEvent` and `Event` carry a
|
||||||
|
`Tags` field, so the method facades delegate to the free function.
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/protected.rs}
|
||||||
|
impl HashedEvent {
|
||||||
|
/// Whether this event carries the NIP-70 protected marker.
|
||||||
|
pub fn is_protected(&self) -> bool {
|
||||||
|
is_protected(&self.tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Event {
|
||||||
|
/// Whether this event carries the NIP-70 protected marker.
|
||||||
|
pub fn is_protected(&self) -> bool {
|
||||||
|
is_protected(&self.tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage patterns
|
||||||
|
|
||||||
|
**Marking an event protected.** Attach the tag at template construction,
|
||||||
|
just like any other:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let event = EventContent::new()
|
||||||
|
.content("members only")
|
||||||
|
.tags(vec![Tag::protected()])
|
||||||
|
.kind(1)
|
||||||
|
.stamp(now)
|
||||||
|
.own(pubkey);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Checking on receipt.** A relay or client deciding how to handle an
|
||||||
|
incoming event asks the event directly:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
if event.is_protected() {
|
||||||
|
// only accept from an authenticated author
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's next
|
||||||
|
|
||||||
|
Marking an event protected is half of NIP-70; honoring the mark is the
|
||||||
|
other half, and it lives where connections and authentication do. The
|
||||||
|
[Relay Authentication](26-relay-authentication.md) chapter implements the
|
||||||
|
`AUTH` exchange and the author check that turn this tag into a rule.
|
||||||
|
|
||||||
|
One consequence is worth flagging now: a protected event should not be
|
||||||
|
re-broadcast by being embedded inside someone else's repost, since that
|
||||||
|
would route it past the very acceptance check it asked for. Reposts are
|
||||||
|
their own topic; the protected marker is simply something that logic will
|
||||||
|
need to consult.
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# Plan: Protected Events
|
||||||
|
|
||||||
|
## Topic Summary
|
||||||
|
|
||||||
|
NIP-70 defines an optional, valueless `["-"]` tag — the "protected" marker.
|
||||||
|
Its presence asks relays to accept the event only when published directly by
|
||||||
|
its author: the publishing connection must be NIP-42-authenticated and the
|
||||||
|
authenticated pubkey must equal the event's `pubkey`. Relays receiving a
|
||||||
|
protected event over an unauthenticated connection should issue an `AUTH`
|
||||||
|
challenge and reject it for now; relays receiving one from an authenticated but
|
||||||
|
non-matching connection should reject it outright.
|
||||||
|
|
||||||
|
Like NIP-40 expiration, this is a **behavior tag** — orthogonal to kind, a
|
||||||
|
cooperative request rather than a guarantee. This chapter adds tag-layer support
|
||||||
|
only: a constructor, a boolean predicate, and method facades. Relay-side
|
||||||
|
enforcement is deferred to the Relay Authentication chapter (ch 26), which has
|
||||||
|
the NIP-42 machinery. The tag carries no value, so — unlike expiration — there
|
||||||
|
is nothing to parse and no `get_*` function.
|
||||||
|
|
||||||
|
## Chapter Outline
|
||||||
|
|
||||||
|
1. **Framing** — protected as a behavior tag, sibling to expiration; the
|
||||||
|
`["-"]` tag has no value; the cooperative, relay-enforced (not cryptographic)
|
||||||
|
nature of the request. Pick up the thread chapter 9 left dangling.
|
||||||
|
2. **What the relay rule is** — state the NIP-42 auth-then-compare rule in prose
|
||||||
|
so the reader understands what the tag *asks for*, and explain why none of
|
||||||
|
that logic lives here (the tag layer is stateless; enforcement needs a
|
||||||
|
connection and an authenticated pubkey — ch 26).
|
||||||
|
3. **The module declaration** — `pub mod protected;` in `lib.rs` plus the module
|
||||||
|
header doc comment.
|
||||||
|
4. **The predicate** — `is_protected(&Tags) -> bool`. A thin wrapper over
|
||||||
|
`Tags::has("-")`; justify it on consistency and discoverability (a named
|
||||||
|
NIP-70 function), the same justification the expiration chapter used for its
|
||||||
|
constructor. No value to parse means no `Option`, just `bool`.
|
||||||
|
5. **Building the tag** — `Tag::protected() -> Tag` returning `["-"]`. One line;
|
||||||
|
the narrative explains the unusual single-dash, valueless shape.
|
||||||
|
6. **Methods on event types** — `is_protected(&self)` on both `HashedEvent` and
|
||||||
|
`Event`, delegating to the free function.
|
||||||
|
7. **Usage patterns** — building a protected event with the template builder;
|
||||||
|
checking on receipt. Brief.
|
||||||
|
8. **What's next** — bridge forward: enforcement lands with relay
|
||||||
|
authentication (ch 26); note the NIP-18 repost interaction (a protected event
|
||||||
|
shouldn't be embedded in a repost) as a forward reference, not implemented
|
||||||
|
here.
|
||||||
|
|
||||||
|
## API Design
|
||||||
|
|
||||||
|
### Module `coracle-lib/src/protected.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use crate::events::{Event, HashedEvent};
|
||||||
|
use crate::tags::Tags;
|
||||||
|
|
||||||
|
/// True if the tag set carries the NIP-70 `["-"]` protected marker.
|
||||||
|
pub fn is_protected(tags: &Tags) -> bool;
|
||||||
|
|
||||||
|
impl HashedEvent {
|
||||||
|
pub fn is_protected(&self) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Event {
|
||||||
|
pub fn is_protected(&self) -> bool;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`is_protected(tags)` is `tags.has("-")`.
|
||||||
|
|
||||||
|
### Tag constructor in `coracle-lib/src/tags.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl Tag {
|
||||||
|
/// Build a NIP-70 `["-"]` protected tag.
|
||||||
|
pub fn protected() -> Tag;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Implemented as `Tag::new("-", std::iter::empty::<String>())` — a tag with the
|
||||||
|
name `"-"` and no values, serializing as `["-"]`.
|
||||||
|
|
||||||
|
## Code Organization
|
||||||
|
|
||||||
|
- **`coracle-lib/src/protected.rs`** — new module: the free function and the two
|
||||||
|
method impls. Mirrors `expiration.rs`.
|
||||||
|
- **`coracle-lib/src/lib.rs`** — add `pub mod protected;` (after `expiration`,
|
||||||
|
matching SUMMARY order: ch 10 follows ch 9).
|
||||||
|
- **`coracle-lib/src/tags.rs`** — append a `Tag::protected` constructor inside a
|
||||||
|
new `impl Tag` block (code blocks targeting the same file concatenate; another
|
||||||
|
`impl Tag` block at the end is valid Rust, exactly as `Tag::expiration` does).
|
||||||
|
- **`coracle-lib/tests/protected.rs`** — hand-written integration test, not
|
||||||
|
tangled. Covers the constructor shape, the predicate (present / absent), the
|
||||||
|
method facades on both event types, and tag survival through hash + sign.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
None. Pure std and existing crate types. No new `Cargo.toml` entries, no new
|
||||||
|
workspace members.
|
||||||
|
|
||||||
|
## Narrative Notes
|
||||||
|
|
||||||
|
- **Lead by connecting to expiration.** Chapter 9 ends with an explicit promise:
|
||||||
|
"The next chapter introduces another — NIP-70's `protected` tag, which tells
|
||||||
|
relays not to accept an event from anyone but its author." Open by fulfilling
|
||||||
|
that promise. The reader already has the behavior-tag mental model.
|
||||||
|
- **The valueless tag is the one novel thing.** Spend a sentence on why `["-"]`
|
||||||
|
has no value and why that collapses the whole API to a boolean — there is no
|
||||||
|
malformed-vs-absent distinction to make, no parse to fail.
|
||||||
|
- **Be honest about enforcement.** This is the "hint, not guarantee" point from
|
||||||
|
expiration, sharpened: the tag asks relays to require NIP-42 auth from the
|
||||||
|
author. Without a cooperating relay, the tag does nothing. State the relay
|
||||||
|
rule precisely (auth challenge, then pubkey comparison) so the reader knows
|
||||||
|
what they're requesting, then say plainly that implementing it is ch 26's job.
|
||||||
|
- **Justify the free function.** It is `tags.has("-")` and nothing more. Defend
|
||||||
|
it the way ch 9 defended `Tag::expiration`: a named, discoverable NIP-70 entry
|
||||||
|
point reads better at call sites and at the doc level than a bare string
|
||||||
|
literal, and it keeps the protected-events surface symmetrical with the rest
|
||||||
|
of the library (a free function on `&Tags` plus method sugar on the events).
|
||||||
|
- **Keep it short.** This is the shortest behavior-tag chapter — no parsing, no
|
||||||
|
timestamp comparison, no clock. Resist padding. The interest is conceptual
|
||||||
|
(what the tag asks of the network), not mechanical.
|
||||||
|
- **Forward references, not detours.** Mention enforcement (ch 26) and the
|
||||||
|
repost interaction once each; do not explain them.
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
- **Boolean, not `Option`.** The tag has no value; presence is the entire
|
||||||
|
signal. `is_protected -> bool` is correct and matches every reference
|
||||||
|
(`isProtectedEvent`, `IsProtected`, `Event::is_protected`).
|
||||||
|
- **No `get_protected`.** Nothing to return but a bool, which `is_protected`
|
||||||
|
already gives. A `get_*` would imply a value that does not exist.
|
||||||
|
- **Free function on `&Tags` plus method facades.** Matches the
|
||||||
|
`get_expiration`/`get_pow` pattern exactly. The free function is reusable by
|
||||||
|
any caller holding a tag slice (e.g., a relay inspecting raw tags before
|
||||||
|
reconstructing an `Event`); the methods are the ergonomic surface.
|
||||||
|
- **Constructor in `tags.rs`, not `protected.rs`.** Lives with `Tag::new` and
|
||||||
|
`Tag::expiration`; discoverable where callers build tags; no import from
|
||||||
|
`protected` just to construct one. Identical placement reasoning to ch 9.
|
||||||
|
- **Enforcement deferred to ch 26.** The acceptance rule needs an authenticated
|
||||||
|
connection and a pubkey to compare against — state, not a stateless tag query.
|
||||||
|
Putting a `should_accept`-style helper here (the option the user declined)
|
||||||
|
would strand half the logic away from where the connection lives. The chapter
|
||||||
|
describes the rule and points forward.
|
||||||
|
- **No `set_protected(bool)` builder helper.** ndk/applesauce expose a
|
||||||
|
toggle, but our builder surface is already chainable via
|
||||||
|
`.tags(Tags::new().add(...))` / `Tag::protected()`. Adding a toggle would be
|
||||||
|
ceremony, consistent with ch 9 declining `set_expiration`.
|
||||||
|
- **Constructor uses an empty value iterator.** `Tag::new("-", iter::empty())`
|
||||||
|
produces `["-"]`. Verified against `Tag::new`'s signature (name plus an
|
||||||
|
`IntoIterator` of values); an empty iterator yields a one-element tag.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Whether to show the relay-enforcement pseudocode inline. Decision: no — a prose
|
||||||
|
statement of the rule is enough; code belongs in ch 26 where the connection and
|
||||||
|
auth state exist. Showing non-tangled relay code here would invite the reader
|
||||||
|
to think enforcement is part of this layer.
|
||||||
|
- Whether to add a `Tags`-level convenience beyond the free function. Skip, per
|
||||||
|
ch 9 reasoning: keep `Tags` a generic container; the named function lives in
|
||||||
|
`protected.rs`.
|
||||||
|
- Whether to document the NIP-18 repost interaction in depth. Keep it to a single
|
||||||
|
forward-reference sentence; reposts are their own topic.
|
||||||
@@ -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.
|
||||||
@@ -15,7 +15,9 @@ fn fixed_secret() -> SecretKey {
|
|||||||
fn make_signed_event(kind: u16, tags: Vec<Tag>) -> coracle_lib::events::Event {
|
fn make_signed_event(kind: u16, tags: Vec<Tag>) -> coracle_lib::events::Event {
|
||||||
let sk = fixed_secret();
|
let sk = fixed_secret();
|
||||||
let pk = sk.public_key();
|
let pk = sk.public_key();
|
||||||
let hashed = EventContent::new("test", tags)
|
let hashed = EventContent::new()
|
||||||
|
.content("test")
|
||||||
|
.tags(tags)
|
||||||
.kind(kind)
|
.kind(kind)
|
||||||
.stamp(1_700_000_000)
|
.stamp(1_700_000_000)
|
||||||
.own(pk)
|
.own(pk)
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ fn fixed_secret() -> SecretKey {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_hashed(content: &str, tags: Vec<Tag>) -> HashedEvent {
|
fn build_hashed(content: &str, tags: Vec<Tag>) -> HashedEvent {
|
||||||
EventContent::new(content, tags)
|
EventContent::new()
|
||||||
|
.content(content)
|
||||||
|
.tags(tags)
|
||||||
.kind(1)
|
.kind(1)
|
||||||
.stamp(1_700_000_000)
|
.stamp(1_700_000_000)
|
||||||
.own(fixed_secret().public_key())
|
.own(fixed_secret().public_key())
|
||||||
@@ -29,7 +31,7 @@ fn sign_fixture() -> Event {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pipeline_progresses_through_types() {
|
fn pipeline_progresses_through_types() {
|
||||||
let content = EventContent::new("hi", Tags::new());
|
let content = EventContent::new().content("hi").tags(Tags::new());
|
||||||
let template: EventTemplate = content.kind(1);
|
let template: EventTemplate = content.kind(1);
|
||||||
let stamped: StampedEvent = template.stamp(1_700_000_000);
|
let stamped: StampedEvent = template.stamp(1_700_000_000);
|
||||||
let owned: OwnedEvent = stamped.own(fixed_secret().public_key());
|
let owned: OwnedEvent = stamped.own(fixed_secret().public_key());
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ fn fixed_secret() -> SecretKey {
|
|||||||
|
|
||||||
fn make_event(kind: u16, timestamp: u64, tags: Vec<Tag>) -> Event {
|
fn make_event(kind: u16, timestamp: u64, tags: Vec<Tag>) -> Event {
|
||||||
let sk = fixed_secret();
|
let sk = fixed_secret();
|
||||||
let hashed = EventContent::new("hello", tags)
|
let hashed = EventContent::new()
|
||||||
|
.content("hello")
|
||||||
|
.tags(tags)
|
||||||
.kind(kind)
|
.kind(kind)
|
||||||
.stamp(timestamp)
|
.stamp(timestamp)
|
||||||
.own(sk.public_key())
|
.own(sk.public_key())
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ fn fixed_secret() -> SecretKey {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn test_event() -> OwnedEvent {
|
fn test_event() -> OwnedEvent {
|
||||||
EventContent::new("hello nostr", Tags::new())
|
EventContent::new()
|
||||||
|
.content("hello nostr")
|
||||||
|
.tags(Tags::new())
|
||||||
.kind(1)
|
.kind(1)
|
||||||
.stamp(1_700_000_000)
|
.stamp(1_700_000_000)
|
||||||
.own(fixed_secret().public_key())
|
.own(fixed_secret().public_key())
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
use coracle_lib::events::{EventContent, HashedEvent};
|
||||||
|
use coracle_lib::keys::SecretKey;
|
||||||
|
use coracle_lib::protected::is_protected;
|
||||||
|
use coracle_lib::tags::{Tag, Tags};
|
||||||
|
|
||||||
|
fn fixed_secret() -> SecretKey {
|
||||||
|
let bytes: [u8; 32] = [
|
||||||
|
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25,
|
||||||
|
26, 27, 28, 29, 30, 31, 32,
|
||||||
|
];
|
||||||
|
SecretKey::from_hex(&hex::encode(bytes)).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_hashed(tags: Tags) -> HashedEvent {
|
||||||
|
EventContent::new()
|
||||||
|
.content("hi")
|
||||||
|
.tags(tags)
|
||||||
|
.kind(1)
|
||||||
|
.stamp(1_700_000_000)
|
||||||
|
.own(fixed_secret().public_key())
|
||||||
|
.hash()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tag::protected constructor ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tag_constructor_shape() {
|
||||||
|
let t = Tag::protected();
|
||||||
|
assert_eq!(t.name(), "-");
|
||||||
|
assert_eq!(t.len(), 1);
|
||||||
|
assert!(t.values().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tag_constructor_serializes_as_single_element() {
|
||||||
|
let t = Tag::protected();
|
||||||
|
assert_eq!(serde_json::to_string(&t).unwrap(), r#"["-"]"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- is_protected ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_protected_false_when_tag_absent() {
|
||||||
|
let tags = Tags::new();
|
||||||
|
assert!(!is_protected(&tags));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_protected_false_with_unrelated_tags() {
|
||||||
|
let tags = Tags::from(vec![Tag::new("t", ["nostr"]), Tag::expiration(100)]);
|
||||||
|
assert!(!is_protected(&tags));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_protected_true_when_tag_present() {
|
||||||
|
let tags = Tags::from(vec![Tag::protected()]);
|
||||||
|
assert!(is_protected(&tags));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_protected_true_among_other_tags() {
|
||||||
|
let tags = Tags::from(vec![
|
||||||
|
Tag::new("t", ["nostr"]),
|
||||||
|
Tag::protected(),
|
||||||
|
Tag::expiration(100),
|
||||||
|
]);
|
||||||
|
assert!(is_protected(&tags));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_protected_detects_a_raw_dash_tag() {
|
||||||
|
// The marker is identified by name alone; a hand-built ["-"] is
|
||||||
|
// equivalent to Tag::protected().
|
||||||
|
let tags = Tags::from(vec![Tag::new("-", Vec::<String>::new())]);
|
||||||
|
assert!(is_protected(&tags));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HashedEvent method facade ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hashed_event_reports_protected() {
|
||||||
|
let event = build_hashed(Tags::from(vec![Tag::protected()]));
|
||||||
|
assert!(event.is_protected());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hashed_event_reports_unprotected() {
|
||||||
|
let event = build_hashed(Tags::new());
|
||||||
|
assert!(!event.is_protected());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Event method facade ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signed_event_reports_protected() {
|
||||||
|
let sk = fixed_secret();
|
||||||
|
let hashed = build_hashed(Tags::from(vec![Tag::protected()]));
|
||||||
|
let sig = sk.sign(&hashed.id);
|
||||||
|
let signed = hashed.sign(sig);
|
||||||
|
|
||||||
|
assert!(signed.is_protected());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protected_tag_survives_hashing_and_signing() {
|
||||||
|
// The tag is part of the canonical hash input, so it must round-trip
|
||||||
|
// through hash() and sign() intact and still verify.
|
||||||
|
let sk = fixed_secret();
|
||||||
|
let hashed = build_hashed(Tags::from(vec![Tag::protected()]));
|
||||||
|
let sig = sk.sign(&hashed.id);
|
||||||
|
let signed = hashed.sign(sig);
|
||||||
|
|
||||||
|
assert!(signed.verify_id());
|
||||||
|
assert!(signed.is_protected());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unprotected_event_stays_unprotected_through_signing() {
|
||||||
|
let sk = fixed_secret();
|
||||||
|
let hashed = build_hashed(Tags::new());
|
||||||
|
let sig = sk.sign(&hashed.id);
|
||||||
|
let signed = hashed.sign(sig);
|
||||||
|
|
||||||
|
assert!(!signed.is_protected());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user