Commit Graph

11619 Commits

Author SHA1 Message Date
Claude ed7b406ae7 fix(marmot): populate UpdatePath on empty commits + carry pending signing key
Two bugs that surfaced together once FramedContentTBS signing started
being checked at decrypt time:

1. Empty-proposal commits (pure forward-secrecy / self-update) were
   skipping the UpdatePath entirely, so the sender's commit_secret
   fell back to an all-zero vector while a spec-compliant receiver
   would derive a real commit_secret from the path — diverging the
   epoch key schedule on the very next message. Per RFC 9420 §12.4.1
   the path value MUST be populated when the proposal list is empty.

2. When an Update proposal rotates our own signing key, commit()
   rebuilds the committer leaf for the UpdatePath and signs it with
   signingPrivateKey (the PRE-rotation key) before the post-commit
   promotion step swaps signingPrivateKey for pendingSigningKey. The
   receiver then stores our leaf with the pre-rotation signature_key,
   while we start minting FramedContentTBS signatures with the new
   key — every subsequent decrypt fails signature verification. Use
   pendingSigningKey when present to seal the UpdatePath leaf.

Un-Ignore the two empty-commit / multi-epoch tests that documented
this divergence (one in MlsGroupLifecycleTest, one in
MlsGroupEdgeCaseTest). Both pass now, as does the existing signing-
key rotation cross-member round-trip test.

https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
2026-04-21 13:19:59 +00:00
Claude 236ebfe4d6 fix(marmot): apply RFC 9420 PrivateMessageContent framing to app messages
MlsGroup.encrypt / decrypt used to pass raw application bytes as the
AEAD plaintext — no PrivateMessageContent framing, no FramedContentTBS
signature, no padding. Amethyst ↔ Amethyst round-trips worked (both
sides skipped it the same way) but every cross-implementation message
was unreadable.

Per RFC 9420 §6.3.1 the AEAD plaintext for an application PrivateMessage
is the serialized PrivateMessageContent:

    opaque application_data<V>;     // varint-prefixed payload
    opaque signature<V>;            // FramedContentAuthData.signature
    opaque padding[N];              // zero padding (N may be 0)

where the signature is `SignWithLabel(., "FramedContentTBS",
FramedContentTBS)` computed over (version || wire_format ||
FramedContent || GroupContext). This change wires both sides up
end-to-end:

* encrypt now builds FramedContentTBS via the new
  buildApplicationFramedContentTbs helper, signs it with the caller's
  signature private key, and writes application_data<V> || signature<V>
  (zero-length padding) as the AEAD plaintext.
* decrypt parses application_data<V> and signature<V> from the AEAD
  plaintext, asserts all remaining bytes are zero padding, rebuilds
  FramedContentTBS for the sender's leaf, and verifies the Ed25519
  signature against that leaf's signatureKey before returning the
  application payload. Non-application PrivateMessages now error
  (commit/proposal-inside-PrivateMessage was never correctly handled
  by the old raw-bytes path either; leaving that for a follow-up).

Un-Ignore MdkWelcomeInteropTest.testBobDecryptsMdkApplicationMessagesFromAlice
— Amethyst now decrypts and verifies each of the three openmls-authored
application messages against Alice's committer leaf.

https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
2026-04-21 12:49:01 +00:00
Claude 91f5d21cc3 test(marmot): MDK-authored Welcome interop vector + decryptor
Add a Rust generator under quartz/tools/mdk-vector-gen that emits a
Welcome, joiner KeyPackage, committer signer_pub, joiner's three
private keys, and a post-join MLS-Exporter KAT, using the same
openmls 0.8 backend MDK / whitenoise use. Commit its output at
quartz/src/commonTest/resources/mls/mdk-welcome.json.

MdkWelcomeInteropTest consumes the vector and proves Amethyst can:
  - parse a KeyPackage OpenMLS authored and verify its self-signature;
  - run the joiner's processWelcome end-to-end (HPKE unwrap of group
    secrets, welcome-key derivation, AEAD GroupInfo decrypt, ratchet
    tree decode, signer leaf lookup, GroupInfoTBS Ed25519 verify);
  - derive MLS-Exporter("marmot", "group-event", 32) byte-for-byte
    identically to openmls — the exact exporter Marmot uses for the
    outer ChaCha20 wrap on kind:445 events.

testBobDecryptsMdkApplicationMessagesFromAlice is @Ignored with a
detailed TODO because Amethyst's MlsGroup.encrypt/decrypt skip the
RFC 9420 §6.3.1 PrivateMessageContent framing (application_data<V> +
FramedContentAuthData + padding). Amethyst ↔ Amethyst works (both
sides omit it) but every cross-implementation PrivateMessage fails.
Tracked as a follow-up.

https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
2026-04-21 05:04:41 +00:00
Claude d7114fc6e2 fix(marmot): align MLS extension IDs and sender-data sample size with RFC 9420
Three latent spec divergences that only surfaced once we fed the Marmot
stack with a Welcome authored by openmls 0.8 (the MLS backend under MDK
/ whitenoise):

* ratchet_tree extension type was 0x0001 (RFC 9420 §13.3 assigns 0x0001
  to application_id; ratchet_tree is 0x0002). Welcomes and GroupInfos
  Amethyst emitted were unreadable to any spec-compliant peer, and
  Welcomes from peers couldn't be joined because the tree lookup failed.
* external_pub extension type was 0x0003 (that slot is
  required_capabilities; external_pub is 0x0004). External commits would
  have failed interop the same way.
* sender-data ciphertext_sample used AEAD.Nk (16 B) bytes of the
  ciphertext; RFC 9420 §6.3.2 mandates KDF.Nh (32 B). Every
  cross-implementation PrivateMessage would fail sender-data AEAD.

Amethyst ↔ Amethyst round-trip tests passed because both sides were
wrong the same way — the existing MlsConformanceTest hard-coded the
wrong extension IDs, so it even asserted the broken invariant.
Conformance test assertions updated to the spec-correct values.

https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
2026-04-21 05:04:25 +00:00
Claude fec6a7bba4 test(marmot): tighten MLS interop with IETF crypto vectors
Existing interop tests covered wire-format round-trips but never actually
ran ciphertext from IETF/OpenMLS/mls-rs test vectors through our crypto
code paths. That left two major spec bugs unverified by CI — the wrong
HPKE Welcome-context and the wrong "eae_prk" DHKEM label — because
both sides of every round-trip were wrong the same way.

Tighten coverage with cross-implementation KATs:

CryptoBasicsInteropTest
  + testEncryptWithLabelDecryptsIetfVector: decrypt the IETF
    encrypt_with_label vector directly (kem_output + ciphertext), not
    just our own round-trip output.

WelcomeInteropTest
  + testX25519Rfc7748Vectors: RFC 7748 §6.1 X25519 KATs.
  + testX25519DhMatchesJavaXdh: compare against JDK 11+ XDH on the IETF
    encrypt_with_label priv/kem_output.
  + testWelcomeInitKeyMatchesPrivateKey: init_pub == publicFromPrivate
    (init_priv) for every IETF welcome vector.
  + testIetfKeyPackageSelfSignatureVerifies: exercises Ed25519 +
    SignContext + LeafNode TLS encoding against OpenMLS output.
  + testIetfWelcomeFullDecryptAndGroupInfoSignature: end-to-end path
    through HPKE GroupSecrets decrypt → welcome_key derivation → AEAD
    GroupInfo decrypt → GroupInfoTBS Ed25519 verify against signer_pub.
  + testPassiveClientWelcomeDecryptsGroupInfoAndFindsLeaf: same
    end-to-end path on passive-client-welcome.json, plus ratchet-tree
    leaf lookup and signer-leaf GroupInfo verify. Skips vectors with
    external_psks (we don't model PSK secret derivation here) and with
    no ratchet tree (delivery-service lookup).

The TreeKEM UpdatePath KAT was considered but needs the full direct
path / copath / resolution / LCA plumbing from MlsGroup.processCommit
to pick which HPKE ciphertext a given leaf should decrypt. Not worth
the duplication in a test — the "UpdatePathNode" HPKE path now has KAT
coverage indirectly via the IETF encrypt_with_label vector above
(same HPKE stack, different label).

https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
2026-04-21 04:22:08 +00:00
Claude fa24f1c9a2 fix(marmot): use "eae_prk" label in DHKEM ExtractAndExpand
RFC 9180 §4.1 specifies DHKEM derives the shared secret as:

    eae_prk       = LabeledExtract("", "eae_prk", dh)
    shared_secret = LabeledExpand(eae_prk, "shared_secret",
                                  kem_context, Nsecret)

Hpke.extractAndExpand was using "shared_secret" for both the Extract and
Expand labels, so every HPKE decapsulation produced a key schedule that
disagreed with every spec-compliant implementation (OpenMLS, MDK/
whitenoise, OpenSSL, BoringSSL, Java XDH). Round-trips within Amethyst
worked because both sides were wrong the same way; cross-implementation
Welcome HPKE decryption always failed with AEAD BAD_DECRYPT.

Add IETF-vector interop tests locking in the fix:
- decrypt the crypto-basics encrypt_with_label KAT end-to-end,
- decrypt GroupSecrets from a passive-client Welcome vector,
- X25519 DH matches RFC 7748 §6.1 and Java XDH on a KAT input,
- init_pub in each Welcome vector matches publicFromPrivate(init_priv).

Drop the stale comment in CryptoBasicsInteropTest that claimed the vector
decryption was impossible due to an "X25519 DH discrepancy" — the DH was
always fine; the eae_prk label was the bug.

https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
2026-04-21 03:56:03 +00:00
Claude 38b0c681d1 fix(marmot): pass encrypted_group_info as HPKE context for Welcome
RFC 9420 §12.4.3.1 specifies the HPKE context for encrypted_group_secrets
must be the encrypted_group_info field of the Welcome:

    encrypted_group_secrets = EncryptWithLabel(init_key, "Welcome",
                                encrypted_group_info, group_secrets)

Both our buildWelcome and processWelcome were passing an empty context,
which let Amethyst↔Amethyst round-trips work, but made Welcome messages
sent by RFC-compliant implementations (MDK/OpenMLS, used by whitenoise)
fail with BAD_DECRYPT inside the HPKE AEAD open.

https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
2026-04-21 03:28:12 +00:00
Vitor Pamplona f859449aba Merge pull request #2470 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-20 23:11:53 -04:00
Vitor Pamplona 4d3ebb51ba Merge pull request #2471 from vitorpamplona/claude/debug-group-event-handler-DEcD1
Fix MLS group state divergence and outer-layer decryption issues
2026-04-20 23:11:45 -04:00
Claude 96cedcbc9b chore(marmot): demote benign inbound failures from WARN to DEBUG
Two kinds of noisy warnings appear every time an invitee or a
restarted client catches up with events still sitting on the relays.
Both are actually expected, per-spec behavior — not errors.

(a) Outer ChaCha20-Poly1305 decrypt fails with "current and N
    retained epoch key(s)".

    A newly-joined member processes the three kind:445s that
    advanced the group to their current epoch (add-me-commit plus
    any earlier commits). The commits were outer-encrypted with
    keys that predate their Welcome, so they never held them — this
    is MLS forward secrecy working as intended. No state on the
    receiver's side actually needs to update: they're already at
    the post-Welcome epoch.

(b) "NO matching KeyPackageBundle for eventId=…" after app restart.

    The gift-wrapped Welcome (kind:1059) is still on the relay and
    gets redelivered on every subscription refresh. The client's
    `processedEventIds` dedup set is in-memory, so after a restart
    the Welcome flows all the way to `processWelcome`, which then
    fails to find the KeyPackage bundle because it was consumed and
    marked during the first processing.

Both of these fire every time a member opens the app, so the logs
currently look like something is broken even when everything is
behaving correctly.

Fix:

- Add `GroupEventResult.UndecryptableOuterLayer(groupId,
  retainedEpochCount)`. `MarmotInboundProcessor.processGroupEvent`
  and `applyCommit` now call the new `tryDecryptOuterLayer` (which
  returns `ByteArray?` instead of throwing) and surface this
  variant when every available key fails. `GroupEventHandler.add`
  logs it at DEBUG with a one-liner noting "likely from before our
  join".

- Add `WelcomeResult.AlreadyJoined(nostrGroupId)`. When
  `MarmotInboundProcessor.processWelcome` is called with a
  `hintNostrGroupId` we're already a member of, short-circuit
  before the KeyPackageBundle lookup and return `AlreadyJoined`.
  `processMarmotWelcomeFlow` logs at DEBUG.

- `MarmotManager.processGroupEvent` treats `UndecryptableOuterLayer`
  as a no-op for subscription-timestamp updates (same as
  Duplicate/Error/CommitPending), so a unreadable past-epoch event
  doesn't move the group's `since` forward.

No behavioral changes beyond logging level and the short-circuit of
a Welcome we already processed (which was throwing and returning
Error before). Existing callers that only care about successful
`Joined` / successful `CommitProcessed` / `ApplicationMessage`
branches are unaffected — the two new variants join the
`Duplicate` / `CommitPending` / `Error` quiet side of the sealed
class.

https://claude.ai/code/session_014zfdNeeKAfU1zGGyFw4bUL
2026-04-21 02:56:52 +00:00
Claude 7ee5f64fd8 fix(marmot): reject past/future-epoch commits instead of partially applying
After a group creator restarts the app, their own relay-echoed
kind:445 commit events (add-member, updateGroupExtensions, etc.) stop
being filtered by `inboundProcessor.processedEventIds` — that
in-memory dedup set doesn't survive process death. The echo then:

1. Outer-decrypts via a retained epoch key (we still hold those).
2. Gets parsed as a `PublicMessage` with `contentType = COMMIT`.
3. Flows into `applyCommit` → `groupManager.processCommit` →
   `group.processCommit`.

`MlsGroup.processCommit` is not atomic: it runs proposal application,
transcript-hash recomputation, epoch advance, key-schedule derivation,
and SecretTree rebuild **in place** before reaching the final
confirmation-tag check. If that check fails — which it always does
for an already-applied commit, because the tag was computed against
the original epoch's confirmation key and we're now several epochs
past — the exception fires after the mutations, leaving the group
context at a bogus "epoch + 1" with a completely new exporter
secret.

Net result on the wire: the creator's next outbound kind:445 is
encrypted with this rogue exporter secret. Every other member is at
the real epoch, so they all log "Outer decryption failed with current
and N retained epoch key(s)" and no one receives the message. The
creator's own self-echo still decrypts because his sentKeys /
self-state are internally consistent; only external observers are
cut off.

Fix: in `MarmotInboundProcessor.applyCommit`, after parsing the
PublicMessage header but **before** calling `processCommit`, compare
`pubMsg.epoch` with the current local `group.epoch`.
- `pubMsg.epoch < current` → commit is from the past (either our own
  already-applied echo or a stale relay replay). Return
  `GroupEventResult.Duplicate` and don't touch local state.
- `pubMsg.epoch > current` → we're behind; the inbound pipeline
  upstream needs to feed us the intervening commits in order. Return
  an error describing the gap without touching local state.
- Otherwise → proceed to `processCommit` as before.

Making `processCommit` itself atomic would be nicer, but is a much
larger refactor touching tree mutations, key-schedule state, and
per-sender ratchets. The epoch check catches the production scenario
at the right boundary — we never call into `processCommit` with
bytes that we know won't apply cleanly.

Regression test:
- `testCreatorDoesNotDoubleApplyOwnCommitAfterRestart`: David creates
  a group, adds Eden and Fred, persists. Simulates David's app
  restart by rebuilding the `MlsGroupManager` from the store. Then
  feeds David's own add-Eden commit back through the inbound
  pipeline — exactly what a relay echo would deliver. Asserts the
  result is `Duplicate`, and that David's epoch and exporter secret
  are unchanged afterwards. Before the fix, his epoch advances by
  one and his exporter secret drifts, which is exactly what the
  production logs showed.

- `testCreatorCanSendMessageAfterRestart`: save/restore round-trip
  sanity check that the restored manager's exporter secret matches
  what the members still at the same epoch hold, and that the first
  post-restart message from the creator is decryptable by an
  un-restarted member.

https://claude.ai/code/session_014zfdNeeKAfU1zGGyFw4bUL
2026-04-21 02:31:12 +00:00
Claude 621fe9c8c5 fix(marmot): derive SecretTree leaf secrets by walking down from root
Fred (third member of a three-member group, `leafIndex = 2`) crashed
with `StackOverflowError` on ART / NPE on JVM the moment he tried to
send his first application message. The recursion / null is in
`SecretTree.getNodeSecret`, which assumes `nodeIndex` is always a
direct child of `BinaryTree.parent(nodeIndex)`:

    val parentIdx = BinaryTree.parent(nodeIndex, BinaryTree.nodeCount(leafCount))
    val parentSecret = getNodeSecret(parentIdx)
    val leftIdx = BinaryTree.left(parentIdx)
    val rightIdx = BinaryTree.right(parentIdx)
    treeSecrets[leftIdx] = leftSecret
    treeSecrets[rightIdx] = rightSecret
    return treeSecrets[nodeIndex]!!

That invariant holds only for power-of-two trees. For Fred (leafCount
= 3, nodeCount = 5), `BinaryTree.parent(4, 5)` walks via
`parentInRange(5, 5)` and returns **3** (the root) — skipping the
virtual intermediate node 5 that would normally sit between node 4
and the root in a 4-leaf tree. But `left(3) = 1` and `right(3) = 5`;
neither is 4. The cache fills entries 1 and 5 from the root's secret,
node 4's entry is never populated, and the final
`treeSecrets[nodeIndex]!!` throws NPE. On Android the error path
instead re-enters `getNodeSecret` until the stack is exhausted.

The fix walks **down** from the root to the target leaf, picking
left or right at each step based on `targetNode < currentNode`
(holds in left-balanced MLS trees) and using `BinaryTree.left` /
`BinaryTree.right` which correctly descend through virtual
intermediate nodes. The node-level cache is no longer needed —
`getOrInitSender` caches the per-sender ratchet state once, so
re-deriving the leaf secret each time a new sender is initialized
costs a handful of HKDF calls at worst.

Side effects (all in the right direction):

- Re-enabled 6 previously `@Ignore`d tests in
  `MlsGroupLifecycleTest` and `MlsGroupEdgeCaseTest` that exercised
  exactly the three-member / non-full-tree path this fix addresses:
  `testThreeMemberGroup_SequentialAdditions`,
  `testExternalJoin_ZaraJoinsViaGroupInfo`,
  `testExternalJoin_ExporterSecretsAgree`,
  `testSigningKeyRotation_EpochAdvances`,
  `testEncryptDecryptAfterSigningKeyRotation`,
  `testPskProposal_EpochAdvancesWithPsk`. They now pass.

- `testEmptyCommit_AdvancesEpoch` and
  `testMultipleEpochTransitions_EncryptDecryptStillWorks` remain
  `@Ignore`d — they hit a **different** pre-existing bug where
  Alice's and Bob's exporter secrets diverge after a no-proposal
  commit (commit with only an UpdatePath). Unrelated to the
  non-full-tree fix; tracked separately. I retagged them with an
  explanatory note instead of the old "see
  testThreeMemberGroup_SequentialAdditions" reference.

- Fixed an unrelated typo in
  `testMultipleEpochTransitions_EncryptDecryptStillWorks` that
  asserted `7L` and then `6L` for the same epoch value.

Regression test:
- `testFredEncryptsAfterJoiningThreeMemberGroup` creates a
  three-member group via the production path (Alice adds Bob, then
  adds Fred; Bob processes the add-Fred commit), then has Fred
  encrypt an application message and asserts both Alice and Bob can
  decrypt it. Without the fix this throws NPE / stack-overflows at
  `encrypt()`.

https://claude.ai/code/session_014zfdNeeKAfU1zGGyFw4bUL
2026-04-21 01:49:05 +00:00
Crowdin Bot 76da39cc2a New Crowdin translations by GitHub Action 2026-04-21 01:12:52 +00:00
Vitor Pamplona 32dcdde0cc Merge pull request #2469 from vitorpamplona/claude/add-zaps-to-chat-n1wsc
Add live stream top zappers leaderboard and NIP-53 event types
2026-04-20 21:11:00 -04:00
Claude 900d953fb1 fix(live-chat): keep Top Zappers chips the same height when anonymous
The "Anonymous" chip rendered without a 24dp UserPicture, so its pill
shrank to the text's height and broke horizontal alignment with the
avatar chips in the row. Give the chip row a minimum height equal to
the avatar size so text-only variants stay visually aligned.

https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi
2026-04-21 01:05:52 +00:00
Claude b039543807 fix(marmot): revert snapshot-based stage/merge; capture pre-commit key in CommitResult
The snapshot/restore machinery added in the previous commit
(ca988b7) to implement MDK-style `stageCommit` + `mergeStagedCommit`
introduced a tree-rebuild path (RatchetTree.decodeTls followed by a
fresh SecretTree construction) that, in production, caused a
recursive StackOverflowError in `SecretTree.getNodeSecret` during
encryption after a second add-member cycle. The unit test flow did
not exercise it and couldn't reproduce.

Keep the underlying fix — outer-encrypt outbound kind:445 commits
with the PRE-commit (epoch-N) exporter secret — but deliver it with
far less machinery:

- `CommitResult.preCommitExporterSecret` now carries the
  `MLS-Exporter("marmot", "group-event", 32)` value captured inside
  `MlsGroup.commit()` BEFORE any state mutation. It's the key
  existing members still at epoch N hold, so the outer kind:445
  wrap with it is decryptable to them (RFC 9420 §12.4 + MDK parity).
- `MlsGroup.commit()` captures this exporter once at the top of the
  function, threads it into the returned `CommitResult`.
- `MarmotManager.addMember / removeMember / updateGroupMetadata`
  read `commitResult.preCommitExporterSecret` and pass it to
  `MarmotOutboundProcessor.buildCommitEvent(..., exporterKey=...)`.
  The eager `group.addMember` / etc. continue to advance the local
  epoch immediately — the same behavior as before the previous
  commit, which was proven safe under production load.

Removed:
- `MlsGroup.stageCommit` / `mergeStagedCommit` / `stageAddMember` /
  `stageRemoveMember` and their snapshot/restore helpers.
- `MlsGroupSnapshot` / `StagedCommit` types.
- `MlsGroupManager.stageAddMember` / `stageRemoveMember` /
  `stageRotateSigningKey` / `stageUpdateGroupExtensions` /
  `mergeStagedCommit`.
- `tree` back to `val` in `MlsGroup`.

Kept from the previous commit:
- `MarmotOutboundProcessor.buildCommitEvent(... exporterKey: ByteArray?)`
  with default falling back to `groupManager.exporterSecret(...)`.
- `MlsGroupManager.processCommit` retains epoch secrets AFTER
  successful apply (was a secondary bug polluting the retention
  window with duplicates of the current key when a commit failed).
- `pushRetainedEpoch` helper that accepts a pre-captured
  RetainedEpochSecrets, used throughout instead of the old
  `retainEpochSecrets(nostrGroupId, group)`.

Test updated:
- `testCreatorCanAddTwoMembersAndAllDecryptFollowingMessage` now
  uses the eager `addMember` path and asserts that the member still
  at the old epoch can outer-decrypt with the pre-commit key shipped
  back via `CommitResult`. Also decrypts Alice's self-echo and
  sends multiple follow-up messages — this is the scenario that
  triggered the production stack overflow before the revert.
- `testAddMemberExposesPreCommitExporterSecret` (renamed) confirms
  `CommitResult.preCommitExporterSecret` equals the exporter the
  group had immediately before the call, and that the post-commit
  exporter differs from it (proves we actually rotated).

https://claude.ai/code/session_014zfdNeeKAfU1zGGyFw4bUL
2026-04-21 00:50:29 +00:00
Claude 5705a2b840 fix(marmot): outer-encrypt commits with pre-commit exporter secret
Per RFC 9420 §12.4 and the MDK reference implementation, a kind:445
Commit MUST be outer-encrypted with the epoch-N exporter secret — the
key existing members still hold — so that they can decrypt, process the
commit, and advance to N+1. Amethyst was instead encrypting with the
epoch-(N+1) key because MlsGroupManager.addMember / removeMember /
updateGroupExtensions applied the commit locally *before* handing the
bytes to MarmotOutboundProcessor.buildCommitEvent, which read the
current (post-commit) exporter via groupManager.exporterSecret.

Observed in a 3-party Amethyst-to-Amethyst test (David creates group,
adds Eden then Fred, sends "Hi"): Eden joined at epoch 1 via Welcome,
then "succeeded" in outer-decrypting the add-Eden kind:445 with her
own post-commit key but failed to apply ("Duplicate encryption key:
leaf 1") because she was already in the tree from the Welcome. Eden
never advanced past epoch 1, so Fred's subsequent join and David's
application message were undecryptable. Fred, joining directly at
epoch 2, saw the Hi message fine — matching the symptom where the
last-added member is the only one who can read new messages.

Fix splits commit creation from commit application on the outbound
path, mirroring OpenMLS / MDK's `create_commit` + `merge_pending_commit`
pair:

- MlsGroup gains stageCommit() / mergeStagedCommit(). stageCommit
  captures a full snapshot, runs the existing commit logic (which
  mutates in place), captures the post-commit snapshot, then restores
  the pre-commit snapshot so the caller observes epoch N until merge.
  The returned StagedCommit carries the pre-commit exporter secret
  and the framed commit bytes. stageAddMember / stageRemoveMember
  are thin wrappers that propose + stage.
- MlsGroupManager exposes stageAddMember, stageRemoveMember,
  stageRotateSigningKey, stageUpdateGroupExtensions, and
  mergeStagedCommit. The eager addMember/removeMember/commit/etc.
  entry points are retained (documented as test-only) so the MLS
  unit tests in MlsGroupTest, MlsGroupLifecycleTest, etc. keep
  working unchanged.
- MarmotOutboundProcessor.buildCommitEvent takes an optional
  exporterKey override; callers pass StagedCommit.preCommitExporterSecret.
- MarmotManager.addMember / removeMember / updateGroupMetadata now
  stage → build kind:445 → markEventProcessed → mergeStagedCommit.
- MlsGroupManager.processCommit was also retaining epoch secrets
  *before* calling group.processCommit, so a failed commit (such as
  the add-me relay echo that the new member can't apply) polluted
  the retained window with a duplicate of the current epoch key.
  Now retain only on success.

Regression tests:
- testStagedAddMemberUsesPreCommitExporter: asserts stage does not
  advance the epoch and that preCommitExporterSecret equals the
  pre-stage exporter output.
- testCreatorCanAddTwoMembersAndAllDecryptFollowingMessage: end-to-end
  David/Eden/Fred reproduction — creator adds two members in sequence
  and publishes an application message; both members must decrypt it.
  Fails without the fix, passes with it.

https://claude.ai/code/session_014zfdNeeKAfU1zGGyFw4bUL
2026-04-21 00:50:29 +00:00
Claude 4888d30f6c refactor(live-chat): lift NIP-53 live-stream state into commons
Address the architectural critique from self-review. The leaderboard
math and the shared system card are now platform-neutral primitives in
commons, the on-UI-thread aggregation is gone, and two latent
correctness bugs around subscription lifecycle and zap routing are
fixed.

- Introduce a pure LiveActivityTopZappersAggregator in commons that
  takes plain ZapContribution values and returns a sorted, deduped,
  anon-bucketed top-N list. Covered by a unit-test suite that is
  Compose/Android-independent.
- Introduce LiveStreamTopZappersViewModel in commons/commonMain. It
  owns two partitioned contribution maps (stream-scoped and
  goal-scoped), mutates them under a Mutex on the IO dispatcher in
  response to channel.changesFlow and Note.zaps.stateFlow emissions,
  and publishes the aggregator result via a StateFlow<List<TopZapperEntry>>.
  UI is now a dumb consumer with no aggregation logic left in the
  composable.
- Move StreamSystemCard to commons/commonMain/compose/ so Desktop can
  consume it alongside Android.
- Fix attachZapToLiveActivityChannel to run even when a zap receipt was
  already consumed by another subscription. Previously a zap first seen
  by notifications/profile would never reach the stream's channel
  cache; addNote is already idempotent so this is a safe retro-route.
- Fix ChannelFilterAssemblerSubscription to re-invalidate filters when
  a live-activity channel's metadata arrives. The 30311 stream event
  usually lands after the initial assembler run, so the goal-tag-driven
  subscription never fired. Now it re-runs as metadata resolves and the
  goal id is discovered.

https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi
2026-04-21 00:20:01 +00:00
Claude e12b6b4172 feat(live-chat): surface stream clips in the profile gallery tab
Clips are authored by viewers but carry a `p` tag identifying the
stream host, so they naturally belong on the host's profile. Extend
the profile media subscription with `{kinds:[1313], #p:[pubkey]}`,
accept LiveActivitiesClipEvent in UserProfileGalleryFeedFilter when
the clip references a stream hosted by this user, and render the clip's
`r`-tag URL as a MediaUrlVideo tile in GalleryThumbnail.

Also plug LiveActivitiesClipEvent into NoteCompose via the existing
RenderChatClip so tapping a clip tile opens a working thread view
instead of an empty one.

https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi
2026-04-20 23:33:55 +00:00
Claude 766274d0b2 refactor(live-chat): polish NIP-53 chat renderers from self-review
- Extract a shared StreamSystemCard composable for the rounded accent
  container used by zap, raid, and clip cards so they share one visual
  language.
- Unify clip caption rendering through CrossfadeToDisplayComment so
  captions get the same NIP-19 / emoji treatment as zap and raid
  messages.
- Iterate every `a` tag when routing zap receipts into live-activity
  channels so a receipt referencing multiple simulcasted streams lands
  in each of them, and fold the host match into the same pass.
- Bucket anonymous and private zaps under a single sentinel key in the
  Top Zappers aggregator so an "Anonymous" chip shows once with the
  combined total instead of one chip per one-time pubkey.
- Add commonTest coverage for LiveActivitiesRaidEvent marker parsing,
  LiveActivitiesClipEvent tag parsing, and LiveActivitiesEvent.goalEventId().

https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi
2026-04-20 23:29:05 +00:00
Claude 003603fd81 feat(live-chat): top zappers leaderboard above live stream chat
Add a horizontally-scrollable strip of pill chips showing the top
zappers on a live stream, placed above the goal header. Each chip has
an avatar, lightning icon, and compact sat total (K/M), matching
zap.stream's TopZappers look. Tapping a chip opens the zapper's
profile.

Aggregates zaps from two sources and dedupes by receipt id so a zap
that tags both #a=<stream> and #e=<goalId> doesn't double-count:
  - stream-scoped host-directed zaps already routed into the
    LiveActivitiesChannel cache via the existing #a subscription
  - goal-scoped zaps reachable through goalNote.zaps when a NIP-75
    goal is attached

Sorts contributors by total sats desc, takes top 10, hides the row
when there are none.

https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi
2026-04-20 23:09:10 +00:00
Vitor Pamplona 00b9d36d5d Fixing spacing in the bottom bar for chat screens 2026-04-20 18:13:20 -04:00
Claude 43435427b2 feat(live-chat): show NIP-75 zap goal at top of live stream chat
Add a goalEventId() accessor on LiveActivitiesEvent that reads the
zap.stream `["goal", "<hex>"]` tag. When a stream channel's 30311 event
references a goal, fetch the kind 9041 event and its zap receipts
(#e=<goalId>) so the existing goal-progress aggregation works.

Render a compact LiveStreamGoalHeader above the chat feed showing the
goal title, the existing GoalProgressBar, and a one-tap ZapReaction
that targets the goal event so viewers can contribute directly to the
fundraiser without leaving the stream.

https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi
2026-04-20 22:10:54 +00:00
Claude 8368c9d661 feat(live-chat): display live stream clips (kind 1313) inline
Add a Quartz LiveActivitiesClipEvent for zap.stream's kind 1313 clip
convention, parsing the `a` tag (stream address), `p` tag (host), `r`
tag (video URL), and `title` tag. Register it in EventFactory, subscribe
in the live-activity chat filter, and route clips into the source
LiveActivitiesChannel cache. Render clips as an accent-colored card
showing "X created a clip" with the title and an inline video player
for the clip's playable URL.

https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi
2026-04-20 21:53:02 +00:00
Vitor Pamplona e1c30ec5d7 Merge pull request #2468 from vitorpamplona/claude/nip72-communities-screen-TwEMq
Add community creation and management UI with NIP-72 support
2026-04-20 17:43:49 -04:00
Claude 81ab25fbbc fix(communities): always show Save on the edit screen
The edit/create top bar was keyed on model.isEditing(), which reads
existingDTag set by loadFrom(). loadFrom runs in a LaunchedEffect after
the first composition, so the first render of the edit screen briefly
showed the "Create" button before flipping to "Save".

Derive the top bar from the `editing: Address?` parameter instead — it
is known synchronously at composition, so edit mode shows "Save" on
frame one.
2026-04-20 21:40:27 +00:00
Claude 4786647762 feat(live-chat): display live stream raids (kind 1312) inline
Add a Quartz LiveActivitiesRaidEvent for zap.stream's kind 1312 raid
convention with root/mention a-tag markers. Wire it into the event
factory, subscribe to it in the live-activity chat filter, and route
received raids into both source and target LiveActivitiesChannel caches
so viewers on either side see the notification. Render raids as an
accent-colored pill showing "X is raiding Y" that navigates to the
target stream when tapped.

https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi
2026-04-20 21:29:50 +00:00
Claude 868e674a1c perf(communities): stop rebuilding the card on every reaction/zap
RenderCommunitiesThumb was observing the full NoteState, which
recomposes on every reaction, zap, boost or metadata tick. Each
recomposition rebuilt the CommunityCard, re-parsing the definition
tags and calling moderatorKeys().toImmutableList() — producing a
brand-new ImmutableList instance. That new list identity then
invalidated LaunchedEffect(key = moderators) in LoadModerators,
re-running the expensive ParticipantListBuilder traversal (author +
all replies + boosts + zaps + reactions + public-chat notes) per
tick.

Fixes:
- Switch to observeNoteEvent<CommunityDefinitionEvent> so the
  thumb only recomposes when the definition event itself changes
  (including edits). Reactions/zaps are handled separately inside
  LikeReaction/ZapReaction.
- remember(event.id) { CommunityCard(...) } so the card and its
  moderator list identity are allocated once per event version.
- Stabilize LoadModerators' LaunchedEffect with (baseNote.idHex,
  moderators). Convert the hosts list to a Set for O(1) filtering,
  reuse a single ParticipantListBuilder instance, and skip the
  redundant .minus(hosts) call on the new set.
2026-04-20 21:25:30 +00:00
Claude 30646998ea feat(communities): prefer name() tag + add edit flow
Name display
- CommunityCard, CommunityName spinner option, FeedFilterSpinner
  renderer, and DisplayFollowingCommunityInPost now all prefer the
  NIP-72 `name` tag and fall back to the `d` tag. Previously they
  rendered the raw UUID d-tag for communities created by this app,
  which made them show up as /n/<hex> instead of the configured name.
- DisplayCommunity is now reactive via observeNote so the chip in
  NoteCompose recomposes when the community definition arrives.

Edit flow
- New Route.EditCommunity(kind, pubKeyHex, dTag) and
  EditCommunityScreen that reuses the create form via a shared
  CommunityFormScreen composable.
- NewCommunityModel gains loadFrom(CommunityDefinitionEvent) to
  preload name/description/rules/moderators/relays and to remember
  the existing d-tag + image URL. publish() reuses the existing
  d-tag so the replaceable kind-34550 event updates in place.
- Cover preview on the form supports the already-uploaded image
  (AsyncImage with remove button) and falls back to the placeholder
  if cleared.
- Owner (note author == signer) sees an "Edit" FilledTonalIconButton
  in LongCommunityActionOptions that opens the edit screen.
- Top bar becomes "Edit Community" with a Save button when editing.
2026-04-20 21:05:06 +00:00
Vitor Pamplona 942fcf4315 Merge pull request #2467 from greenart7c3/claude/video-autoplay-setting-dY2Cd
Add separate autoplay videos setting independent of video loading
2026-04-20 17:03:46 -04:00
Vitor Pamplona 0f85c4e9fb Merge pull request #2466 from vitorpamplona/claude/fix-marmot-add-member-j5dx2
Fix MLS commit framing for wire protocol compliance
2026-04-20 16:47:44 -04:00
Claude 238c9ea626 feat(live-chat): display zap receipts inline in live stream chat
Subscribe to kind 9735 alongside kind 1311 against the stream's #a tag,
route host-directed zaps into the LiveActivitiesChannel cache, and render
them as a centered, lightning-accented row with the zapper, amount, and
optional zap message. Matches zap.stream's chat behavior for
NIP-53 + NIP-57 interop.

https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi
2026-04-20 20:47:12 +00:00
Claude f9214daba2 fix(marmot): frame outbound commits as MlsMessage(PublicMessage(...))
addMember/removeMember/updateGroupMetadata were publishing the raw TLS
Commit struct inside the kind:445 outer ChaCha20 layer, so receivers
that started with MlsMessage.decodeTls read the first two bytes of the
Commit as the MLS protocol version and aborted with "Unsupported MLS
version: <garbage>" (e.g. 16704 = 0x4140).

Wrap commits produced by MlsGroup.commit in a PublicMessage carrying
the sender leaf index and confirmation_tag, then in an MlsMessage
envelope, and expose those bytes via CommitResult.framedCommitBytes so
MarmotManager uses them instead of the raw commit bytes. The internal
CommitResult.commitBytes field stays raw so MlsGroup.processCommit and
existing tests that exercise it directly keep working.

Also mark the committer's own published kind:445 as already processed
in MarmotInboundProcessor so that the relay echo of our own commit is
treated as a duplicate rather than re-applied on top of the already-
advanced local epoch.

https://claude.ai/code/session_01NR6JgYRimVb142T2nkChuh
2026-04-20 20:45:32 +00:00
Claude 9e9fd7394e feat(settings): add autoplay videos toggle and clarify video loading description
Introduces a dedicated "Autoplay Videos" (Always/Never) preference that
controls whether videos start playing automatically when visible in the
feed. The pre-existing "Video Playback" preference actually gates
auto-downloading, so its description is corrected to say "Automatically
load videos and GIFs".

https://claude.ai/code/session_01T4MHiQHK6moNu8e6Rx3Nao
2026-04-20 20:38:45 +00:00
Vitor Pamplona 71a29aae57 Fixes the reversal of the issue when the FAB becomes unclickable 2026-04-20 16:29:25 -04:00
Claude 0f84981879 feat(communities): redesign New Community screen
- Large tap-to-pick cover image at the top (Badge-style): shows an
  aspect-16:9 placeholder that opens the gallery, uploads to the user's
  chosen media server, and previews via ShowImageUploadGallery.
- Moderators section: lists the current user as pinned "Owner" plus
  each added moderator (picture + display name + NIP-05) with a
  remove icon. The add field is powered by ShowUserSuggestionList so
  names, npubs and NIP-05s all search live.
- Relays section: each row uses BasicRelaySetupInfoClickableRow so
  users see the full relay stats (icon, users, event counts, status)
  just like the AllRelayList screen. Below each row a FilterChip group
  sets the NIP-72 marker (any / author / requests / approvals).
  A RelayUrlEditField at the bottom adds new relays with live search
  suggestions.
- Reworked NewCommunityModel: dedicated mutableStateListOf for
  moderators and relays, proper image upload via MultiOrchestrator,
  CommunityRelayEntry data class, publish() now uploads then signs
  kind 34550 and follows it (so the new community appears in "Mine").
- Updated strings and localized resources.
2026-04-20 20:28:09 +00:00
Vitor Pamplona c9880a689d Merge pull request #2465 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-20 16:10:28 -04:00
Crowdin Bot 00ed676c6b New Crowdin translations by GitHub Action 2026-04-20 20:08:38 +00:00
Vitor Pamplona 1d91218455 Merge pull request #2463 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-20 16:07:38 -04:00
Vitor Pamplona 617fb38332 Merge pull request #2464 from vitorpamplona/claude/marmot-invite-event-N55Yh
Add daemon diagnostics and heartbeat logging to invite polling
2026-04-20 16:07:07 -04:00
Claude de78bceeae chore(marmot-interop): diagnostics for invite-delivery failures
Adds a dump_daemon_diagnostics helper (relays per type, wn debug health,
relay-control-state, pending invites, wnd stderr tail) and wires it into
Test 02 and Test 04 on both sides of the poll (pre-invite baseline +
post-timeout). wait_for_invite now prints a ~10s heartbeat with the
pending-invite count and the most recent welcome/giftwrap line from wnd
stderr. On timeout the script also prompts the operator to paste the
Amethyst-side MarmotDbg logcat so both ends of the gift-wrap pipe end up
in one log file.
2026-04-20 20:01:51 +00:00
Crowdin Bot da3d9cf0d7 New Crowdin translations by GitHub Action 2026-04-20 19:29:53 +00:00
Vitor Pamplona 6e61653bca Merge pull request #2462 from vitorpamplona/claude/marmot-interoperability-check-vR5ws
MIP-01/MIP-05 compliance: VarInt encoding, validation, and MDK interop
2026-04-20 15:28:33 -04:00
Vitor Pamplona a01cbbf2e1 Merge pull request #2461 from davotoula/claude/hls-resolution-selection-jzBTG
HLS video: Lowest resolution in feed/PiP, auto resolution in full screen
2026-04-20 15:19:30 -04:00
davotoula cb4a987498 refactor(video): apply code review fixes 2026-04-20 20:51:40 +02:00
davotoula 830e410a79 feat(video): default feed/PiP to lowest HLS resolution, fullscreen to auto 2026-04-20 20:51:14 +02:00
Claude 77c3634745 fix: tighten Marmot MIP-00/01/02/05 + RFC 9420 PublicMessage framing
Follow-up to the earlier MIP-spec alignment commit, addressing the
remaining audit items:

- MIP-00 (KeyPackageUtils.isValid): now performs every strict tag-level
  check that MDK does — d tag is exactly 64 hex chars, mls_protocol_version
  is exactly "1.0", mls_ciphersuite is exactly "0x0001", mls_extensions
  contains both 0xf2ee + 0x000a, mls_proposals contains 0x000a, encoding
  is "base64", content non-empty, i tag present. Adds
  `isCryptographicallyValid` for deep checks: i tag matches the computed
  KeyPackageRef (RFC 9420 §5.2), Basic credential identity equals the
  event's pubkey, KeyPackage signature verifies.

- MIP-01 (MarmotGroupData): encoder now omits the v3-only
  `disappearing_message_secs` field when version < 3, so v2 output
  matches MDK's `tls_codec` 0.4 byte-for-byte. Adds byte-level fixture
  tests pinned to the Rust reference (encode + decode in both directions).

- MIP-02 (WelcomeGiftWrap): replaces the redundant sign-then-rumorize
  step with `RumorAssembler.assembleRumor`, producing a true unsigned
  rumor as NIP-59 requires (no wasted secp256k1 signature).

- MIP-05 kind 449 (TokenRemovalEvent.build): no longer accepts a tag
  initializer. Per the MIP-05 spec the token-removal event MUST have
  empty tags; allowing arbitrary caller-supplied tags risks metadata
  leakage and rejection by strict validators (e.g. the MDK reference).

- RFC 9420 §6 PublicMessage framing: encoder/decoder now branch on
  `contentType`. Application messages keep the `opaque<V>` length prefix;
  Proposal/Commit bodies are emitted/parsed as their typed structs (no
  outer length prefix), per the MLS RFC. Previously encoder used
  `putBytes` raw and decoder used `readOpaqueVarInt`, so neither could
  roundtrip and Proposal/Commit PublicMessages from other MLS clients
  would mis-parse.

- KeyPackageUtilsTest fixtures: switch tiny "0"/"1"/"lr" slot ids to
  realistic 64-char hex slot ids, since `isValid` now enforces the
  MIP-00 d-tag format.
2026-04-20 18:02:12 +00:00
Claude 30a78c5922 feat: NIP-72 compliance fixes + standalone Communities screen
Quartz (NIP-72 compliance)
- Fix CommunityDefinition image() builder to use the NIP-72 ImageTag
  (was wrongly pointing at the NIP-23 ImageTag, which prevented the
  builder from attaching `<W>x<H>` dimensions).
- Fix CommunityPostApproval notifyAuthor() to emit a `p` tag for the
  post author as required by NIP-72 (was duplicating the `e` tag).
- Add RelayTag.MARKER_AUTHOR/REQUESTS/APPROVALS constants.

Amethyst - Communities screen
- New top-level Communities screen with FeedFilterSpinner TopNav that
  supports the Mine option (filters to communities authored by the
  logged-in user).
- New Route.Communities + drawer entry.
- New CommunitiesFeedFilter (subclass of DiscoverCommunityFeedFilter
  using defaultCommunitiesFollowList + Mine handling).
- New CommunitiesListFilterAssembler/SubAssembler with dedicated
  filterCommunitiesMine relay query.
- Account.liveCommunitiesFollowLists + settings.defaultCommunitiesFollowList.
- TopNavFilterState.communityRoutes (includes Mine).

Amethyst - Create Community
- Route.NewCommunity + FAB on the Communities screen.
- NewCommunityModel + Material3 form: name, description, image URL,
  rules, moderator pubkeys, relay requests/approvals URLs.
- Signs a kind 34550 CommunityDefinitionEvent and auto-follows it so
  it appears under "Mine".
- Account.sendCommunityDefinition helper.

Misc
- Extracted parseTopFilterOrDefault helper in LocalPreferences to keep
  the existing inner load method under the JVM method-size limit.
2026-04-20 17:56:18 +00:00
Vitor Pamplona c7b8d178f5 Merge pull request #2460 from vitorpamplona/claude/fix-empty-groups-display-MO6E9
Show placeholder notes for empty Marmot groups in chat list
2026-04-20 13:15:26 -04:00
Claude aea1dc53c3 fix: show empty Marmot groups in Messages screen
Previously ChatroomListKnownFeedFilter dropped any group whose
newestMessage was null, so groups the user just created and groups
received via Welcome events without any activity yet never appeared
in the Messages list.

Emit a stable placeholder Note (carrying the chatroom as a gatherer)
per empty group, route it through the existing Marmot row path, and
invalidate dmKnown on MarmotGroupList.groupListChanges so newly added
or promoted groups rebuild the feed.
2026-04-20 17:08:59 +00:00