fix(mls): use filtered direct path in UpdatePath per RFC 9420 §7.9

Interop Test 06 (wn removes a member from a group Amethyst is in) was
failing with:

  GroupEventHandler.add: ERROR Failed to apply commit:
    UpdatePath node count (1) doesn't match direct path length (2)

Repro scenario: 3-member group [B=leaf 0, C=leaf 1, A=leaf 2]; B (admin,
committer) removes C. After the Remove proposal applies, leaf 1 is
blank but leaf 2 (A) is still occupied — so leaf_count stays at 3 per
RFC 9420 §7.8 and the sender's direct path has length 2 ([node 1,
root]).

Per RFC 9420 §4.1.2 and §7.9 the **filtered direct path** drops any
parent node whose child on the copath has an empty resolution:
encrypting to such a parent is equivalent to encrypting to its only
non-blank child, which is already on the direct path. In our scenario
the parent at level 1 (parent of leaves 0, 1) has a blank leaf 1 on
the copath — empty resolution — so it's filtered out. Filtered
direct path length is 1, and that's what openmls / whitenoise-rs emit
in `UpdatePath.nodes<V>`.

Quartz was always using the unfiltered direct path on both sides
(send + receive + parent-hash chain + path-secret decrypt). That
worked for quartz↔quartz (both sides agreed), but broke the moment a
spec-correct sender (wn) sent a filtered UpdatePath into a quartz
receiver. Parent-hash chain is further affected because §7.9.2 walks
the filtered path — even if the size check passed, the hashes would
not match.

Fix it throughout:

- RatchetTree.filteredDirectPath: new helper returning parallel
  (filteredDirectPath, filteredCopath) with the empty-resolution
  entries dropped. Uses the existing `resolution()` helper, which
  already handles unmerged_leaves per §4.1.2.

- RatchetTree.applyUpdatePath: size-check + target the FILTERED path.

- MlsGroup.commit (sender): emit one staged path node per filtered
  level; encrypt path secrets using the filtered copath; patch parent
  hashes into the filtered positions only.

- MlsGroup.processCommitInner (receiver): the UpdatePath node index
  aligns to the filtered direct path, so the common-ancestor lookup
  must find the filtered index to pick the right ciphertext + copath
  resolution. Use the unfiltered index only for counting KDF steps
  to the root (path_secret chain advances one step per unfiltered
  level regardless of filtering — the chain is continuous; filtering
  only decides which levels emit a UpdatePathNode, not how many KDF
  steps separate them).

- MlsGroup.computeSenderParentHashes: walk the filtered direct path.
  Map each filtered node back to its unfiltered level so the
  preUpdateSiblingHashes lookup (indexed by unfiltered level) still
  resolves. This makes the parent_hash chain agree with §7.9.2 and
  with what openmls computes on the other side.

- MlsGroup.verifyParentHash: short-circuit on empty filtered path
  rather than empty unfiltered path.

New regression test: testRemoveMiddleLeaf_ReceiverAcceptsCommit
reproduces the 3-member remove-middle scenario end-to-end within
quartz. It passed before this patch (because both sides were
unfiltered and agreed with each other), and it passes after this
patch (because both sides are now filtered and still agree) — the
compatibility win is that quartz now also agrees with
openmls/wn-produced commits of the same shape.

Also fix an unrelated script bug in marmot-interop.sh: the
discover_a_relays SQL probe was falsely reporting "wn has NO cached
relay entries for A" even when welcome delivery was working, because
the query assumed users.pubkey stored as BLOB but wn stores it as
TEXT (hex) in the current schema. Accept either encoding.
This commit is contained in:
Claude
2026-04-22 22:58:21 +00:00
parent 6f3abac3b2
commit 3279c2463a
4 changed files with 209 additions and 49 deletions
+14 -3
View File
@@ -311,16 +311,27 @@ discover_a_relays() {
step "wn_b's cached view of A's relay lists (SELECT from user_relays)"
local rows
# wn's SQLite schema stores `users.pubkey` — we don't have a public
# CLI to confirm the column type, and in practice it's been observed
# stored as both BLOB (raw 32 bytes) and TEXT (lowercase hex) across
# versions. Try both forms so the probe doesn't falsely report "NO
# cached relay entries" when the cache is actually populated (symptom:
# this diagnostic warned loudly even though Tests 02/03/04/05 were
# delivering welcomes successfully — the welcomes were flowing, the
# probe was just querying the wrong column encoding).
rows=$(sqlite3 -readonly "$db" \
"SELECT ur.relay_type, r.url FROM user_relays ur
JOIN users u ON u.id = ur.user_id
JOIN relays r ON r.id = ur.relay_id
WHERE LOWER(HEX(u.pubkey)) = LOWER('$A_HEX')
WHERE u.pubkey = X'$A_HEX'
OR LOWER(CAST(u.pubkey AS TEXT)) = LOWER('$A_HEX')
ORDER BY ur.relay_type, r.url;" 2>>"$LOG_FILE" || true)
if [[ -z "$rows" ]]; then
warn "wn has NO cached relay entries for A ($A_HEX)."
warn " → Amethyst hasn't published kind:10002/10050/10051 to any relay wn_b can reach."
warn " → Welcomes and gift wraps will not reach Amethyst. Tests 03+ will fail."
warn " → Either Amethyst hasn't published kind:10002/10050/10051 to any relay wn_b"
warn " can reach, OR wn's SQLite schema changed pubkey column encoding again."
warn " → If later tests deliver welcomes successfully, the schema is the culprit;"
warn " grep the daemon's migration files for the users.pubkey column type."
return
fi