Status: project draft, not an accepted NIP or a claim of registered kind
ownership. Published by forgesworn/kithmoot. The reference and independent
Kotlin implementation consume the same interop vectors.
This freezes existing KithMoot event numbers and adds compatible signalling
metadata. Existing readers remain supported indefinitely.
This document and its normative companions constitute the draft:
- Persistent groups: v3 admission, retirement, owner recovery and the distinction between admission and epoch authority.
- Messages, reactions: channel derivation, edits, retractions, threads, mentions, DM invitations and read positions.
- Agents: ownership, sender consent, approvals, room authority, channels, file announcements and the Wildbloom envelope contract.
- Shared context and shared assignments: current M1 review/execution shapes carried through the existing channels.
- Service admission: reserved pass/policy shapes. No consumer enforces them in M2.
- Vectors: exact input/output bytes and negative cases. Secrets in that file are synthetic fixtures, never credentials.
An implementation MUST read these contracts together. Reject an invalid signed statement; do not repair its security-relevant fields. Preserve unknown optional fields only where a companion expressly defines extensibility. A UI projects untrusted input onto named fields. Nothing in this draft authorises executing text received from a relay, a file or another member.
A participant is a person or an agent; a device is an endpoint acting for that participant in one room. Device credentials bind a device to a participant, room and expiry. Participant secrets never go to relays, forwarders or stores. Device keys MUST be scoped to rooms. A member can have several devices without becoming several people. Agents carry a principal's verified ownership attestation; their ability to read or act remains constrained by sender consent and explicit approvals. Possession of a room key does not grant room-authority privileges.
Relays see event kinds, event authors, recipient/room selectors, sizes and timing. Current roster events reveal per-room device public keys and opaque room selectors; signalling wraps reveal their recipient device keys. Room rekeys reveal the room selector, authority and epoch metadata. Encryption hides payloads, not these observations, and does not prevent timing correlation. No claim of anonymous network transport or unlinkability against all colluding observers is made.
Services are optional and replaceable. A forwarder carries encrypted media and never receives a room traffic key. TURN provides connectivity; a Blossom store holds encrypted files. A Bothy node is the preferred optional archive host in M3 and optional nudger host in M5. Neither is required to create or use a room.
Use NIP-01 canonical event IDs and BIP-340 signatures, NIP-44 v2 encryption and HKDF-SHA256. Event timestamps and expiries are integral Unix seconds. JSON is UTF-8; compact encoding is used inside encrypted payloads. Hex writers emit lower case. Existing key readers compare hex case-insensitively where the vectors specify it. New service scopes require canonical lower-case identifiers.
A room starts with a random 32-byte secret. Derive two independent 32-byte values using HKDF-SHA256 with that secret as IKM, an empty salt and UTF-8 info:
| Info | Output |
|---|---|
kithmoot/v1/room-id |
Lower-case hex room ID |
kithmoot/v1/room-key |
Room encryption key |
Never substitute the room secret directly for either derived value. Channel
keys/selectors, epoch keys and media keys have separate domains; their canonical
inputs and outputs are pinned by the companion contracts and vector groups
channelDerivation, roomEpoch, roomDescriptor and verificationWords.
Transport receives NIP-01 events through REQ subscriptions, verifies signed events and closes each subscription when its owner leaves. Publish to chosen writable relays and read chosen readable relays. A relay is not authoritative merely because it supplied an event. Duplicate relay delivery must not repeat a message, action or negotiation. Regular events may be replayed; ephemeral events must not be relied upon as storage.
The registration file prepares project entries; submission to the upstream registry is separate. Existing numbers do not change.
| Kind | Meaning | Visibility and persistence |
|---|---|---|
| 1460 | Chat and named-channel messages | Room/channel ciphertext; regular |
| 1461 | Invitation retirement | Creator-signed tombstone; regular |
| 1462 | Room rekey | Authority-signed, prior-epoch ciphertext; regular |
| 1463 | Group invitation | Creator-signed, bearer-derived ciphertext; regular |
| 1464 | Call bell | Throwaway-signed, daily room-derived tag, room/epoch ciphertext; regular with NIP-40 expiration |
| 20460 | Device credential | Participant-signed inner event; never published bare |
| 20461 | Roster | Device-signed room ciphertext; ephemeral |
| 20462 | KithMoot signal | Signed inner event or sealed rumor; never published bare |
| 20463 / 20464 | Device pairing request / grant | Room ciphertext; ephemeral |
| 20465 | Room descriptor | Room ciphertext; ephemeral |
| 20466 / 20467 | Legacy invitation request / grant | Capability/requester ciphertext; ephemeral |
| 20468 / 20469 | Epoch request / grant | Authority/device ciphertext; a request carries an admission proof under the epoch-0 room key; ephemeral |
| 20470 | Member pass | Reserved signed presentation; no automatic relay publication |
| 20471 / 20472 | Member epoch request / grant | A request is device-signed ciphertext under a key derived from the epoch-0 room key, with the same admission proof as 20468; a grant is signed by a one-time key, sealed to the asking device, and carries the missed secrets with the authority's own 1462 rekeys as proof; ephemeral. See fold-kit's docs/member-epoch-catch-up.md |
| 21059 | Ephemeral signal wrap | Shared upstream kind; recipient-addressed ciphertext |
| 1059 | Quiet room drop | Shared upstream kind; a kind 1460 inside, addressed to a room-derived rendezvous key (nostr-deaddrop room case); regular |
| 30460 | Service policy | Reserved addressable event; no publication in M2 |
| 30461–30469 | Service policy expansion | Reserved only; no semantics assigned |
| 30078 | Read positions | NIP-78, label kithmoot.read.v1; own-account ciphertext |
| 1063 / 24242 | File metadata / Blossom authorisation | Existing file standards, unchanged |
| 9734 / 9735 | Zap request / receipt | Existing payment standards, unchanged |
RelaySwarm's 24170 and 24171 are documented alongside these entries for collision avoidance. They remain RelaySwarm's protocol and are not KithMoot call events. Neither their payloads nor Link's rendezvous-tag format is imported by this draft.
A link carries its capability only after #, encoded as unpadded base64url of
UTF-8 JSON. The HTTP request never carries the fragment. Fields are r (relay
URLs), i (ICE URL hints), optional a (admission policy), n (display name),
and c (one-use device pairing code). A fragment is at most 16 KiB. At most eight
relay and eight ICE hints and 256 policy keys are accepted. Public relays require
wss; plain ws is retained for loopback development. Hints do not override
signature checks or room membership.
New room creation emits v3: {v:3,j:<32-byte bearer base64url>,h:<inviter pubkey>,r,i}.
The creator saves owner recovery and publishes kind 1463 before offering durable
admission. Its d selector is HKDF-SHA256(bearer, empty salt,
kithmoot/v2/invitation-id, 32), hex encoded. Its encryption key uses the separate
info kithmoot/v3/group-invitation-key. The decrypted body is
{v:3,room:<room ID>,secret:<base64url initial secret>}. The signature MUST match
the pinned inviter. A valid retirement wins over the invitation. This record
carries epoch zero only; it cannot override removal or mint authority.
Conference rooms. A group MAY end on a fixed date. Its body then also
carries ends, a positive integer of Unix seconds (v stays 3), and the 1463
event carries a NIP-40 tag ["expiration", "<ends>"] with the same value, so
relays drop the invitation when the room ends. A creator MUST choose ends
after its own clock and no more than 30 days beyond it. A reader MUST reject
the invitation when ends is present and not a positive integer, when there is
more than one expiration tag, or when an expiration tag is present and is
not exactly the decimal of the body's ends (including when the body has no
ends). A body ends with no tag is valid. When two valid copies disagree,
the earlier ends stands. At or after ends the room is ended: a reader MUST
NOT join it and SHOULD tell a newcomer the date it ended. A re-signed
invitation keeps the same ends, and none is re-signed after it. A 1461
retirement of such a room carries the same expiration tag.
Room relays. The body MAY also carry relays, after ends (key order
v, room, secret, ends, relays): the relays the room was made on, fixed
then and never changed. When present it MUST be an array of one to eight
distinct strings, each a safe relay URL (wss://, or ws:// on loopback
only) already in canonical form - nostr-tools' normalizeURL, so
wss://relay.example/ with its trailing slash - with no credentials. A
reader MUST reject the whole invitation otherwise, never trim it. Absent, the
body is byte-identical to one written before the field existed, and a reader
that predates it ignores the key. When two valid copies disagree, the newest
created_at that names any relays stands; a copy naming none says nothing
about them, and between equal timestamps the first heard stays. (Contrast
ends, where the earliest stands.)
Every member's pool is the room's relays - these, then the relays its
authority's newest relays control op added - first, reading and writing,
never cut; then the member's own relays for the room, up to sixteen in all.
Own relays are what the cap cuts. So any two members share at least one
relay, whatever else each of them uses. A client chooses the room's relays,
in order: a signed invitation's relays, replacing any learnt from a link;
else the ones it already holds; else a link's r, on first sight (a
temporary room, or one made before the field). The creator fixes them from
the relays it both reads and writes, at most eight. A link names the room's
relays, then the op's, cut to eight, rather than its sharer's whole pool. A
member holding no signed list reads the invitation once after opening and
adopts its relays; the creator's six-hourly re-sign carries them into an
invitation written before the field.
Every event a member device signs for a conference room carries
["expiration", "<ends>"]: chat and its channels, reactions, edits and
retractions, roster and farewell entries, signal wraps, descriptors, call
bells, assignment envelopes, file announcements, read positions, and the
authority's rekey (1462), epoch request (20468) and grant (20469), and the
member epoch request (20471) and grant (20472). An event
whose own expiration is earlier keeps it (a signal wrap's 60 seconds, a call
bell's 120); a later one is lowered to ends; an event never carries two.
Account-level events (project directory, bookmarks) are not tagged, and a
device credential keeps its own expiry. Readers ignore the tag on decode; it
tells relays when to let the room go, and is not an access rule.
A kind 1461 retirement's content is {v:1}. When the room itself was ended,
not just the link replaced, it is {v:1,ended:true}. The field is additive:
readers MUST treat any valid v:1 retirement as a retirement and MAY tell a
newcomer the room ended when ended is exactly true. A browser room with no
keeper is ended from the browser holding its authority by publishing that
retirement and then a closing kind 1462 rekey with no recipients, the same
pair a keeper's close publishes. A keeper's close does not set ended, since
the admin who asked it to close need not be the person who started the room.
Readers MUST continue accepting v2 (v:2,j,h) and v1 (s:<room secret>, with no
version) indefinitely. Resharing an existing room preserves its capability and
version. In particular, an old keeper state file is not silently converted to a
durable invitation. A creator's explicit conversion to a group issues a fresh
bearer while retaining the conversation and authority. New v3 membership does
not hand an inviter/delegation signing key to a joining member.
An admission policy is {tier,admitted?,agents?,members?,quiet?}. tier is open,
ken, kith or kin; agents, when present, is owned-by-members. members
is an explicit participant restriction. quiet, when present, is exactly true
and requires members: the room's kind 1460 events are never published bare,
and ride instead inside kind 1059 room drops as nostr-deaddrop defines them,
each addressed to a key derived from the current epoch key, the member's
participant pubkey and a counter, one per slot per device with fillers between.
Every reader derives every listed member's keys; a reader that does not know
quiet would talk in the open, so a quiet in any other shape, or without
members, rejects the link. Unsupported/malformed policy means reject the link,
never an open room. The accessEvaluation and kindredProof vectors define the
recognised proof and expiry cases.
Asking before letting people in. The admission request body is
{"v":1,"device":<hex>} and may also carry "name" (what the person
asking calls themselves, at most 64 characters, a claim) and
"participant" (their participant key, when they hold one). Both are
optional and additive: a responder that does not know them grants as it
always did, and a responder whose owner has asked to be consulted shows
them on a card before answering. There is no refusal on the wire; a
declined request is simply never answered, which is indistinguishable
from nobody being online, and the door says so.
A kind-20460 credential has empty content and tags d = room ID, device =
device public key, expiration = Unix seconds. It is signed by the participant.
It travels inside the encrypted roster, never as a public participant-to-device
mapping. Check signature, room, device and expiry before attributing any track,
message or action to that participant. Pairing grants only a room-scoped,
expiring credential; it must not copy the participant secret to the new device.
A credential may end with ["seal", <x-only pubkey>]: the device's seal key,
minted fresh at each renewal. The authority's rekey copy (1462 keys), its
grant (20469) and a member's grant (20472) are NIP-44-sealed to the seal key of
the newest credential the sender has seen for the device, not to the device
key; with no seal tag, to the device key as before. Senders keep that newest
credential per device and never move it back to an older one a roster entry
carries, and a desk seals to the newer of it and the credential a request
presents. Readers try every seal key they hold, then the device key, and keep
their seal secrets as they keep the device key. Because the participant, not
the device, signs the seal key, a copied device stops reading new epochs once
the credential it caught has lapsed and the room has rekeyed. See fold-kit's
docs/seal-key.md and vectors/seal-vectors.json.
The roster is kind 20461, signed by the device, tagged d with the current
room/epoch selector, and NIP-44-encrypted with the current room/epoch key. Its
inner credential must agree with the outer author. A device advertises at
most one track per role: a decode rule, not a new constraint, since a
device only ever runs one camera, mic, share and screen-audio track at a
time - an extra advert for a role already seen is dropped and the entry is
kept. updatedAt, tracks, claims,
name, verified ownership and optional assist advertisement are interpreted by
rosterEvent vectors. A farewell uses left:true. An optional call
object { id, since } says the device is on the call with that id (32
lower-case hex characters, chosen by whoever started it) since that Unix
second; a call is read off presence, has no kind of its own, and ends when
the last present device stops carrying it (valid-on-call vector). A
malformed call is dropped and the entry kept. An optional sid of 8
lower-case hex characters names the page session that published the entry,
minted fresh per page session and never reused; a malformed one is dropped
and the entry kept. A device key is per browser profile, not per tab, so a
reader holds entries under device|sid where one is given and under
device alone where it is absent: two tabs of one room are two entries
rather than a last-writer-wins overwrite, a farewell removes only the page
session that sent it, and a media connection is bound to the page session
it was negotiated with - a changed sid for a device is a different
endpoint, and whatever was open to the old one is closed and rebuilt rather
than reused. Absence means unknown and reads exactly as it did before the
field existed. Presence expires; replayed
presence is not a permanent guest list. New arrivals announce and existing
members answer because relays need not retain presence.
An optional callProfile names the call-signalling profile a device speaks;
only the exact number 2 counts, meaning fixed media slots, reliable
signalling with generations and pairwise health (see "Profile 2 additions"
below) - anything else, including absence, is profile 1. It is a roster
field rather than a bump to the signal wire's own profile tag, on purpose:
docs/protocol.md already requires that an unknown profile tag never
silently enable new semantics, so capability lives on the one thing each
device already publishes about itself instead. A malformed value is dropped
and the entry kept, exactly as a malformed sid or call is.
A track advert may carry an optional muted, meaning the device itself
turned the track's source off (track.enabled = false) rather than a peer
turning its own playback down; only the exact boolean true counts, so it
sits beside callProfile as one more field where absence, false, 1 or
any other value reads as not-muted and the entry is kept regardless. This is
the device's own mic state, distinct from and additive to a listener's local
volume choice, which is never on the wire at all.
An automated device declares agent:true. It may additionally advertise
requestReceipts:true while its driver sends signed request-received markers
(see agent receipts). Only boolean
true on an agent entry enables this capability; other values are ignored.
Absence of this field does not imply that an ordinary agent reply failed.
A descriptor (20465) contains forwarder references and ICE configuration under the room key. It does not replace the invitation admission policy. A forwarder reference names its signalling relay and public key; unrecognised properties do not create capabilities. The old descriptor and first-offer shapes stay valid without a ticket or policy event.
The room's name is shared room state with no kind of its own: any member
renames the room with a name op on the encrypted control channel,
{"op":"name","name":"…","id":"<32 hex>","at":<unix ms>}, plus
"carried":true on a copy a member posts again. The newest by at, then
id, then name wins; floor(at/1000) may not exceed the carrying message's
sentAt; a name over 32 code points is refused, and the rest is sanitised as
a display name. Members carry the current name into each new epoch and back
inside the retention window. See the room name and the
roomName vectors.
A call can be run as a meeting. The authority signs a meeting op on the
control channel, {"op":"meeting","on":<bool>,"speakers":[<pubkey>…],"version":<int>,"sig":"<128 hex>"},
over `sha256("kithmoot/v1/meeting:" + roomId + ":" + version + ":" + (on ? 1 : 0)
- ":" + JSON(speakers))
, the speakers lower-case, deduplicated, sorted and at most 64. The newest verified version wins and any member may repost it. Whileon, a client MUST NOT send microphone, camera or screen from a participant not on the list, and MUST NOT play or show what it receives from one; a device whose participant it cannot place is treated as not on the list. A recording is announced the same way,{"op":"recording","on":,"id":"<32 hex>","version":,"sig":"<128 hex>"}oversha256("kithmoot/v1/recording:" + roomId + ":" + version + ":" + id + ":" - (on ? 1 : 0))
; a client records only after posting one, reposts it while recording, and every client shows a notice for as long as the newest is on.{"op":"hand","up":}raises or lowers the sender's hand and is unsigned: the sender is the message's credential-bound participant. Seesrc/meeting.ts, "A meeting is moderated by the room's authority" in [decisions](decisions.md), and themeeting` vectors.
Rekeys and the encrypted control channel are authority-bound. A room advances
one verified epoch at a time; a removed device can read the removal notice but
cannot recover a next-epoch secret addressed only to remaining devices. The
roomEpoch vectors include wrong authority, missing recipients and epoch gaps.
An epoch request (20468) body is {v:1,credential,proof?,admission}. admission
is `HMAC-SHA256(k, "kithmoot/v1/epoch-request:" + roomId + ":" + authority + ":"
- device + ":" + created_at)
as lower-case hex, wherekis HKDF-SHA256(ikm = epoch-0 room key, no salt, infokithmoot/v1/epoch-request-key`,
- and the identifiers are lower-case hex. It proves the asking device was
admitted to the room, which the credential alone does not: the room id and the
authority's pubkey are public on every rekey, and a credential is minted by any
participant key. A desk MUST refuse a request whose
admissionis missing or does not verify, before consulting the policy and without publishing any grant. The proof key is always epoch 0's, since the device asking is by definition behind. A responder from before this field ignores it. TheepochRequestAdmissionvectors pin the derivation and the refusals. A client that has left an epoch goes on reading its chat and channels for a while: the history window, every epoch left within the last 30 days (HISTORY_WINDOW_SECONDS), newest first, at most 16 (MAX_HISTORY_EPOCHS), each decoded under its own root. Every client applies the one rule, fold-kit'sepochsInWindow, so what an authority hands over is what a member goes on reading. When the room left an epoch is thecreated_atof the authority's rekey out of it. A chat subscription asks for the current epoch and the four most recently left in a filter each, and folds any older ones into one filter with several#dvalues, so it carries at most six filters however many epochs it reads (thechatHistoryvectors). This is how a message published under an epoch just before a rekey, and delivered after it, is still heard, how a device that applies several rekeys in a row on returning reads the epochs it passed through, and how a room on a weekly schedule keeps its last month. A message under a left epoch from a participant removed at any epoch is refused, whatever itscreated_at: the removed keep the old key. Messages are never written under a left epoch.
Scheduled rekeys. A rekey that turns the key on the room's schedule,
rather than to remove somebody, carries "scheduled": true in its encrypted
body (between where closed and commit would be), so it is signed and a
relay cannot tell it from a removal. It never sits beside a non-empty
removed or closed, and a reader believes it only when removed is empty
and the room stays open, so a body that contradicts itself is still
announced. A client moves to the new epoch exactly as for any rekey and says
nothing about it: no "moved to epoch" line, no removal. A reader that does
not know the field ignores it and announces the rekey as before. See
fold-kit's docs/scheduled-rekey.md and vectors/schedule-vectors.json.
The window in an authority's grant. An authority's grant (20469) may carry
passed: the window's left epochs, oldest first, each
{epoch, secret (base64url), left (unix seconds)}, never epoch 0 and never
one at or above the epoch granted, at most 16. Every grant carries it, because
a request does not say which epoch the device holds, so a newcomer and a
device back after missing several rekeys are both handed the last month. A
reader takes passed only in exactly that form and otherwise drops it and
keeps the grant; a reader that does not know it reads the current epoch, as
before. A member's grant (20472) already carries the epochs it passed, each
proven by the next rekey, and its reader takes when each was left from that
rekey's created_at. A client reads every passed epoch as a left one, from
when the room left it.
Following a rekey from outside the room. A client's rooms list reads a
room it is not in under the epoch it last held, and follows the authority's
1462 from there with this device's own copy (its device key and the seal
keys its credentials named), as a member does. A device the rekey left out,
because it was removed, the room was closed, or it was not in the room when
the authority rekeyed, has no copy and stays where it is until the room is
opened. The web keeps the epoch's secret, the window's secrets with when each
was left, the removed and the members (kithmoot.room-epoch.v2.<room>) and
opens the room from them, so it neither replays every rekey from epoch 0 nor
stalls on one sealed to a seal key it has since dropped.
A rekey is compared by event id. Two different rekeys for one epoch, both
validly signed by the authority, mean its key is rekeying from two places;
a client reports this (EpochConflict) and does not choose between them by
arrival order. A disagreement over an epoch the client skipped through a
grant is not seen. A client that reaches an epoch through a grant rather than
the rekey chain has jumped epochs (EpochGap) and cannot read the ones
between, except those a member grant carried (passed), which it reads as
left epochs; from an epoch above 0 that is a returning device that was offline
at a rekey or whose relays let one go, and it says so. The web client, the
library and the Android client (kithmoot-android #126) do all of this. On
Android, a quiet room still opens its dead drops only under the current
epoch's key, so a late message on an epoch it has left is not read there.
Clients that cannot follow an epoch must say so rather than display a quiet, empty room. Android capability gaps are recorded in the compatibility ledger; passing M2 codecs does not implement features it did not previously support.
A call has no event of its own in the roster: it is read off presence. A phone with the app closed cannot afford presence, which is a heartbeat every 20 seconds from every device in every room. Kind 1464 gives it one event per call start and one per call end to wait for on an idle socket.
The bell is a regular kind, so a socket that has just reconnected catches it
with since, and every bell carries a NIP-40 expiration of created_at + 120. Its outer event is signed by a secret key minted for that one bell and
then discarded, never a device or participant key. It has exactly two tags:
d: the first 32 hex characters of HMAC-SHA256(K, UTF-8kithmoot-call-bell-v1|+ the UTC day ofcreated_atasyyyy-mm-dd), where K = HKDF-SHA256(current room/epoch key, empty salt,kithmoot/v1/call-bell-tag, 32).expiration:created_at + 120, decimal.
The content is NIP-44 v2 with the conversation key HKDF-SHA256(current
room/epoch key, empty salt, kithmoot/v1/call-bell-key, 32), of compact
JSON {"v":1,"state":"start"|"end","call":{"id","since"},"device","sig"}.
call has the roster's shape. device is the ringing device's public key.
sig is its BIP-340 signature over SHA-256 of UTF-8
kithmoot/v1/call-bell:<room id>:<state>:<call id>:<since>:<created_at>,
with the room's (epoch-0) id, so a bell replayed into another room, or
restamped to another time, fails. The body carries no names.
A device publishes a start bell when it goes on a call no other present
endpoint carries, and an end bell when it leaves a call (by stepping off
or by leaving the room) and no other present endpoint carries it. Nothing
else rings: no periodic bells, and no bell when a call moves between two tabs
of one device. Publishing never delays going on or off a call; a failure
costs only the ring.
A reader subscribes to {"kinds":[1464],"#d":[...]} with today's tag and,
within 120 seconds after or 60 seconds before UTC midnight, the neighbouring
day's. It rejects a bell whose tag is not the day tag for its created_at,
whose outer signature fails, that does not decrypt, whose v is not 1 or
state not start/end, whose call id is not 32 lower-case hex characters,
whose device signature fails, that is more than 120 seconds old or more than
60 seconds in the future, or whose since is more than 60 seconds after
created_at, more than 120 seconds before it on a start, or more than 30
days before it on an end. A valid bell says a device holding the room key
rang; whether that device belongs to the room is checked against the roster
or a credential the reader holds. The callBell vectors pin the derivation
and the refusals.
What a relay learns. That some opaque tag, which changes every UTC day, saw a call start or end at a time, from an unlinked key, and the connection (and so the IP address) that published it. A relay that also carries the room's roster can correlate that connection and moment with the roster heartbeats the same connection sends, and so guess which roster tag the bell belongs to; the bell hides nothing that the connection's own timing gives away. Relays that ignore NIP-40 keep the event, still opaque. Anybody who holds the room key can compute the tag and read the bell, exactly as they can read the roster.
What it cannot. Derive the roster's d from the bell's tag or the other
way round, link bells of one room across days without the key, or tie a bell
to any device or participant key: those are only inside the ciphertext.
Another room's bells share nothing with this one's on the wire.
The credited envelope model is NIP-AC, revision 31c7a41. NIP-59 and NIP-40 are pinned in upstream.json. KithMoot is envelope-compatible with that NIP-AC draft, not live-call interoperable: its inner kind is 20462, its recipients are room devices and its room-derived call ID is not NIP-AC's per-call UUID. The roster supplies the participant-to-device mapping privately.
Writers keep the existing seal-less envelope:
- Sign a kind-20462 inner event with the room device key. Its content is a
SignalBodyJSON object:type=offer,answer,ice,assistor optionalannotation;roomId; optionalsdp, JSON-stringcandidate,trackHints,tier,assist(far-end device),accept(assist response boolean) andannotation. An annotation is temporary screen-share markup:{op:"stroke",shareId,strokeId,points:[{x,y},...]}or{op:"clear",shareId,strokeId:""}.shareIdis the advertised screen track ID; coordinates are finite numbers from zero to one; a stroke has 2–128 points. Readers discard invalid annotation bodies. An old reader ignores the unknown signal type, so drawing cannot disturb its call. - Include inner tags
p= recipient device,call-id= room ID,alt=KithMoot call signalling, andkithmoot=1. - Generate a fresh ephemeral signing key for each wrap. NIP-44-encrypt the complete signed inner event to the recipient using that ephemeral key.
- Sign kind 21059 with the ephemeral key, tagging only
p= recipient andexpiration= inner creation time + 60 seconds. Sample creation time once for both emitted layers. Do not place the room ID or sender in public tags.
The 60-second expiry is a relay-retention hint, not the acceptance window. Local staleness stays at ±20 seconds around the innermost signed time. Both numbers include their specified boundary exactly; at expiry, a future service pass is expired. A relay can ignore expiry, so receivers must still check staleness.
Receivers accept both the emitted shape and NIP-59-style seals:
- Bound wrap content at 131,072 characters, at most 16 outer tags with at most eight strings of 2,048 characters each, and bound crypto attempts before identifying the sender. Verify the outer signature. Duplicate outer IDs may be discarded before crypto.
- Decrypt one layer. If its kind is 20462, verify the inner signature. If its kind is 13, verify the seal signature, decrypt its content with the seal author's shared key, and require a kind-20462 rumor with the same public key as the seal. Recompute the rumor's canonical ID. Any other kind fails.
- On either path, check the innermost time, room binding and recipient. If a
call-idtag exists it must be unique and equal the room ID. Absence is accepted for old writers.altand profile tags are descriptive; future unknown profile tags do not silently enable new wire semantics. - Deduplicate the innermost event ID across fresh rewraps and both envelope forms. Randomised seal/wrap timestamps are never substituted for inner time.
- Apply sender rate limits and roster/authority checks before negotiating or displaying annotation. Annotation is never chat history and is not replayed to a device that was absent.
The implementation budgets 4,096 unwrap attempts per room per 20 seconds and 120 accepted signals per sender per 20 seconds. Replay tables are bounded at 4,096 entries. These are local resource bounds, not proof against denial of service: a malicious relay can consume the budget or withhold legitimate events. Independent relays remain a resilience choice. A reconnect or new ephemeral sender key must not bypass the room budget. Accepting two receive forms does not change what old clients receive: writers still emit one seal-less form.
NIP-AC's offer/answer/candidate meanings correspond to 25050/25051/25052. Those numbers are reserved for a future explicit compatibility decision after upstream settles; M2 neither emits them nor pretends a 20462 body is their payload. Assist stays on 20462. A weekly independent workflow reports changes to the pinned upstream texts and relevant registry entries; it cannot change production code or block every ordinary PR because a draft moved.
A device that speaks fixed media slots, reliable signalling with generations
and pairwise health declares this on its roster entry (callProfile: 2), not
on the signal wire's profile tag: kithmoot stays '1' on every signal a
profile-2 device sends, because an unknown profile tag must not silently
enable new semantics. A device is profile 2 for a pair once either its roster
entry says so or a signature-valid signal carrying gen has arrived from it,
whichever the reader learns first - roster presence can lag behind a signal
that has already crossed. A profile-1-shaped signal (no gen) from a device
believed profile 2 downgrades that pair back to profile 1: this is what a far
end reloading into an old build looks like.
All of the following are additional optional SignalBody fields, ignored by
an old reader's JSON.parse exactly as any other unknown field always was:
| Field | Type | On | Meaning |
|---|---|---|---|
gen |
integer >= 1 | every profile-2 signal | Pair generation. Monotonic per pair, never reused. |
conn |
16 lower-case hex | every profile-2 signal | Sender's connection instance id, fresh per RTCPeerConnection. |
peerConn |
16 lower-case hex | when known | The connection the sender believes it is addressing. |
seq |
integer >= 1 | offer, answer, ice | Per conn, from 1, gapless. |
first |
integer >= 1 | batched ice | First seq covered by this batch. |
candidates |
string[] | ice | Batched ICE candidates. candidate stays on the wire for profile-1 peers. |
ack |
integer >= 0 | any | Highest contiguous seq received from peerConn, piggybacked wherever possible. |
re |
integer >= 1 | answer | Seq of the offer being answered. |
restart |
true |
offer | This offer carries an ICE restart inside the current generation. |
slots |
{ [mid]: TrackRole } |
generation-opening offer | Exactly four entries - mic, camera, screen, screen-audio - each exactly once, keyed by transceiver mid. |
rx |
{ [TrackRole]: 'ok' | 'dead' } |
health | What the sender is receiving from the recipient, per slot; at least one entry. |
Three new type values ride the same envelope: ack (a standalone
acknowledgement, when nothing else is due to be sent), health (a report
that carries only rx, for a single dead slot on an otherwise healthy
transport - RTCP cannot express that on its own), and sync (carries gen
only, meaning "my current generation is this"; sent when a signal from an
older generation arrives, rate limited to one per two seconds per pair). An
old reader rejects all three exactly as it rejects any other unrecognised
type today, which is what keeps them addressed only to peers already known
to speak profile 2.
Every field above is validated strictly on decode - integer ranges, exact hex
length, the slot map's fixed shape - and a present-but-malformed field rejects
the whole signal body rather than being dropped on its own; unlike a roster
display name or assist offer, there is no "the entry is still someone
genuinely in the room" to fall back to; a mis-shaped connection id or
generation number is not safe to act on partially. What is deliberately not
validated at this layer: whether a field belongs on the type it arrived
with, and any cross-signal bookkeeping such as gen ordering or (conn, seq) receiver dedup - those depend on protocol state the codec does not
hold and are Peer/Mesh concerns.
A retransmission of an unacknowledged offer, answer or batched ice is
re-wrapped with a fresh created_at each time it goes out, exactly as any
other call to the wrap function does - so the 60-second relay-retention hint
and the ±20-second staleness window never confuse a retransmission for a stale
replay, and the inner-event-id dedup above never mistakes it for a duplicate
of the original. Receiver dedup for profile-2 signals is keyed (conn, seq)
rather than by inner event id.
Wildbloom files retain FSWNENC2, FSWNENC1 and WBLMENC1 read support. Recovery keys travel inside encrypted chat/context, never in public kind-1063 metadata or Blossom requests. Verify the envelope hash before decrypting. Upload signing continues to use the room device key. M2 introduces no store allowlist, account requirement, participant-key signing or compulsory Bothy service.
V4V payment/zap behaviour and RelaySwarm discovery/signalling are unchanged. Their numbers, formats and trust boundaries are not repurposed for KithMoot. The compatibility ledger distinguishes direct consumers, shared formats and planned integrations; shared branding is not evidence of an integration test.
A future mode may use pair-scoped, per-epoch rendezvous tags, borrowing Link's privacy principles rather than its tag format. No wire shape is frozen. It needs KithMoot-specific Nostr-relay threat-model vectors, capability negotiation and explicit authority-controlled room activation.
Old clients require p. Emitting both selectors retains those clients and still
reveals device keys; emitting tags alone excludes them. Until a negotiated mode
exists, relays continue learning the current per-room device keys. M2 changes
neither selectors nor subscriptions to hide that trade-off.
Old-to-new and new-to-old reads must pass for signalling, links, chat, agents, keeper state and forwarder offers. Shared Wildbloom vectors and attachment journeys must remain valid. Android runs the same signal/pass/policy vectors. Existing services must operate without redeployment or an authority policy.
See M2 compatibility ledger for commands, concrete results and remaining physical/live gates. Protocol vectors, browser CI and an APK build are separate evidence from installed-device acceptance and public service behaviour. A feature absent from the old Android client is not claimed as implemented merely because its vectors are copied into the repository.