MIP-03 ("Application Messages" → Security Requirements) is explicit:
> "Inner events MUST remain unsigned (no `sig` field)
> This ensures leaked events cannot be published to public relays."
Authentication comes from (a) MLS framing — the sender's LeafNode
credential signs the outer MLS Application message — and (b) the
mandatory check in MarmotInboundProcessor.processPrivateMessage that
the inner event's `pubkey` equals the MLS sender's credential identity.
The Nostr Schnorr signature is redundant, and leaving it populated
means a leaked plaintext can be replayed as a valid public kind:9.
whitenoise-rs is spec-compliant (builds via `UnsignedEvent::new()` +
`ensure_id()`, never calls sign). Amethyst was the non-conforming side
on both directions:
1. Send path (`MarmotManager.buildTextMessage`,
`AccountViewModel.sendMarmotGroupMediaMessage`) called
`signer.sign(template)`, producing a signed rumor that shipped a
valid Schnorr signature through the encrypted channel. Switch both
to `RumorAssembler.assembleRumor` — same id derivation (SHA-256
over canonicalized [0, pubkey, createdAt, kind, tags, content]),
but `sig = ""`.
2. Restore path (`Account.kt` on startup reload of persisted inner
events) called `cache.justConsume(innerEvent, null, false)`, which
routed through `consumeRegularEvent` → `justVerify` → `event.verify()`
→ FAIL on empty sig → Note registered with `event = null`, message
never rendered. Pass `wasVerified = true`, matching what the live
receive path already does after the previous commit (573c5c2b).
Existing on-disk persisted messages from older signed-rumor builds
still load — `wasVerified=true` skips sig verify entirely, so both
legacy signed and spec-correct unsigned rumors deserialize cleanly.
The previous commit (6ad522b5) shielded the chatroom from the mistaken
"duplicate" drop but was treating the symptom. The real cause of the
"inner event already in cache" log — reported while Test 02's chat
stayed completely empty — is that `cache.justConsume(innerEvent, null,
wasVerified=false)` runs `event.verify()` on a wn-signed inner kind:9,
that verify fails (likely JSON-canonicalization skew or a rumor-style
empty signature), so `consumeRegularEvent` bails at line 633 before
ever setting `note.event`. LocalCache then always had the empty Note
registered by id but with no event content — subsequent arrivals re-hit
line 615 (`note.event != null` is still false, not the "duplicate"
path), retried verify, failed again, and the chatroom never got a
populated message to render.
The inner event has already been cryptographically authenticated by
MLS — `MarmotInboundProcessor.processPrivateMessage` rejects any inner
event whose `pubkey` doesn't match the MLS sender's credential
identity (MIP-03, line 430). Running Nostr's `event.verify()` again
is a redundant gate. Pass `wasVerified=true`: `consumeRegularEvent`
skips `justVerify` and calls `note.loadEvent(...)` on first arrival,
chatroom-add works immediately, and the test passes.
Also tightens the "already in cache" comment to reflect what that
branch actually means now (a legitimate hydrated-by-startup-restore
duplicate, not a silent verify failure masquerading as one).
The B->Amethyst kind:445 reply was decrypting fine — the harness's
new outbound-side diagnostics + the Amethyst logcat showed a clean
ApplicationMessage path right up to the chatroom-add step:
GroupEventHandler.add: kind:445 id=988221d0… groupId=ee27bc48…
GroupEventHandler.add: ApplicationMessage decrypted innerKind=9
innerId=ebf35373… author=170df8d1…
GroupEventHandler.add: inner event already in cache (duplicate)
`cache.justConsume` returned false because the inner event id was
already in LocalCache (a prior session persisted the same plaintext
via persistDecryptedMessage and the startup restore loop in
Account.kt:3034-3044 reloaded it into the cache before this kind:445
arrived). The live-receive branch in DecryptAndIndexProcessor was
gated on `isNew` and silently dropped the message — never calling
marmotGroupList.addMessage — so the chatroom never saw B's reply
even though decryption succeeded.
Mirror what the restore path does: always surface the inner note
in the chatroom. `MarmotGroupChatroom.addMessageSync` is itself
idempotent (dedupes by Note identity), so the call is safe even
when the message was already added through another path.
`persistDecryptedMessage` stays guarded on `isNew` because the
message store is append-only and a re-persist would grow the
on-disk log unboundedly across decrypt retries.
The bug report: after tapping Remove on the Marmot Group Info screen,
the target npub stays in the member list, and the only log line is
the round-tripped kind:445 hitting the "Duplicate" branch in
GroupEventHandler.add. The author-side path (Account.removeMarmotGroupMember
→ MarmotManager.removeMember → syncMetadataTo → publish) has no logging,
so there's no way to tell which step broke — the MLS tree mutation,
the local member-list push to chatroom.members, or the UI recomposition.
Mirror the addMarmotGroupMember logging style on removeMarmotGroupMember
(entry, no-op guards, built commit id, publish) so we can see whether
the author path even runs end-to-end, and have syncMetadataTo log the
group id and the previous→new member count so the StateFlow update
becomes observable. No behavior change.
bash 3.2 (stock on macOS) raises "unbound variable" on "${gids[*]}"
when the array is empty under `set -u`. The happy-path sanity check
still worked (the array was populated), but the shell error aborted
execution before the actual test cases ran on a fresh state/ directory.
Only expand when the array has at least one entry.
Broaden the default public relay set for the interactive harness so
kind:1059 gift wraps and kind:10050 inbox lists have more landing
spots that don't pre-filter Marmot traffic out. Both relays are
known to accept kinds outside the core "note / metadata / contacts"
trio that damus/primal tend to restrict.
The Amethyst-side setup instructions are rendered from the same list,
so operators who configure Amethyst per the harness prompt get the
same five URLs on the app side too.
wnd persists pending group invites across daemon restarts, so on any
rerun C (and B) already have unaccepted welcomes from previous runs
lying around. `wait_for_invite` was pulling `.[0]` out of the invite
list, which is the oldest pending — i.e. the stalest — and treating
it as the arrival the caller just triggered. In the sanity probe this
caused the kind:445 step to send to B's NEW group id while C was
accepting a DIFFERENT (old) group, producing a bogus "kind:445 failed
— relays may be dropping group messages" with no actual relay issue.
Add `snapshot_invites <B|C>` which returns the current gids as CSV,
and teach `wait_for_invite` to accept that CSV as an ignore list so
it walks past stale welcomes and only returns a freshly-arrived one.
Use the new snapshot→wait pattern at every call site: sanity check,
Test 02 (B), Test 04 (C), Test 05 (C), Test 08 (C), Test 12 (C).
Also log a gid-mismatch warning in the sanity check when B's created
group and C's accepted welcome disagree, as an extra guard against
the filter being bypassed.
Several public relays (damus.io in particular) silently drop kind:1059 /
10050 / 30443 from accounts they've never seen before — the sanity
check in the interactive harness reports exactly this failure mode
when the three-relay default set is in use, because wn's identities are
brand-new and have nothing on file.
Before the kind:30443 + 10050/1059/445 probes, publish a kind:0
profile event for each wn identity via `wn profile update --name
--about`. That marks both accounts as "known" on the relay and lets
the subsequent gift-wrap and KeyPackage publishes land. Runs inside
configure_relays (after relays are added, before the first sanity
probe) so the sequencing is: add relay -> advertise profile -> publish
KPs -> start exchanging welcomes.
Does not help when a relay outright refuses the kind (some relays
whitelist only kind:1 / 3 / 7); those still need --local-relays.
When the user updates their relay configuration on the AllRelay screen,
the newly-selected relays often have no copy of the account's existing
settings (profile, follow list, mute list, other relay lists, etc.), so
the user appears to "lose" their account to anyone reading from those
relays until the next time each list is edited.
After saving each relay list, re-publish the relevant existing events
to the newly-selected relays (only when the set actually differs):
- Outbox/inbox (NIP-65) or private-storage or broadcast changes
republish every known account-settings event to the new relay set.
- KeyPackage relay-list changes republish all active kind:30443
KeyPackage events authored by this account.
- Local relay-list changes republish account-settings events locally.
- Indexer relay-list changes publish kind:0 and kind:3.
- Search relay-list changes publish kind:0.
Local relays now flow through a new Account.saveLocalRelayList so the
republish hook lives alongside every other relay-list save.
https://claude.ai/code/session_01PcvDaNyzT6Tk4D7jn75Jbm
The three Marmot stores (MLS group state, messages, KeyPackage bundles)
were all initialized with the global app filesDir, so chats and MLS
state from one account were visible to any other logged-in account.
Pass each store a per-account subdirectory (accounts/<pubkey>) derived
from the signer pubkey, so paths become:
files/accounts/<pubkey>/mls_groups/<groupId>/{state,retained,messages}
files/accounts/<pubkey>/marmot_keypackages/state
Existing in-memory state was already scoped per-Account; this closes the
last remaining cross-account leak on disk.
The DisappearingScaffold hid its top/bottom bars purely from overscroll
gestures, so a feed with too few items to scroll would end up with two
empty bands where the chrome used to be. Pure overscroll (consumed.y == 0)
from the fully-visible state is now ignored; once the bars have started
moving we know the list is scrollable and edge overscroll keeps feeding
bar motion as before.
When `wn_b messages send` fires but Amethyst never displays the reply,
the harness used to swallow the send output and only ask the operator a
blind yes/no — giving no signal about which side of the pipe failed.
Amethyst subscribes to kind:445 on the MLS GroupContext `relays`
extension (falling back to `Account.homeRelays`), so the two
actionable failure modes are:
- wn published to a relay Amethyst isn't subscribed to (group-relay
mismatch between the MLS extension and Amethyst's subscription)
- Amethyst received the event but `GroupEventHandler.add` dropped it
(no `h` tag, `isMember=false`, decrypt error)
Teach the harness to dump both sides automatically on the reply step:
- Capture wn's `messages send` stdout/stderr into the run log.
- `dump_outbound_diagnostics` prints wn's local view of the group
messages (did the send persist to wn's own DB?), the MLS
GroupContext relay list (what Amethyst should be subscribed to),
and live relay connection status.
- On operator-reported fail, prompt for an Amethyst-side logcat
grep targeted at the exact log points — MarmotGroupEventsEoseManager
filter updates, GroupEventHandler.add arrivals, and the membership
drop branch — naming the current group id so the operator can grep
for the right hex prefix.
`wn users show <npub>` only resolves identities the local daemon has
cached, so A's npub (the Amethyst account, which B's daemon has never
interacted with directly) falls through to the "return input unchanged"
branch. That leaks the raw npub into the Test 02 member-list assertion,
where it's compared against hex pubkeys pulled from `wn groups members`
and fails with "member list missing npub1...".
Add a pure-awk bech32 decoder and use it as the final fallback in
`npub_to_hex`, so unknown npubs still convert to the 32-byte hex form
the daemon emits. Also harden the JSON path to peel a `.result` wrapper
in case a future `wn --json users show` ships one.
The interactive harness was failing with the same
KeyringEntryMissingForExistingDatabase error as the headless one —
the MLS SQLite DB on disk references a keyring entry that no longer
exists (keychain entry pruned, data dir restored out of band, or a
previous run used the mock keyring). wnd can't open the DB in that
state, so the daemon exits before its socket appears.
Unlike the headless harness (which wipes unconditionally because its
mock keyring is always ephemeral), the interactive harness uses the
real OS keyring and benefits from preserving B/C identities across
runs. So: try once, and only wipe + retry when stderr actually shows
the keyring-missing error. Also dump stderr/stdout tails inline on
final failure so the operator doesn't have to chase a log path.
Apply the same SIGINT/SIGTERM/SIGHUP trap hardening to the interactive
harness that just landed on the headless one. Without it, killing the
script mid-run leaves the nohup'd wn daemons alive and the next run
fails in preflight because the sockets are still held.
The previous trap only fired on the EXIT pseudo-signal, which doesn't
always run cleanly when the script is killed mid-flight — leaving the
nohup'd wnd daemons (and the local relay) alive and still holding the
port. Route SIGINT/SIGTERM/SIGHUP through an explicit `exit`, use a
single idempotent cleanup handler, and preserve the exit code so the
harness still reports failure when killed.
The headless harness runs wnd with WHITENOISE_MOCK_KEYRING=1, but the
mock keyring is in-memory only — it starts empty on every wnd restart.
Meanwhile the SQLite databases under $data_dir persist across runs and
reference keys that no longer exist, so the second run onward fails at
startup with KeyringEntryMissingForExistingDatabase before wnd can even
open its control socket. Wipe everything under the daemon's data dir
(except logs and pid) before each start so wnd always comes up cold and
consistent with its ephemeral keyring.
Also surface failure diagnostics inline instead of burying them in a log
file path: print the last 40 lines of stderr and 20 of stdout, report
whether wnd is still alive or already exited, and break out of the 30s
wait loop the moment the daemon dies.
The prior change tracked the "no slide" intent via a mutable flag on Nav
that every navigate method had to reset. Replace it with a per-entry
hint: navBottomBar writes SKIP_SLIDE_ANIMATION_KEY=true to the new
destination's savedStateHandle, and composableFromEnd reads it off
targetState (forward) / initialState (pop).
Benefits:
- No shared mutable state; no reset bookkeeping in nav/newStack/popUpTo.
- No race between interleaved navigate calls.
- Pop animations are automatically consistent with how the entry was
entered - backing out of a bottom-bar tab now also fades.
Drawer routes are registered with composableFromEnd (slide-from-right +
scale-out) because that matches the expected drawer push animation.
When a user pins one of those routes onto the bottom navigation bar
(feature added in 376e404c), the bottom-bar tap still replayed the
slide animation because the transition is bound to the Route type at
composable<> registration time, not to the caller.
Introduce a third navigation verb, INav.navBottomBar(route), that sets
a skipSlideAnimation flag on Nav before performing the same newStack-
style stack reset (popUpTo + launchSingleTop). composableFromEnd /
composableFromEndArgs read the flag in their enter/exit/popEnter/
popExit lambdas and return null when set, inheriting the NavHost-level
fade (matching what the five default bottom-bar routes already do).
nav.newStack behaviour and all drawer navigation paths are unchanged,
so non-bottom-bar callers (intent redirects in AppNavigation.kt and
every DrawerContent click) keep their slide.
Two drifts surfaced as 7 failing tests on the jvmTest run:
1. MarmotGroupData CURRENT_VERSION is held at 2 for mdk-core interop
(see the const-doc block), but three tests still assumed v3:
- marmotGroupData_defaultVersionIsThree asserted the constant directly.
- marmotGroupData_roundTripWithDisappearingSecs and
buildGroupEvent_appendsExpirationTagWhenDisappearingConfigured relied
on the default version emitting the v3-only disappearing_message_secs
field, which the encoder correctly skips for v2.
Rename the first to `currentVersionIsHeldAtTwoForMdkInterop` with a
spec-cross-reference comment and an extra assertion that v3 is still
in SUPPORTED_VERSIONS. Pin version=3 explicitly in the other two so
they exercise the v3 path regardless of the interop hold-back.
2. MlsGroup.processCommit now requires a non-empty FramedContentTBS
signature on non-external commits (per RFC 9420 §6.1, introduced in
0c970945). Four pipeline tests still called the raw processCommit
overload with signature=ByteArray(0), so they all failed with
"FramedContentTBS signature missing on commit from leaf 0".
Add a `processFramedCommit` wrapper on MlsGroupManager that mirrors
MlsGroup.processFramedCommit + the retention/persistence bookkeeping
from processCommit, and switch the four tests to feed framedCommitBytes
through it. MarmotInboundProcessor is unaffected (it already has the
decoded PublicMessage fields).
All 7 originally-failing Marmot tests now pass; the 4 unrelated
NostrClient network tests remain independently flaky.
https://claude.ai/code/session_01XQNAmwn1y87GAoK94QWgyn
Two bugs in MlsGroup were blocking 12 of 209 jvmTest cases (all RFC 9420
interop vectors already passed; only our higher-level lifecycle code
diverged from the spec).
Bug A — write/read asymmetry on member commits. processCommit requires
signature + confirmation_tag as separate parameters, but CommitResult
only exposed framedCommitBytes (the full PublicMessage envelope). Tests
called processCommit(commitBytes, ..., ByteArray(0)) and immediately
hit "FramedContentTBS signature missing on commit from leaf N".
Add MlsGroup.processFramedCommit(framedCommitBytes) that unpacks the
MlsMessage(PublicMessage(Commit)) envelope, verifies membership_tag for
member senders (RFC 9420 §6.2), and delegates to processCommit. Mirrors
the unwrap MarmotInboundProcessor already does in production. Updates
the 9 affected tests to use the new entry point.
Bug B — externalJoin used the wrong HPKE info bytes. The joiner
encrypted UpdatePath path-secrets against groupContext.toTlsBytes()
(pre-mutation), but receivers HPKE-Open against the post-mutation
context (epoch+1, new tree_hash) per RFC 9420 §7.6. Result:
AEADBadTagException on every external join.
Refactor externalJoin to follow the same staging pattern as commit():
stage public keys, applyUpdatePath, patch parent_hash, replace
placeholder leaf — then compute pathEncContextBytes from the post-
mutation tree, then HPKE-encrypt path-secrets with that context.
To make the framed pipeline available to external commits as well,
introduce ExternalJoinResult exposing both commitBytes and
framedCommitBytes (PublicMessage with sender = new_member_commit, no
membership_tag). processFramedCommit resolves NEW_MEMBER_COMMIT senders
to the receiver-side leaf index using the same first-blank-or-append
algorithm as RatchetTree.addLeaf, so wire decoding doesn't need to
carry the joiner's chosen index in the empty Sender field.
After: 209/209 MLS tests pass.
Two harness-side bugs that made a broken wn silently look like a working one:
1. whitenoise-skip-unprocessable-retry.patch's hunk header declared
`@@ -178,7 +178,23 @@` but the new side only has 22 lines. GNU patch
bails with "malformed patch at line 26" and leaves the target file
untouched. Fix the count to +178,22 so the patch actually applies.
2. setup.sh's patch-apply loop piped `patch` through `tee`, which
masked patch's exit code behind tee's. A miscounted hunk therefore
still touched the `.headless-patched-*` marker and the next run
skipped the "patch" step entirely, so the resulting wn ran the
stock 10-retry exponential backoff on every MlsMessageUnprocessable
— ~17min per event, which pegs the per-account serial event
processor and makes every later test timeout "just because".
Check patch's real exit status; fail preflight loudly instead.
Same class of bug was also hiding cargo build failures: the
`cargo build ... | tee` pipeline reported success even when rustup
couldn't fetch the toolchain manifest (503 from static.rust-lang.org)
or cargo couldn't hit the crates.io index (503 from fastly). setup.sh
then `info`-logged the expected binary paths and happily moved on —
until the daemon launch tripped over `nohup: No such file or directory`.
Now each cargo build runs in a 4-attempt retry loop that terminates
only when the binary actually exists on disk, and fails preflight
loudly if it never materialises. Matches the jitpack 503 retry
behaviour already in place for `:cli:installDist`.
https://claude.ai/code/session_018kSBco5VVfctkW6vAm7NzA
jitpack.io and dl.google.com both return 503 on a non-trivial fraction
of cold-cache fetches, and Gradle disables the whole repository for the
rest of the build the moment a single 503 lands — so one bad roll
aborts the entire harness before any test gets to run. Wrap the gradle
invocation in a 4-attempt retry loop; each attempt resumes from
Gradle's local cache so only the still-missing artifacts get re-fetched.
In practice the retry completes in seconds when jitpack is the only
flake, and the whole preflight still wall-clocks well under the first
attempt's runtime. Existing success path is unchanged.
https://claude.ai/code/session_018kSBco5VVfctkW6vAm7NzA
The `.result` wrapper fix landed in lib.sh's pollers and the headless tests
back in f08b010f, but the 8 inline `jq` reads in marmot-interop.sh were
missed — which silently returned empty against current `wn` and made every
UI-verification test look like "Amethyst didn't propagate X" when in fact
the underlying wn state was correct and the script just couldn't see it.
All 8 reads now peel `(.result // .)` the same way the lib.sh helpers do,
staying backwards-compatible with the pre-v0.2 flat shape:
* test 02, 04 member lists
* test 06 member-removed check
* test 07 group-name poll
* test 08 admin list (promote + demote)
* test 09 anchor message lookup
* test 11 leave-group member check
Also split the configure_relays sanity check into three relay-level
surfaces so a broken relay set points at the specific kind it can't
handle, instead of 10 later tests all timing out with the same "no
invite" symptom:
* kind:30443 (KeyPackage) — both sides publish + symmetric discover
* kind:10050 (Inbox advertise) — C reachable as giftwrap target
* kind:1059 (Gift wrap) — B->C welcome delivery
* kind:445 (MLS commit/msg) — real group-message round-trip
And drop the orphan whitenoise-mdk-plaintext-policy.patch: it lived in
`headless/patches/` but was never referenced by setup.sh's patch array,
and if it ever were applied it would flip mdk-core's MLS wire-format
from MIXED_CIPHERTEXT to MIXED_PLAINTEXT — a protocol-breaking
divergence from every stock whitenoise-rs / White Noise build. Removing
it eliminates the trap of a future contributor "enabling" it.
https://claude.ai/code/session_018kSBco5VVfctkW6vAm7NzA
Mirrors the Chess drawer entry pattern — `if (isDebug)` gates the
row. Audio rooms' Nostr surface (presence, hand-raise, chat) works,
but without the WebTransport audio backend the entry would bait
release-build users into a room they can't actually listen to.
One-line filter at the call site in DrawerContent.ListContent:
val feedsItems = if (isDebug) DrawerFeedsItems
else DrawerFeedsItems.filter { it != NavBarItem.AUDIO_ROOMS }
CatalogSection(..., feedsItems, ...)
Catalog entry + route + screen wiring all stay, so debug builds still
see it and the Nostr feature continues to be exercisable. When
`QuicWebTransportFactory.connect()` stops throwing NotImplemented the
two lines go away.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
The branch already covers the Nostr-side of audio rooms (drawer
listing, 30312-event parsing, 10312 presence publishing, chat, hand-
raise). The audio-transport pieces (WebTransport + MoQ + Opus play-
back) are blocked on Phase 3b-2 (pure-Kotlin QUIC — see the plan in
`docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md`).
The problem: the in-progress AudioRoomConnectionViewModel was
auto-calling connectNestsListener() on stage enter, and the stub
`KwikWebTransportFactory` throws `NotImplemented`. That surfaced as a
persistent red "Audio failed: WebTransport NotImplemented" chip
every time a user opened any audio room — scary, confusing, and
misleading.
Hiding the broken UI until the transport lands:
- Deleted `AudioRoomConnectionViewModel.kt`. The
AudioRoomConnectionViewModel + ConnectionChip code is recoverable
from git (commit `64b33674`) when Phase 3b-2 makes the underlying
transport real.
- Removed the auto-connect LaunchedEffect + ConnectionChip render
from AudioRoomStage.
- Removed the mute button. Pressing mute when we're not producing a
mic stream would publish `["muted","0"|"1"]` on 10312 presence
with no corresponding audio signal — misleading to peers. The
builder-side support for the muted tag stays in
MeetingRoomPresenceEvent (Quartz) for future use; the UI will
re-add the button when audio capture actually runs.
- `publishPresence(..., muted = null)` so the muted tag is elided
from the broadcast event entirely while the feature is hidden.
- Dropped the unused strings (audio_room_mute, audio_room_unmute,
audio_room_conn_*) from strings.xml.
What still works on the branch (verified):
- Audio Rooms drawer entry lists kind 30312 rooms.
- Tap a room → live-activity channel screen with the audio-room
stage overlay showing title, summary, host+speaker avatars,
audience avatars.
- Kind 1311 chat (read + send) via the existing channel infra.
- Kind 10312 presence published on enter + every 30 s with the
correct `a`-tag and `["hand","1"|"0"]` reflecting local state.
- Hand-raise button toggles the `hand` tag — browser clients +
other NIP-53 peers can see the signal and hosts can promote.
What's hidden until Phase 3b-2 lands:
- WebTransport connection attempt, connection chip, mute button.
Re-enabling them is additive: resurrect AudioRoomConnectionViewModel
from git, paste three LaunchedEffect / DisposableEffect /
ConnectionChip blocks back into AudioRoomStage, restore the five
string resources, restore the mute button + the two-arg muted
presence call. Zero protocol impact on anything else.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
Quartz:
- processWelcome now enforces hash(ratchet_tree) == GroupContext.tree_hash
and verifies the joiner-derived confirmation_tag matches GroupInfo's,
closing the gap where a tampered but-signed GroupInfo could diverge
the joiner's epoch secrets.
- externalJoin does the equivalent tree_hash check.
- Promoted constantTimeEquals to a file-level helper so the companion
object (processWelcome) can use it alongside instance methods.
Tests:
- New MlsGroupNegativeTest exercises every authenticity knob on inbound
commits: tampered confirmation_tag, bit-flipped signature, spoofed
senderLeafIndex, wrong wire_format, empty confirmation_tag, and
tampered/missing membership_tag — each must throw and leave the
receiving group's epoch unchanged (atomic-rollback regression cover).
- Fixed stale jvmAndroidTest references to the removed `selfRemove()`
helper — use `buildSelfRemoveProposalMessage()` (now Pair-returning).
Interop harness:
- Adds tests 14–16 as inverted-role scaffolding (wn drives, amy receives).
Currently recorded as SKIP with explicit notes:
14 / 15 block on filtered-direct-path UpdatePath support in
quartz's applyUpdatePath (RFC 9420 §7.7 path compression).
16 blocks on a createdAt-sorted KeyPackage fetch path — the current
fetchFirst-based fetcher is non-deterministic on relay order.
Preserves the test functions so they start running the moment those
gaps are closed.
Captures the gap between NIP-53's room discovery and what audio-capable
clients/servers must actually agree on to interop. NIP-53 defines the
30312 event but leaves the HTTP control plane + MoQ namespacing +
audio codec params entirely to individual implementations. With Nests
going generic-server, this is the moment to standardize.
What the draft covers:
1. HTTP control plane:
- Path convention: GET <service>/<d-tag>
- NIP-98 `Authorization: Nostr <base64>` header required
- JSON response shape (endpoint, token, transport, codec,
sample_rate, frame_duration_ms, moq_version)
- Canonical error-status map (401/403/404/410/503)
2. WebTransport + MoQ handshake requirements (Extended CONNECT,
required HTTP/3 settings, Bearer token passing).
3. MoQ track naming — vendor-neutral one-element namespace
`[<d-tag>]` with track-name = speaker-pubkey-hex. Explicit
rejection of the `["nests", <d-tag>]` prefix for new deployments.
4. Audio object format: raw Opus packets (no Ogg, no TOC), 48 kHz
mono, 20 ms default, mono PCM 16-bit decode target. Both
OBJECT_DATAGRAM and STREAM_HEADER_SUBGROUP accepted; listeners
MUST handle both.
5. Per-track access control: server MUST verify publishing pubkey is
a host/speaker in the current 30312 event (which is replaceable —
revocation cascade is spec'd).
6. Leave procedure (UNSUBSCRIBE, UNANNOUNCE, final 10312 presence,
WT_CLOSE_SESSION capsule).
7. Presence extension: the `["muted", "1"|"0"]` tag we already ship
in nestsClient's MeetingRoomPresenceEvent overload, promoted from
Amethyst-specific to NIP-defined.
8. Server + client requirements summaries.
9. Known divergences from current nostrnests/nests servers (two-
element `["nests", <d-tag>]` namespace + `/api/v1/nests/<d-tag>`
path) with a transition strategy via a `"nip_xx": true` flag in
room-info responses.
10. Security considerations (bearer-token handling, NIP-98 `u` tag
binding, audio-replay attack surface, server-impersonation VU
meter recommendation).
Deliberately out of scope: E2E-signed audio objects (future NIP),
federation between audio servers, room recording/transcription.
Intended workflow: share with the Nests team while they're designing
the generic server, land changes against their feedback, then open a
PR on nostr-protocol/nips.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
Updates the Architecture section to reflect the decision to house the
pure-Kotlin QUIC + HTTP/3 + WebTransport code in a new top-level
Gradle module `:quic`, sibling to `:quartz`, `:commons`, `:nestsClient`.
Key changes:
- Module placement: new KMP module `:quic` with commonMain +
jvmAndroid + jvmMain + androidMain source sets (mirrors Quartz).
`:quic` takes `api(project(":quartz"))` so all crypto primitives
are in-scope without re-export.
- settings.gradle delta spelled out.
- Package layout shifts from
`nestsClient/src/jvmAndroid/...transport/quic/` to
`:quic/src/commonMain/com/vitorpamplona/quic/`, with UDP
socket-specific bits in `jvmAndroid/`.
- Varint.kt migration called out: moves from
`nestsClient/moq/Varint.kt` to `:quic` at
`com.vitorpamplona.quic.Varint`. Mechanical, one commit.
- Rename KwikWebTransportFactory stub → QuicWebTransportFactory
(real), living in `:quic` but implementing `:nestsClient`'s
existing `WebTransportFactory` interface. AudioRoomConnectionViewModel
changes one ctor call.
- Rationale section documenting why Option A beats putting it in
`:nestsClient` (single responsibility / reusability / security
boundary / test isolation / build graph / charter fit).
- Phase A now explicitly includes the module-creation +
Varint-migration step; test suite must stay green across the move.
Timeline unchanged (17-19 weeks). Dependency story unchanged
(no external libs; everything piggybacks on Quartz).
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
- Verify FramedContentTBS signature on inbound commits using the
sender's pre-commit leaf signature key (rejects forged commits
under a compromised exporter key).
- Verify membership_tag on inbound PublicMessage commits before
advancing state.
- Check LeafNode lifetime bounds (notBefore..notAfter) on UpdatePath.
- Require confirmation_tag to be non-empty.
- Thread wire_format into buildCommitFramedContentTbs so signature
verification reproduces the bytes the sender signed.
- Snapshot RatchetTree + groupContext + epochSecrets + initSecret +
secretTree + interimTranscriptHash + pendingProposals + sentKeys
at the top of processCommit, and restore on any throwable so a
verification failure mid-apply can't leave the group diverged.