This kit is pre-1.0 (see AGENTS.md "Release Notes"); a behaviour change on
main is still called out here, because createDeviceCredential is a
byte-identical copy of a KithMoot function (see EXTRACTION.md) and this is
the one place its behaviour has deliberately diverged.
- Self-destructing rooms (
docs/room-destruct.md):encodePersistentInvitation({ destruct: true })writes"destruct": trueinside the encrypted group invitation body, afterends;endsis not required.decodePersistentInvitationreturnsPersistentRoomAdmission.destructand refuses the envelope when the field is present and nottrue.requestPersistentRoomAdmissionkeepsdestructif any valid signed copy carries it, in either order, as it keeps the earliest end. encodeInvitationRetirement({ ended: true, destruct: true })writes{"v":1,"ended":true,"destruct":true}and throws ondestructwithoutended.decodeInvitationRetirementNoticenow returns{ ended: boolean; destruct?: true }, believingdestructonly besideended. The retirement content is plain JSON, asendedalways was.encodeRekeyEvent({ closed: true, destruct: true })writes"destruct": trueinside the encrypted body, afterclosed, and throws ondestructwithoutclosed.RekeyNotice.destruct(decodeRekeyEvent) andRekeyEvidence.destruct(readRekeyEvidence) report it only on a closing body; on an open rekey the flag is dropped and the rekey still read.- Vectors:
vectors/destruct-vectors.json(npm run generate-destruct).
decodePersistentInvitationrefuses a group invitation whose body carriesdestructas anything buttrue. No released writer produces one.
Without the flag, or with it false, every event is byte-identical to
0.8.0's. A 0.8.0 reader ignores the flag: it admits to a self-destructing
room, reads its retirement as an ended room and its closure as a close, and
ends the room the old way.
- Scheduled rekeys (
docs/scheduled-rekey.md):encodeRekeyEvent({ scheduled: true })writes"scheduled": trueinside the encrypted rekey body, so a client can move to the new epoch without announcing it. The encoder throws when it is combined with a removal or a close.RekeyNotice.scheduled(decodeRekeyEvent) andRekeyEvidence.scheduled(readRekeyEvidence) report it only when nobody was removed and the room stays open. - The history window:
HISTORY_WINDOW_SECONDS(30 days),MAX_HISTORY_EPOCHS(16) andepochsInWindow(left, now), the left epochs a member goes on reading: those left within the window, newest first, at most 16. - The authority's grant carries the window.
encodeEpochGrant({ passed })writes the left epochs (LeftEpoch:{ epoch, secret, leftAt }) aspassed: [{ epoch, secret, left }], oldest first, and throws on more than 16, epoch 0, an epoch not below the one granted, a doubled epoch, a bad secret or a bad time.hostRoomEpoch({ past })asks for them on every grant, keeps the window's and leaves out anything the encoder would refuse.decodeEpochGrantreturns them asEpochGrant.passed, and drops a malformed list while keeping the grant. - Vectors:
vectors/schedule-vectors.json(npm run generate-schedule).
EpochGrant.passedandMemberEpochGrant.passedare nowArray<RoomEpoch & { leftAt?: number }>, anddecodeMemberEpochGrantsetsleftAtfrom thecreated_atof the authority's rekey out of each passed epoch.
A rekey without the marker, and a grant without passed, is byte-identical
to 0.7.0's. A 0.7.0 reader ignores both fields: it announces a scheduled
rekey as any other, and reads the current epoch from a grant that carries
the window.
- Seal keys (
docs/seal-key.md): healing after a device key is copied.createDeviceCredential({ seal })appends["seal", <x-only pubkey>]to the credential, under the participant's signature.encodeRekeyEventtakes recipients as{ device, credential }as well as bare devices (RekeyRecipient),encodeEpochGrantandencodeMemberEpochGranttakecredential, and each copy is sealed to that credential's seal key when it names one.decodeRekeyEvent,decodeEpochGrantanddecodeMemberEpochGranttakesealSks, andrequestRoomEpochandmemberEpochSourcetake asealSks()getter; each tries the seal keys, then the device key.hostRoomEpochandhostMemberEpochDesktakecredentialFor(device)and seal each answer to the newer of that and the credential the request carries (sealCredential). New inseal.ts:SEAL_TAG,isSealPubkey,generateSealKey,credentialSeal,sealTarget,sealTo,openSealed,newerCredential. - Vectors:
vectors/seal-vectors.json(npm run generate-seal).
decodeRekeyEvent: a copy for this device that none of its keys opens is read as no copy (the notice withoutsecret), rather than the whole rekey reading as null. A session asked the authority either way; it now also learns who was removed.
With no seal key anywhere, every event is byte-identical to 0.6.0's.
- The known-members gate (#207). The epoch desks' admission proof is made
under the epoch-0 room key, which a removed member keeps, so a removed
person could ask again under a fresh participant key and be handed the
current epoch. Once a room has removed anybody,
hostRoomEpochandhostMemberEpochDesknow grant only to participants for which the newknown(participant)option says yes, and report anybody else throughonUnknown: once per participant, and again everyreportUnknownEveryseconds (default 60) while they keep asking. A desk given noknownlets nobody through after a removal: wireknownbefore taking this release. Rooms that have never removed anybody behave exactly as before. - New refusal
'unknown'(EpochRefusal): the authority's answer to a participant it does not know. Not final:requestRoomEpochkeeps asking, calls its newonUnknownonce, and rejects withEpochRefusedError('unknown')only at its timeout. A decoder from 0.5.1 reads it as no answer and keeps asking.
encodeRekeyEvent({ members }): the authority's member list in the encrypted rekey body (aftercommit, beforekeys), removed participants dropped. Read asRekeyNotice.members,RekeyEvidence.membersandMemberEpochGrant.members.hostRoomEpoch({ members })andencodeEpochGrant({ members })carry it in the authority's grant (EpochGrant.members).readMemberList. Omitted, every event is byte-identical to 0.5.1's.- Vector
rekey-with-membersinvectors/member-epoch-vectors.json.
MemberEpochGrant.passed: the epochs a member grant carried between the requester's and the one it hands over, oldest first, each proven by the next rekey in the chain as before.requestRoomEpoch's grant carries it as an optionalpassedwhen a member answered. Wire unchanged; a consumer that ignores it behaves exactly as with 0.5.0. KithMoot uses it to read what was said in the epochs a returning device skipped, rather than reporting them lost.
- Member epoch catch-up: any current member can bring an admitted,
non-removed device up to date while the authority is offline, and the
device verifies the answer against the authority's signatures instead of
trusting the member. See
docs/member-epoch-catch-up.md.encodeRekeyEventtakes an optionalcommit: true, which writescommit: epochCommitment(roomId, epoch, secret)into the encrypted body (key orderv, epoch, removed, by, closed, commit, keys; the body version stays 1 and a 0.4.0 reader ignores the key). Without it the event is byte-identical to 0.4.0's.- New module
epoch-commit.ts:epochCommitment,EPOCH_COMMIT_PREFIX. - New module
member-epoch.ts: kinds 20471 (member epoch request) and 20472 (member epoch grant) asMEMBER_EPOCH_KINDS;hostMemberEpochDesk,memberEpochSource,requestMemberEpoch,encodeMemberEpochRequest,decodeMemberEpochRequest,encodeMemberEpochGrant,decodeMemberEpochGrant,readRekeyEvidence,deriveMemberEpochRequestKey,MAX_MEMBER_EPOCH_CHAIN. requestRoomEpochtakes an optionalmemberssource; without it, it behaves exactly as before.memberEpochSourcewatches the room's rekeys and refuses a member grant that stops short of the newest authority-signed one, so a member removed at epoch E cannot hold a requester at E-1 when the caller passes noexpected.- Member grants (20472) are signed by a one-time key per grant, not the
answering member's device key, so a grant does not publicly tie that
device to the room.
MemberEpochGranthas nofrom, andencodeMemberEpochGranttakes nodeviceSk. memberEpochSource'sremovedis a function or an iterable read once, so a generator is not used up by the first grant.- Two new wire labels:
kithmoot/v1/epoch-commit:andkithmoot/v1/member-epoch-request-key. vectors/member-epoch-vectors.json(KithMoot vector format, shipped in the package) andscripts/generate-member-epoch.mjs.
- The browser bundle budget for the main entry rises to 64 KB minified / 19 KB gzip (measured 51.2 / 14.9).
- Room relays: the relays a room is created on, carried in its signed group
invitation so every member's pool includes them and two members can never
end up on disjoint relays.
encodePersistentInvitationtakes an optionalrelays: one to eight distinct strings, each a safe relay URL (isSafeRelayUrl) in canonical form (normalizeURLfromnostr-tools/utils, e.g.wss://relay.example.com/) with no credentials, else it throws. The encrypted v3 body carries them afterends(key orderv, room, secret, ends, relays; the version stays 3).decodePersistentInvitationreturnsrelays(andPersistentRoomAdmissiongains it) and returns null for the whole envelope when the list is malformed - never a trimmed list.requestPersistentRoomAdmissionkeeps the relays of the newest signed copy (bycreated_at) that names any; a copy naming none says nothing about them, and between equal timestamps the first heard stays. ContrastendsAt, where the earliest end wins. isInvitationRelays,requireInvitationRelaysandMAX_INVITATION_RELAYS(8): the rule, for callers that build the list.
With no relays, the body is byte-identical to 0.3.0's, and a 0.3.0
reader ignores the key. scripts/diff-source.mjs declares each added line
(see EXTRACTION.md "Room relays").
- Conference rooms: a persistent group that ends on a fixed date.
encodePersistentInvitationtakes an optionalendsAt(unix seconds, afternowand no more than 30 days beyond it, else it throws). With it, the encrypted v3 body carriesends(the version stays 3) and the kind 1463 event carries a NIP-40['expiration', String(endsAt)]tag, so relays drop the invitation when the room ends.decodePersistentInvitationreturnsendsAt(andPersistentRoomAdmissiongains it) when the body has a positive whole-numberends; it returns null when anexpirationtag is present and does not equal the body'sends, when there are twoexpirationtags, or whenendsis malformed.requestPersistentRoomAdmissionkeeps the earliestendsAtamong the signed copies it hears. encodeInvitationRetirementtakesendsAt, tagging the kind 1461 tombstone with the same expiration.encodeRekeyEvent,encodeEpochRequest,encodeEpochGrant,hostRoomEpochandrequestRoomEpochtakeexpiresAt, so a conference room's epoch events lapse with it.withExpiration(tags, expiresAt),isRoomEnds,requireRoomEndsandMAX_ROOM_ENDS_SECONDS: the expiration rule every event signed for a conference room follows - add the end if the event has no expiration, keep an earlier one, lower a later one, never two.
With no endsAt/expiresAt, every encoder produces exactly the bytes it
did in 0.2.0. scripts/diff-source.mjs declares each added line (see
EXTRACTION.md "Conference rooms").
deriveScoped(epoch, label)for app-defined keys under an epoch, andcreateSubKeyCertificate/verifySubKeyCertificatefor never-published sub-key certificates (see README andvectors/fold-vectors.json).
createDeviceCredentialnow throws for a person-scope credential a restamping signer would make unverifiable (forgesworn/kithmoot#205). Previously, a remote signer that restampedcreated_atto an earlier clock could makecreateDeviceCredentialreturn a credential that everyverifyDeviceCredentialcall would then refuse as"longer than 30 days"- the mint-time cap was checked against the requestednow, while the verifier checks against the signedcreated_at.createDeviceCredentialnow re-checks with the verifier's own measurement and throws a new typed error,RestampedCredentialExpiryError(exported from the package root), instead of returning a credential doomed to fail everywhere.- Who is affected: only callers passing
scope: 'person'whoseidentity.signEventmay restampcreated_at(a NIP-46 bunker, a NIP-55 phone signer, or any other out-of-process signer) AND whose requestedexpiresAtis close enough tonow + 30 daysthat the restamp pushes the real duration over the cap. A local signer (localIdentity), or any signer that does not restamp, is unaffected. Room-scope credentials (roomIdset, noscope) have no 30-day cap and are unaffected. - Migration: catch
RestampedCredentialExpiryErrorand retry with a shorterexpiresAtmargin (this kit's own vectors use a one-hour margin:now + PERSON_CREDENTIAL_MAX_SECONDS - 3600), as the downstream sync spec that reported this issue already does. - KithMoot pairing required: KithMoot has cut over to this kit and
pins
@forgesworn/fold-kitexactly, so itssrc/credential.tsis a re-export and picks this fix up on its next version bump. That bump must land in the same KithMoot PR as the matching change to itsvectors/verify-circle.test.tsrefused-over-30-dayscase (M15) andvectors/generate-circle.mjs, which still expect the mint to return the over-cap event rather than throw.
- Who is affected: only callers passing