✦ Nostrautica docs

NIP-XX: Encrypted Event Networking (Nostrautica Protocol)

draft optional

This NIP defines a protocol for privacy-preserving event networking on Nostr: encrypted attendee registration, an encrypted per-event directory, AI-assisted matchmaking through an organizer-chosen coordinator service, moderated pre-recorded talks, members-only event content, and MLS (Marmot) group chat with multi-device support.


1. Terms and actors

All hex values are lowercase. hex32 means /^[0-9a-f]{64}$/.

1.1 Community (kind 31612)

A community is a standing group: people who already share a chat somewhere and could network if they knew who to talk to. It has no start, no end and no venue.

Why not 31923 with a placeholder start. Kind 31923 is a NIP-52 time-based calendar event whose start is mandatory. A community publishing one would have to invent a value — its creation moment is the only defensible choice — and every NIP-52 calendar client on the network would then show it as a zero-length event on the afternoon it was made, and index it in date filters. That is publishing noise into other people's software to satisfy a field that carries no meaning here. A community is simply not a calendar event, so it gets its own kind and calendar clients never see it.

Tags. 31923's minus time and place:

tag
d the space identifier, identical to the 31600 config's d
title required
summary optional
image optional
t optional hashtags

No start, no end, no location, and no NIP-52 D day-index tags. A community that carried a day index would reappear in exactly the date filters this kind exists to stay out of.

Everything else is unchanged. The 31600 config, the ECK, the roster, the directory, the match lists and group chat are addressed by coordinate and behave identically. There is no mode tag on the config: the kind in the coordinate is the single source of truth, and a second one would only be somewhere for the two to disagree.

Coordinate handling. Anywhere an event identity is required — a key grant, a coordinator grant, a membership coordinate — the kind must be one of 31923 or 31612 and nothing else. That guard is an allowlist (audit R18) and widening it to two values does not weaken it: every downstream record is keyed by the whole coordinate string, so 31612:<E_id>:<d> authorises nothing for 31923:<E_id>:<d> and the two namespaces stay separate even for the same author and identifier.

Forward and backward compatibility. No existing kind changed and no existing tag changed, so every record published before this section stays valid and byte-identical. A client that does not know 31612 does not see communities at all, which is the correct degradation: it cannot misread one as an event, because it never resolves the coordinate. No v bump; the 31600 config inside a community still carries v=2.

Approval. A community defaults to approval: "open" (§6.3): anyone holding the invite link is a member. An older client or coordinator meeting "open" falls back to "manual" per the tolerant-parse rule, which is stricter than intended rather than looser.

2. Wire version

PROTOCOL_VERSION = 2.

Custom kinds occupy the addressable block 31600–31611 and the gift-wrap rumor block 21600–21610.

3. Ordering, replaceability, and revisions

These rules give every reader (app and coordinator, fetch and stream paths alike) one deterministic answer for which replaceable event is current, and give every mutable submission an explicit, replay-safe order. They are global and normative.

3.1 Latest-event rule (all replaceable/addressable kinds)

For any two events with the same (kind, author, d): the event with the higher created_at wins; on a tie, the event with the lexicographically lowest id wins (the NIP-01 convention). Every reader (app and coordinator, fetch and stream paths) MUST apply exactly this rule, so two conforming implementations never disagree about which replaceable event is current.

3.2 Monotonic publishing

A publisher of a replaceable event MUST set created_at to max(now, previous_created_at_for_that_address + 1). This makes §3.1 deterministic in practice and removes same-second collisions between successive business updates.

3.3 Revisioned mutable submissions

Sender-mutable records carry an explicit application revision; sender-chosen timestamps are never the primary ordering key.

Clients maintain rev monotonically per (coordinate, kind) in their own storage and MUST bump it on every edit.

3.4 Admin command ordering and replay horizon

21604 admin commands carry two required fields:

On backfill/restore, an expired command is skipped: an old revoke or recompute can never re-execute after a database loss. Approve/revoke interleavings resolve deterministically per subject instead of by relay arrival order.

Coordinators MUST retain their rumor dedupe ledger (wrap id + rumor id) indefinitely rather than pruning it by age. A coordinator re-scans its inbox from since: 0 on install and on restore, so any dedupe entry aged out under a fixed time-to-live makes the corresponding rumor look unseen on the next rescan and lets it replay, and only 21604 admin commands carry an expires bound; join requests, submissions, and grants do not. Bounding the ledger safely again requires a durable, protocol-level generation or expiry model that covers every rumor kind, not admin commands alone. Only kind-scoped receipts with their own natural single-use expiry (such as consumed Marmot key packages) may be pruned by age.

3.5 Coordinator installation generation and detach

The 31600 coordinator tag is three-element: ["coordinator", <pubkey>, <gen>] where gen is a positive integer chosen by the organizer, strictly increasing across every attach/detach/re-attach of the event (persisted in the organizer's 30078 event-keys backup as coordinator_gen). The 21603 install grant carries the same gen. A malformed coordinator tag (a missing element, a non-hex pubkey, or a non-positive-integer gen) MUST be treated as no coordinator at all.

Coordinator rules:

3.6 Grant authority

21602 key grants are accepted only when the seal author is E_id or the coordinator named (with current generation) in the newest fetchable 31600. A grant whose seal author is a formerly assigned coordinator MUST be rejected once a newer config no longer names it. Missing config is always "retry later," never "accept."

3.7 Detach hygiene, record authority, and handover

Deleting custody on detach (§3.5) is the honest coordinator's obligation; a malicious coordinator may retain copies of E_inbox and the ECK it held before detach. Detach therefore has protocol consequences beyond the coordinator's own state:

4. Encryption model

Every layer reuses an audited primitive (NIP-44 v2 for confidentiality, AES-256-GCM for media) with no bespoke cryptography.

Layer Construction Used for
Inbound NIP-44 v2, sender → E_inbox pubkey, inside NIP-59 gift wrap 21600, 21601, 21608, 21609, 21610
Outbound member content NIP-44 v2 with the raw 32-byte ECK as conversation key (no ECDH) 31603, 31604, 31606, 31607, 31608 private, 31610
Per-recipient NIP-44 v2 ECDH, coordinator → recipient 31605, all grants/status rumors
Self-store NIP-44 v2 self-conversation-key (or signer nip44 to own pubkey) 31602, 30078
Media AES-256-GCM, fresh key + 12-byte IV per blob, whole-file Blossom blobs

5. Gift wraps

Per NIP-59: rumor (unsigned) → kind-13 seal (signed by the true author, NIP-44 to the recipient, empty tags) → kind-1059 wrap (one-time key, single p tag, created_at randomized up to 2 days into the past).

6. Kinds

6.1 Registry

Kind Name Signer Visibility d
31600 Event Networking Config E_id public event d
31601 Invite List E_id public (hash-hidden) event d
31602 Self profile / reuse library account NIP-44 self blinded
31603 Directory Entry coordinator (or E_id) ECK blinded
31604 Roster coordinator (or E_id) ECK event d
31605 Match List coordinator NIP-44 → recipient blinded
31606 Match Matrix coordinator ECK event d
31607 Members-only Post E_id ECK random stable
31608 Event Page E_id public + ECK private event d
31609 Event Theme E_id public event d
31610 Talk coordinator (or E_id) ECK blinded
31611 Coordinator Announcement coordinator public nostrautica:coordinator
31612 Community E_id public space d
Rumor Name Seal author → recipient
21600 Join Request account → E_inbox
21601 Profile Submission account → E_inbox
21602 Key Grant E_id or the currently assigned coordinator → attendee
21603 Coordinator Grant (install) E_id → coordinator
21604 Admin Command E_id → coordinator
21605 Organizer Grant E_id → co-organizer
21606 Coordinator Status coordinator → organizer, and optionally the affected attendee
21607 Chat Device Attestation account → coordinator
21608 Profile Correction account → E_inbox
21609 Talk Submission account → E_inbox
21610 Attendee Withdrawal account → E_inbox

Standard kinds used: 0, 1, 3, 5, 6, 13, 14, 1059, 10000, 10002, 10050, 10063, 24242, 30023, 30078, 31923/31924/31925, and Marmot's 30443/443/444/445.

31610 and 31612 extend past the originally-reserved 31600–31609 addressable block, and 21610 past the originally-reserved 21600–21609 rumor block; both remain inside their respective standard Nostr ranges (parameterized-replaceable 30000–39999, ephemeral 20000–29999).

6.2 Addressable kind specifications

Every field below is exactly what packages/protocol/src/schemas.ts and config.ts accept. ? marks an optional field; "(default …)" marks a field the schema fills in when absent; everything else is required. .strict() schemas reject unknown fields; the rest silently drop them.

31600: Event Networking Config

31601: Invite List

31602: Self Event Profile / Reuse Library

31603: Directory Entry

31604: Roster

6.2.1 Roster pagination

A roster that fits in one NIP-44 payload is published exactly as it always was: one 31604 at the event's d, v: 2, no pages key, byte-identical to what shipped before this section existed. Nothing below applies to it, and that covers every event and community under a few hundred members.

A roster that does not fit is split across several 31604s under the same author:

page d carries
0 <event-d> pages, eck_current, nostr_group_id, the first entries
N <event-d>:N eck_current, the next entries

eck_current and nostr_group_id are read from page 0 and are authoritative for the whole roster; a continuation page repeats eck_current only because the payload schema requires it. Entries are packed front to back, so appending a member changes the last page and nothing before it: a writer SHOULD republish only the pages whose contents actually changed, or one approval costs one relay publish per page for the length of the event. A reader fetches page 0, learns pages, and fetches 1..N-1 in a single REQ (#d: ["<d>:1", "<d>:2", …]) — two round trips, whatever the size.

Because <event-d>:N could also be some other space's own d under the same coordinator, a reader MUST check a continuation page's ["a", <coordinate>] tag as well as its author. The content is ECK-sealed per event, so a collision can only withhold a page, never forge one — but withholding it must not pass unnoticed. A reader that cannot read every page MUST NOT present the pages it did read as the roster; a partial membership list is indistinguishable from an event that shrank.

Why pages under distinct ds, and not the alternatives. A manifest kind naming the pages reads more cleanly, but the round-trip count is identical — page 0 already is the manifest — and it costs a kind allocation for nothing. Dropping chat_keys back out of the roster roughly doubles what one payload holds, but it takes the device list away from the only surface that renders it and buys a single doubling; that is a deferral, not a fix. And no compression or entry shrinking was available: pubkey is 64 hex chars, d is a 32-char blinded value, both already minimal.

What an old client does. This is the part that decided the design. Zod objects strip unknown keys, so simply adding pages would be silently ignored by every build already installed: it would read page 0, see 480 of 600 members, and render that as the complete event. So page 0 of a paginated roster declares v: 3, and an old client's strict z.literal(2) parse rejects it through the path §2 already defines — the roster is authored by a trusted authority, so the client surfaces "update required" instead of dropping it silently. It fails loudly, and only on the rosters it genuinely cannot represent.

This is the one payload field allowed to exceed PROTOCOL_VERSION, and the exception is deliberate. Bumping PROTOCOL_VERSION to 3 would achieve the same refusal and would also invalidate every 31600 config, join request, directory entry, match list and 21xxx rumor on the network — a full wire-v3 flag day across every kind, to solve a problem in exactly one of them. The bump is therefore scoped to the payload that has the problem. 31604 is the only kind whose v may be 2 or 3; every other payload keeps the strict literal-2 parse of §2.

31605: Match List

31606: Match Matrix

31607: Members-only Event Post

31608: Event Page (menu + layout)

31609: Event Theme

31610: Talk

31611: Coordinator Announcement

6.3 Rumor kind specifications

Every rumor below travels only inside a NIP-59 gift wrap (§5); none is ever a signed, relay-visible event on its own.

21600: Join Request

21601: Profile Submission

21602: Key Grant

21603: Coordinator Grant (install)

21604: Admin Command

21605: Organizer Grant

21606: Coordinator Status

21607: Chat Device Attestation

21608: Profile Correction

21609: Talk Submission

21610: Attendee Withdrawal

7. Invite codes

An invite code is a throwaway nsec, transported only in the URL fragment (#/e/:naddr/join?code=<nsec>).

8. Media descriptors

.strict() schema; unknown fields are rejected (a descriptor drives coordinator fetch and transcoding, so an unexpected field is a hard error, not a silently-ignored one).

{
  "kind": "intro" | "talk",
  "url": ["https://…", …],                 // https-only, ≥ 1 entry
  "x": "<sha256 hex of ciphertext>",
  "ox": "<sha256 hex of plaintext>",
  "size": "<int ≥ 1, ciphertext bytes>",
  "m": "<mime type>",
  "duration": "<seconds, number ≥ 0>",      // REQUIRED when m starts with audio/ or video/
  "encryption-algorithm": "aes-gcm",
  "decryption-key": "<base64, decodes to exactly 32 bytes>",
  "decryption-nonce": "<base64, decodes to exactly 12 bytes>"
}

9. Coordinator lifecycle, billing, and announcements

10. Group chat (Marmot/MLS) and multi-device

10.1 Model

10.2 Chat Device Attestation (21607)

Sealed by the account key to the coordinator (schema in §6.3). proof is a BIP-340 signature by the chat device key, required on op:"add", over:

sha256( utf8( JSON.stringify(
  ["nostrautica-chat-device-v2", <coordinate>, <account-pubkey>, <chat_pubkey>, <created_at>] ) ) )

The coordinator MUST verify the proof before binding a device to an account, an account cannot attest a key it does not control. Only attested device keys are chat members: the coordinator authorizes exactly the active attested device keys and nothing else: the attendee/organizer account pubkey is never an implicit chat identity. A local-key account is no exception; it mints and attests its own per-device chat key like any other account type (§10.1), and that attested device (not the raw account key) is what participates. (The one way an account key can become a chat member is an explicit §10.5 link of that key, which makes it an attested, roster-visible, cap-counted, revocable device like any other.) op:"revoke" needs no proof (the account is evicting a key it already named; possession is irrelevant to that decision). Bindings are per (coordinate, account); a chat pubkey MUST NOT be bindable to two different accounts; rebinding it to the same account (e.g. re-add after revoke) mints a fresh binding. On account revocation from the event, all of that account's device leaves are removed from the MLS group.

10.3 Device profiles (kind 0 on chat relays)

So that other Marmot clients (e.g. White Noise) display a human name for each device member, every chat device publishes a kind 0 profile signed by the chat device key, to the event's chat relay set only (the Marmot relays. Never the account's general relays):

{ "name": "<account display name>", "about": "<note pointing at the account's npub>", "picture": "<optional>" }

10.4 Event→group routing

10.5 Linking an external Marmot client

An approved attendee MAY link the identity of an external Marmot client (e.g. a White Noise npub, W) to their account for one event, so that the coordinator adds W to the event's group and the attendee can also chat from that client. W may equal the account key or be unrelated to it. The external client cannot produce a §10.2 proof (it never sees the challenge), so possession is proven differently, and the coordinator, never the attendee, adds W: it holds the account⇄key binding and removes the key again on revoke or withdrawal.

  1. Request. The account seals a 21607 {op:"link", chat_pubkey: W, label} (no proof, no code). The coordinator MUST reject it unless the sealer is an approved attendee of an event with an active group, and MUST apply the same rules as op:"add": W bound to another account is refused (first binder wins), and W counts toward MAX_CHAT_KEYS_PER_ACCOUNT. A W already actively bound to this account needs no new proof; the coordinator just re-syncs it.
  2. Self-link (W = the sealing account key): the seal proves possession. The coordinator binds W immediately and adds it.
  3. Any other W: confirmation code. The coordinator:
    • keeps at most one pending link per (coordinate, account); a newer request supersedes the older one, whose code stops working;
    • SHOULD rate-limit requests per (coordinate, account) (reference: 5 per hour), since each one makes it invite a key the requester chose;
    • discovers W's kind-30443 key package (event relays first, then W's own kind-10002 relays) and resolves W's kind-10050 inbox relays the same broad way (default relays plus the chat interop relays, then W's kind-10002 relays);
    • creates a fresh two-member Marmot group (coordinator as sole admin, plus W) named "Nostrautica: confirm White Noise link", gift-wraps the Welcome to W's inbox relays, and posts one kind-9 application message carrying a one-time code and an instruction to type it into Nostrautica and not to share it;
    • generates the code with a CSPRNG: CHAT_LINK_CODE_LENGTH = 8 characters from CHAT_LINK_CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ" (no 0/O, 1/I/L), displayed as XXXX-XXXX; stores only a hash of it, bound to (coordinate, account, W); expires it after 30 minutes; and kills it after 5 wrong attempts.
  4. Confirm. The attendee accepts the invite in the external client, reads the code, and types it into Nostrautica, which seals {op:"link_confirm", chat_pubkey: W, code}. Both sides compare codes after normalizeChatLinkCode (uppercase; spaces, dots and dashes removed). On a match the coordinator binds W to the account as an active chat key flagged external, adds it to the event group through the normal member-sync path, and then removes W from the confirmation group and discards that group (best effort). The coordinator SHOULD NOT invite W to the event group with the key package the confirmation group consumed while a rotated one may still appear (reference grace: 90 s); after that it MAY use it, which is correct for a last-resort key package.
  5. Afterwards W is an ordinary attested device: it is listed in the roster's chat_keys with external: true (so Nostrautica clients can label it), counts toward the cap, and is removed with the existing op:"revoke" (a real MLS Remove). A linked external key is never promoted to MLS co-admin, even for an organizer. Like any new member it sees messages from its join epoch forward only.
  6. Feedback. Outcomes reach the account as 21606 notices with stage: "chat_link": state: "cleared" when the code has been posted or the key linked, and state: "poison" with one of these error_category values on refusal: chat_link_unavailable, chat_link_rate_limited, chat_link_no_key_package, chat_link_failed, chat_link_no_pending, chat_link_expired, chat_link_code_wrong, chat_link_too_many_attempts, chat_device_cap_reached, chat_key_bound_to_other_account, chat_key_package_ineligible. Clients treat an unknown category as a generic failure.

The code proves that whoever typed it could read a group only W's holder can decrypt. It does not stop W's holder from deliberately handing the code to someone else, which is equivalent to them consenting to the link.

11. Talks

Normative caps: at most 10 distinct talk_d per speaker per event; a talk carries exactly one of a media descriptor (whose kind must be "talk") or an external_url (+ external_kind:"youtube"|"video"); a published 31610 is republished under a new ECK on rotation, with the old-address copy NIP-09-deleted. External-URL talks (a YouTube link or a direct .mp4 hosted off-Blossom, for clips too large to upload) live inside the ECK-encrypted content (members-only), but are never fetched by the coordinator (the §8 media-fetch allowlist is Blossom-origin-only), so they are view-only: never transcribed, never fed into matching. process_for_matching (default false) gates whether a Blossom talk is transcribed and folded into matching at all; talks are not matched by default.

12. Deletions

The coordinator issues NIP-09 kind-5 with both ["a", "<kind>:<pubkey>:<d>"] and ["k","<kind>"] for: a revoked or withdrawn attendee's 31603, a 31606 on visibility downgrade, and obsolete 31610 addresses after rotation or rejection. Deletion is best-effort; the privacy model never depends on relays honoring it.

A retention sweep that deletes a 31604 MUST cover every page address the roster could have used (<event-d> and <event-d>:1…<event-d>:39), not only the pages it currently has. A roster that grew and later shrank leaves higher pages on relays that no reader asks for — pages on page 0 says they are not there — but they still hold member pubkeys, and nothing records how many pages an event ever had. Deleting an address that was never published is a no-op at the relay; missing one leaves member data behind.

13. Security considerations

14. Constants appendix

Wire-normative bounds (packages/protocol/src/schemas.ts, crypto.ts, giftwrap.ts, config.ts, event-page.ts unless noted):

Coordinator operational defaults (packages/coordinator/src/coordinator.ts and neighboring modules, tunable per deployment, not part of interop compatibility):

15. Space hierarchy: a community and its events

A community (31612, §1.1) can own dated events (31923): a meetup series and its meetups. Membership flows up only. Whoever is approved for one of a community's events may join the community without a second approval; nothing flows down, so a community member is not a member of any event they did not join, and leaving an event does not leave the community. Each space keeps everything it has today: its own E_id, E_inbox, ECK, roster, directory, matches and group chat.

A link exists between community P and event C iff, in the newest configs (§3.1):

Both sides are required. Each is signed by its own E_id, so the community's organizer decides which events confer membership (an unrelated event naming someone else's community as its parent gains nothing), and the event's organizer decides which community its attendees are offered. A parent MUST be a 31612 coordinate and a child a 31923 coordinate; readers drop any other value fail-soft. Removing either tag ends the link for every future join; memberships already granted are not revoked.

The two spaces need not share a coordinator, nor have one at all. Organizer clients SHOULD default a child event to its community's coordinator and relays.

15.2 Membership proof

A signature by an authority of the child, which is C's E_id or the coordinator named in C's newest 31600, over:

challenge = sha256( utf8( JSON.stringify(
    ["nostrautica-member-of-v1", <P coordinate>, <C coordinate>, <attendee-pubkey-hex>] ) ) )

The domain tag and the JSON array follow §7's construction. The proof binds the attendee, the child and the parent, so it cannot be replayed for another person, event or community. Authority is evaluated at verification time against C's newest config: a proof signed by a coordinator C has since replaced stops verifying.

15.3 Carrying the proof

15.4 Verification and approval

Whoever approves joins to P (its coordinator, or an organizer client in manual mode) accepts a member_of tag iff:

  1. C is linked to P (§15.1), checked against both newest configs;
  2. by is an authority of C (§15.2); and
  3. the signature verifies.

A coordinator that accepts it auto-approves the join exactly as for a valid invite (§7): same roster ceiling, same grant path. Without a coordinator, an organizer client SHOULD show the verified membership next to the request and MAY approve it in one action. Direct joins to the community (invite or manual approval) are unaffected.

15.5 Clients

15.6 Compatibility

Additive only, no v bump. Readers that predate this section ignore the parent and child tags and drop the grant's parent field. A coordinator that predates it treats a member_of join as having no invite and queues it for manual approval, which is stricter than intended rather than looser.