The RatchetTree leaf count must be computed from the rightmost non-null
node, not the total serialized node count. Tree-validation vectors
include trailing blank nodes that aren't part of the logical tree.
Test results: 36/41 passing (88%).
Remaining 5 failures:
- EncryptWithLabel: X25519 DH result discrepancy with test vector
- TreeOperations (2): tree_hash mismatch for trees with blank interior
leaf slots (leaf count computation needs tree-topology-aware logic)
- TranscriptHash (2): needs AuthenticatedContent decomposition
https://claude.ai/code/session_01NocQDWj2Y92FugjfgazzL3
The add_proposal field in messages.json contains a raw KeyPackage
(the body of an Add proposal) without the uint16 proposal type prefix.
Fixed the test to decode the KeyPackage directly with round-trip
verification.
Test results: 36/41 passing (88%).
https://claude.ai/code/session_01NocQDWj2Y92FugjfgazzL3
Added encrypt(data, aad) and decrypt(data, aad) overloads to the
AESGCM expect/actual class across all platforms:
- JVM/Android: Uses Cipher.updateAAD() with AES/GCM/NoPadding
- Apple: Uses whyoleg.cryptography encryptWithIvBlocking(iv, data, aad)
- Linux: Same as Apple via whyoleg.cryptography
Updated HPKE aeadSeal/aeadOpen and MlsCryptoProvider aeadEncrypt/aeadDecrypt
to use the AAD-aware methods instead of ignoring the AAD parameter.
The EncryptWithLabel test still fails due to an X25519 DH computation
discrepancy between Python reference and the Quartz JVM implementation.
The HPKE implementation is internally consistent (MlsGroupTest passes).
https://claude.ai/code/session_01NocQDWj2Y92FugjfgazzL3
RFC 9420 Section 7.9 tree hash input includes a type discriminant byte:
- Leaf: H(uint8(1) || uint32(leaf_index) || optional<LeafNode>)
- Parent: H(uint8(2) || optional<ParentNode> || opaque left<V> || opaque right<V>)
Also fixed RatchetTree leaf count computation to use the rightmost
non-blank node position instead of total serialized node count,
since trees may be serialized with trailing blank nodes.
Test results: 35/41 passing (85%).
https://claude.ai/code/session_01NocQDWj2Y92FugjfgazzL3
1. Tree hash (RFC 9420 Section 7.9): Leaf hash now includes
uint32(leaf_index) before optional<LeafNode>. Parent hash wraps
left_hash and right_hash with VarInt-prefixed opaques.
2. Message serialization tests: Fixed Add/Remove proposal tests to
match the messages.json format (Add includes type prefix,
Remove is just uint32 body without type prefix).
Test results: 34/41 passing (83%).
Remaining 7 failures:
- EncryptWithLabel: HPKE AEAD needs AAD support
- Add proposal: KeyPackage decode issue in test data
- Transcript hashes (2): Need AuthenticatedContent parsing
- Tree hash (1): May need per-node hash verification
- Tree operations (2): Tree hash still mismatches after operations
https://claude.ai/code/session_01NocQDWj2Y92FugjfgazzL3
The exporter test vector labels are hex-encoded strings used AS-IS
(as string labels), not decoded from hex to bytes. The test was
incorrectly hex-decoding the label before passing it.
Also removed the now-unused expandWithLabelRaw and ByteArray mlsExporter
overloads since the string-based API is correct for all MLS usage.
Test results: 33/41 passing (80%).
https://claude.ai/code/session_01NocQDWj2Y92FugjfgazzL3
Major interop fixes discovered by IETF test vectors:
1. VarInt migration: All MLS TLS struct serialization now uses
QUIC-style VarInt encoding for opaque<V> and vector<V> fields,
matching OpenMLS and mls-rs wire format. Added readVarInt(),
readOpaqueVarInt(), readVectorVarInt() to TlsReader and
putVectorVarInt() to TlsWriter.
2. SecretTree left/right derivation: Fixed tree secret splitting
to use "left"/"right" as context strings per RFC 9420 Section 9,
instead of byte(0)/byte(1).
3. LeafNode parent_hash: Added parent_hash<V> field for COMMIT
source per RFC 9420 Section 7.2. The COMMIT case is NOT empty -
it includes a parent_hash opaque field.
4. MLS-Exporter: Added ByteArray overload for raw byte labels
(test vectors use non-UTF-8 label bytes).
Test results: 32/41 passing (78%), up from 25/41 (61%).
Newly passing: SecretTree (2), TreeValidation deserialization (1),
TreeValidation resolution (1), TreeKem deserialization (1),
Commit deserialization (1), RatchetTree deserialization (1).
https://claude.ai/code/session_01NocQDWj2Y92FugjfgazzL3
The welcome_secret must be derived from member_secret (the Extract of
joiner_secret and psk_secret), not directly from joiner_secret. This
matches the RFC 9420 key schedule diagram where welcome_secret is
derived after the psk extraction step.
Before: welcome = DeriveSecret(joiner_secret, "welcome")
After: member = Extract(joiner_secret, psk_secret)
welcome = DeriveSecret(member, "welcome")
epoch = ExpandWithLabel(member, "epoch", GroupContext, Nh)
This fix was discovered and validated by the IETF interop test vectors.
Key schedule tests now pass all 11 derived secrets across multiple epochs.
https://claude.ai/code/session_01NocQDWj2Y92FugjfgazzL3
Three encoding bugs found by IETF interop test vectors:
1. ExpandWithLabel: label and context length prefixes must use
QUIC-style variable-length integer encoding (VarInt), not fixed-size
opaque prefixes. Values < 64 use 1 byte, 64-16383 use 2 bytes with
0x40 prefix. This is critical when GroupContext (112+ bytes) is
passed as context.
2. RefHash: label and value also use VarInt-prefixed opaque fields,
matching the MLS TLS codec convention.
3. SecretTree: DeriveTreeSecret must pass the generation counter as a
uint32 big-endian context parameter, not empty context. This affects
key/nonce derivation and ratchet advancement.
Also fixes:
- SignContent and EncryptWithLabel/DecryptWithLabel info encoding
updated to use VarInt
- KeySchedule test updated to use initial_init_secret from test vector
(not hardcoded zeros)
- Added putOpaqueVarInt() to TlsWriter for QUIC-style VarInt encoding
https://claude.ai/code/session_01NocQDWj2Y92FugjfgazzL3
Import the canonical IETF MLS test vectors from mlswg/mls-implementations
to verify wire-format compatibility with OpenMLS and mls-rs. These are
the same test vectors used by both Rust implementations for cross-
implementation interop validation.
Phase 1 (Foundations):
- crypto-basics.json: RefHash, ExpandWithLabel, DeriveSecret,
DeriveTreeSecret, SignWithLabel, EncryptWithLabel
- tree-math.json: Binary tree arithmetic for all tree sizes
- key-schedule.json: Full 12-secret epoch key derivation chain
- secret-tree.json: Per-sender handshake/application ratchet keys
Phase 2 (Wire Format):
- messages.json: Round-trip serialization of all MLS message types
- message-protection.json: PublicMessage/PrivateMessage framing
- transcript-hashes.json: Confirmed/interim transcript hash computation
Phase 3 (TreeKEM):
- treekem.json: UpdatePath processing, path secret derivation
- tree-validation.json: Tree hash and resolution verification
- tree-operations.json: Add/remove/update proposal application
- welcome.json: Welcome message deserialization
Phase 4 (End-to-End Protocol):
- passive-client-welcome.json: Join via Welcome, follow epochs
- passive-client-handling-commit.json: Process varied commit types
- passive-client-random.json: Randomized multi-epoch scenarios
Initial test results reveal ExpandWithLabel encoding discrepancy
in HkdfLabel context field that cascades to most crypto operations.
Tree math tests pass fully. These findings demonstrate the value of
importing standard interop vectors.
https://claude.ai/code/session_01NocQDWj2Y92FugjfgazzL3
Three issues addressed:
1. Make cleanup() exception-safe: wrap each WebRTC dispose call in
its own try-catch so that a failure in one (e.g. native crash
disposing a PeerConnection) does not skip releasing the camera,
audio mode, foreground service, or EGL context.
2. Upgrade the Idle safety net to call cleanup() instead of only
stopping ringing/notifications. If the Ended state is missed due
to StateFlow conflation, the Idle handler now performs full
resource cleanup (camera, WebRTC, audio mode, foreground service,
proximity wake lock). cleanup() is idempotent since all resources
are null-checked and nulled out.
3. Call cleanup() in AccountViewModel.onCleared() so that WebRTC
resources, camera, audio mode, and foreground service are released
when the ViewModel is destroyed (e.g. logout, activity recreation).
4. Clear processedEventIds when transitioning to Idle to prevent
unbounded memory growth across calls.
https://claude.ai/code/session_01GWRdrVAa29BsDkv8R7Z3Y9
StateFlow is conflated — when the state transitions rapidly from
Offering → Connecting → Connected, the collector may skip Connecting
entirely and only see Connected. Since stopRingbackTone() was only
called in the Connecting handler, the ringback tone would keep playing
through the entire call.
Fix: duplicate the ringing/notification cleanup in the Connected handler
so the tone is always stopped regardless of which state the collector
sees first. Also make switchToCallAudioMode() idempotent and clean up
the previous ToneGenerator in startRingbackTone() to prevent leaks.
https://claude.ai/code/session_01GWRdrVAa29BsDkv8R7Z3Y9
The state collector in CallController was blocking on
showIncomingCallNotification() which downloads the caller's profile
picture over the network. Because StateFlow is conflated, if the call
ended (peer hangup, reject, timeout) while the collector was suspended,
the Ended→Idle transition would cause the Ended emission to be lost.
Since cleanup/stopRinging only ran in the Ended handler, the ringtone
and vibration would continue indefinitely.
Two fixes:
1. Launch showIncomingCallNotification in a separate coroutine so the
collector is never blocked by network I/O.
2. Add a safety-net Idle handler that stops ringing, ringback tone,
and cancels the call notification.
https://claude.ai/code/session_01NfyLNgR4d8yjnxaQJ6FUt5
The Offering, Ended, Connected, and PipConnected call states were showing
the logged-in user's picture alongside other call members. IncomingCall and
Connecting already filtered correctly. Now all states consistently exclude
the current user via `- accountViewModel.account.signer.pubKey`.
https://claude.ai/code/session_01PFzKU8Y3nUQXhshA3MT5EM
Replaces the single-PeerConnection architecture with full mesh topology
where each participant maintains one PeerConnection per peer, as
specified by NIP-AC.
WebRtcCallSession: Refactored to a pure PeerConnection wrapper that
accepts a shared PeerConnectionFactory. No longer manages media sources,
tracks, or camera — those are now shared across all peer sessions.
CallController: Manages per-peer sessions via ConcurrentHashMap. Shared
resources (PeerConnectionFactory, EglBase, audio/video sources, camera)
are initialized once and reused. Each peer gets its own WebRtcCallSession
with per-peer ICE candidate routing. Supports callee-to-callee mesh
connections with pubkey-based tie-breaking to avoid ofer glare.
CallManager: New methods for per-peer offer/answer publishing
(publishOfferToPeer, publishAnswerToPeer, beginOffering). Forwards ALL
answers to CallController (not just the first). New callbacks
onNewPeerInGroupCall and onMidCallOfferReceived for mesh setup. Handles
mid-call offers (same call-id) when already in Connecting/Connected state.
AccountViewModel: Updated callback wiring to pass peer pubkey with
answer events and register new group call callbacks.
https://claude.ai/code/session_01J5fJx9YbSBx1BsBMctiAm8
Two bugs caused WebRTC group calls to get stuck in "Connecting":
1. CallController.onLocalIceCandidate() used currentPeerPubKey() which
returns only the first peer via firstOrNull(). ICE candidates were
gift-wrapped to only one peer; the others never received them.
Fixed by iterating over all currentPeerPubKeys().
2. CallManager.acceptCall() set Connecting.peerPubKeys to groupMembers
which includes the local user's own pubkey. This caused
currentPeerPubKey() to potentially return self, sending ICE
candidates to oneself instead of the caller.
Fixed by filtering out signer.pubKey from the peer set.
https://claude.ai/code/session_01J5fJx9YbSBx1BsBMctiAm8
1. Filter out the logged-in user from the Connecting state UI, matching
the existing behavior in IncomingCall state.
2. Change GroupCallNames default textColor from Color.Unspecified to
MaterialTheme.colorScheme.onSurface so names are visible in dark mode
(Box+background doesn't set LocalContentColor like Surface does).
https://claude.ai/code/session_016sDH1SL7P8aXhcyFaFtzYu
In group calls (e.g. Alice calls Bob and Charlie), when Bob accepts the
call, a CallAnswerEvent is gift-wrapped to all group members including
Charlie. Charlie's CallManager, still in IncomingCall state, was treating
ANY CallAnswerEvent as "answered elsewhere" (intended for multi-device
scenarios) and ending the call prematurely.
The fix checks whether the answering/rejecting peer is actually the
current user (signer.pubKey) before treating it as an "answered/rejected
elsewhere" event. If it's a different group member, we simply ignore it
and keep ringing.
https://claude.ai/code/session_01EYzWB93PZRqw15QuQadCf4
Adds debug logging across CallManager, CallController, and WebRtcCallSession
to trace the full call lifecycle: SDP offer/answer creation, remote description
set success/failure, ICE candidate exchange, ICE connection state transitions,
and signaling state changes. The noOpSdpObserver is replaced with a logging
observer so setRemoteDescription success/failure is visible in logcat.
Filter logcat with: CallManager|CallController|WebRtcCallSession
https://claude.ai/code/session_01M3yyyRyu8KWJZ5PjNt4V2H
Hardware bitmaps cannot be efficiently read for pixels, which is required
when parceling notifications. Adding allowHardware(false) to all image
requests used for notification bitmaps prevents the slow pixel readback.
https://claude.ai/code/session_018y75qshU48REuHXxLH3Tmb