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
@@ -488,29 +488,50 @@ class MlsGroup private constructor(
// the decryption into `UpdatePathError(UnableToDecrypt)`. // the decryption into `UpdatePathError(UnableToDecrypt)`.
val updatePath: UpdatePath? = val updatePath: UpdatePath? =
if (needsPath && pathSecrets.isNotEmpty()) { if (needsPath && pathSecrets.isNotEmpty()) {
val copath = BinaryTree.copath(myLeafIndex, tree.leafCount) // RFC 9420 §7.9: UpdatePath carries one node per entry in the
// **filtered** direct path — parents whose copath subtree has
// empty resolution are omitted (encrypting to them is
// equivalent to encrypting to their only non-blank child).
// Sender and receiver must agree on filtering or the
// UpdatePath length check fails and openmls/wn reject the
// commit with "UpdatePath node count doesn't match".
val (filteredDp, filteredCp) = tree.filteredDirectPath(myLeafIndex)
val directPath = BinaryTree.directPath(myLeafIndex, tree.leafCount)
// path_secrets are derived one per UNFILTERED level so the
// KDF chain reaches the root regardless of filtering
// (commit_secret = DeriveSecret(root_path_secret, "path")
// must be computable even when the root is in a filtered-out
// position). Select the subset aligned to the filtered
// direct path for UpdatePath node generation.
val filteredPathSecrets =
directPath.indices.mapNotNull { i ->
val idxInFiltered = filteredDp.indexOf(directPath[i])
if (idxInFiltered >= 0) pathSecrets[i] else null
}
// Capture sibling tree hashes BEFORE applying the UpdatePath — // Capture sibling tree hashes BEFORE applying the UpdatePath —
// parent_hash computation (RFC 9420 §7.9.2) uses the // parent_hash computation (RFC 9420 §7.9.2) uses the
// ORIGINAL sibling-subtree tree hashes. // ORIGINAL sibling-subtree tree hashes. Only needed at the
// filtered positions (those are the nodes whose parent_hash
// we're going to compute).
val preUpdateSiblingHashes = capturePreUpdateSiblingHashes(myLeafIndex) val preUpdateSiblingHashes = capturePreUpdateSiblingHashes(myLeafIndex)
// Stage path-keys into the tree so subsequent parent_hash / // Stage path-keys into the tree so subsequent parent_hash /
// tree_hash computations reflect the new keys. We'll fill in // tree_hash computations reflect the new keys. We'll fill in
// the HPKE-encrypted secrets once we know the post-commit // the HPKE-encrypted secrets once we know the post-commit
// context bytes. // context bytes. One staged node per FILTERED level.
val stagedPathNodes = val stagedPathNodes =
pathSecrets.zip(copath).map { (pathKey, _) -> filteredPathSecrets.map { pathKey ->
UpdatePathNode(pathKey.publicKey, emptyList()) UpdatePathNode(pathKey.publicKey, emptyList())
} }
tree.applyUpdatePath(myLeafIndex, stagedPathNodes) tree.applyUpdatePath(myLeafIndex, stagedPathNodes)
// Compute parent_hash for every direct-path parent node and // Compute parent_hash for every FILTERED-direct-path parent
// for the committer's leaf (RFC 9420 §7.9.2). // node and for the committer's leaf (RFC 9420 §7.9.2).
val (parentNodeHashes, leafParentHash) = val (parentNodeHashes, leafParentHash) =
computeSenderParentHashes(myLeafIndex, preUpdateSiblingHashes) computeSenderParentHashes(myLeafIndex, preUpdateSiblingHashes)
val directPath = BinaryTree.directPath(myLeafIndex, tree.leafCount) for (nodeIdx in filteredDp) {
for (nodeIdx in directPath) {
val existing = tree.getNode(nodeIdx) val existing = tree.getNode(nodeIdx)
if (existing is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) { if (existing is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) {
tree.setParent( tree.setParent(
@@ -559,7 +580,7 @@ class MlsGroup private constructor(
).toTlsBytes() ).toTlsBytes()
val pathNodes = val pathNodes =
stagedPathNodes.zip(copath).zip(pathSecrets) { (staged, copathNode), pathKey -> stagedPathNodes.zip(filteredCp).zip(filteredPathSecrets) { (staged, copathNode), pathKey ->
val resolution = val resolution =
tree.resolution(copathNode).filterNot { resNode -> tree.resolution(copathNode).filterNot { resNode ->
BinaryTree.isLeaf(resNode) && BinaryTree.isLeaf(resNode) &&
@@ -1291,12 +1312,15 @@ class MlsGroup private constructor(
} }
// After verification, patch the computed parent_hash values into // After verification, patch the computed parent_hash values into
// the direct-path parent nodes. The sender's tree has these // the FILTERED-direct-path parent nodes. The sender's tree has these
// values filled in; receivers must match for treeHash() to agree // values filled in; receivers must match for treeHash() to agree
// (and thus for the epoch key schedule to converge). // (and thus for the epoch key schedule to converge). Unfiltered
// parents have been blanked by the proposal or were never on the
// chain — skip them.
val (recvParentHashes, _) = val (recvParentHashes, _) =
computeSenderParentHashes(senderLeafIndex, preUpdateSiblingHashes) computeSenderParentHashes(senderLeafIndex, preUpdateSiblingHashes)
for (nodeIdx in BinaryTree.directPath(senderLeafIndex, tree.leafCount)) { val (filteredDp, filteredCp) = tree.filteredDirectPath(senderLeafIndex)
for (nodeIdx in filteredDp) {
val existing = tree.getNode(nodeIdx) val existing = tree.getNode(nodeIdx)
if (existing is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) { if (existing is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) {
tree.setParent( tree.setParent(
@@ -1308,8 +1332,15 @@ class MlsGroup private constructor(
} }
} }
// Decrypt path secret from our copath node // Decrypt path secret from our copath node.
val directPath = BinaryTree.directPath(senderLeafIndex, tree.leafCount) //
// RFC 9420 §7.9: UpdatePath nodes align to the sender's FILTERED
// direct path, so `commonAncestorIdx` must be computed against
// that filtered list (not the unfiltered directPath). Using the
// unfiltered index picks the wrong `updatePath.nodes[i]` and
// HPKE-decrypt fails silently — causing `ConfirmationTagMismatch`
// at best, or a completely wrong commit_secret at worst.
val unfilteredDirectPath = BinaryTree.directPath(senderLeafIndex, tree.leafCount)
val myPath = BinaryTree.directPath(myLeafIndex, tree.leafCount) val myPath = BinaryTree.directPath(myLeafIndex, tree.leafCount)
// Path-decryption context (RFC 9420 §7.6): matches what the // Path-decryption context (RFC 9420 §7.6): matches what the
@@ -1322,11 +1353,19 @@ class MlsGroup private constructor(
treeHash = tree.treeHash(), treeHash = tree.treeHash(),
).toTlsBytes() ).toTlsBytes()
// Find the common ancestor // Find the unfiltered common-ancestor index (we need this for the
val commonAncestorIdx = directPath.indexOfFirst { it in myPath } // KDF step count: commit_secret = DeriveSecret(path_secret[root])
if (commonAncestorIdx >= 0 && commonAncestorIdx < updatePath.nodes.size) { // requires walking one KDF step per unfiltered level from the
val pathNode = updatePath.nodes[commonAncestorIdx] // common ancestor to the root).
val copathNodeIdx = BinaryTree.copath(senderLeafIndex, tree.leafCount)[commonAncestorIdx] val commonAncestorUnfilteredIdx = unfilteredDirectPath.indexOfFirst { it in myPath }
// Find the filtered common-ancestor index (for picking the right
// UpdatePath node + copath resolution).
val commonAncestorNode =
if (commonAncestorUnfilteredIdx >= 0) unfilteredDirectPath[commonAncestorUnfilteredIdx] else -1
val commonAncestorFilteredIdx = filteredDp.indexOf(commonAncestorNode)
if (commonAncestorFilteredIdx >= 0 && commonAncestorFilteredIdx < updatePath.nodes.size) {
val pathNode = updatePath.nodes[commonAncestorFilteredIdx]
val copathNodeIdx = filteredCp[commonAncestorFilteredIdx]
val resolution = val resolution =
tree.resolution(copathNodeIdx).filterNot { resNode -> tree.resolution(copathNodeIdx).filterNot { resNode ->
BinaryTree.isLeaf(resNode) && BinaryTree.isLeaf(resNode) &&
@@ -1354,7 +1393,13 @@ class MlsGroup private constructor(
// advances one step past the root; quartz was stopping at the root // advances one step past the root; quartz was stopping at the root
// and diverging — that's what caused `ConfirmationTagMismatch` on // and diverging — that's what caused `ConfirmationTagMismatch` on
// every cross-impl commit. // every cross-impl commit.
val stepsToRoot = directPath.size - commonAncestorIdx - 1 //
// Step count is measured against the UNFILTERED direct
// path — the path_secret chain advances one KDF step per
// level regardless of whether that level emits a
// UpdatePath node, so filtering changes which nodes carry
// ciphertext but not the number of KDF steps.
val stepsToRoot = unfilteredDirectPath.size - commonAncestorUnfilteredIdx - 1
var currentSecret = pathSecret var currentSecret = pathSecret
repeat(stepsToRoot) { repeat(stepsToRoot) {
currentSecret = MlsCryptoProvider.deriveSecret(currentSecret, "path") currentSecret = MlsCryptoProvider.deriveSecret(currentSecret, "path")
@@ -1568,30 +1613,40 @@ class MlsGroup private constructor(
senderLeafIndex: Int, senderLeafIndex: Int,
preUpdateSiblingHashes: Map<Int, ByteArray>, preUpdateSiblingHashes: Map<Int, ByteArray>,
): Pair<Map<Int, ByteArray>, ByteArray> { ): Pair<Map<Int, ByteArray>, ByteArray> {
val directPath = BinaryTree.directPath(senderLeafIndex, tree.leafCount) // RFC 9420 §7.9.2: parent_hash chain walks the FILTERED direct path.
if (directPath.isEmpty()) return Pair(emptyMap(), ByteArray(0)) // A filtered-out parent is never on the chain because its copath
// subtree is blank — there's no sibling tree hash to fold into the
// next hop. Using the unfiltered path here produces a chain that
// doesn't match what openmls/wn compute on the other side.
val (filteredDp, _) = tree.filteredDirectPath(senderLeafIndex)
val unfilteredDp = BinaryTree.directPath(senderLeafIndex, tree.leafCount)
if (filteredDp.isEmpty()) return Pair(emptyMap(), ByteArray(0))
// preUpdateSiblingHashes is indexed by level in the UNFILTERED path —
// map each filtered node to its unfiltered level so we pick the
// correct sibling tree hash.
val filteredLevels = filteredDp.map { unfilteredDp.indexOf(it) }
val hashes = mutableMapOf<Int, ByteArray>() val hashes = mutableMapOf<Int, ByteArray>()
// Root's parent_hash is empty by convention (RFC 9420 §7.9.2). // Root's parent_hash is empty by convention (RFC 9420 §7.9.2).
hashes[directPath.last()] = ByteArray(0) hashes[filteredDp.last()] = ByteArray(0)
// Walk top-down (root has no parent → already set). For each node // Walk top-down (root has no parent → already set). For each node
// X below root, parent_hash(X) = Hash(encode(ParentHashInput{ // X below root on the filtered path, parent_hash(X) is computed
// encryption_key = parent(X).encryption_key, // with parent(X)'s fields, where "parent" is the NEXT entry on the
// parent_hash = parent(X).parent_hash, // filtered direct path.
// original_sibling_tree_hash = tree_hash(sibling(X))_preUpdate, for (i in filteredDp.size - 2 downTo 0) {
// })). val xIdx = filteredDp[i]
for (i in directPath.size - 2 downTo 0) { val parentIdx = filteredDp[i + 1]
val xIdx = directPath[i] val parentLevel = filteredLevels[i + 1]
val parentIdx = directPath[i + 1]
val parentNode = tree.getNode(parentIdx) val parentNode = tree.getNode(parentIdx)
if (parentNode !is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) { if (parentNode !is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) {
hashes[xIdx] = ByteArray(0) hashes[xIdx] = ByteArray(0)
continue continue
} }
val siblingTreeHash = val siblingTreeHash =
preUpdateSiblingHashes[i + 1] preUpdateSiblingHashes[parentLevel]
?: error("missing pre-update sibling tree hash at level ${i + 1}") ?: error("missing pre-update sibling tree hash at level $parentLevel")
hashes[xIdx] = hashes[xIdx] =
MlsCryptoProvider.hash( MlsCryptoProvider.hash(
encodeParentHashInput( encodeParentHashInput(
@@ -1603,14 +1658,15 @@ class MlsGroup private constructor(
} }
// The committer's leaf carries parent_hash(parent(leaf)) = parent_hash // The committer's leaf carries parent_hash(parent(leaf)) = parent_hash
// computed with the immediate parent's (directPath[0]) fields. // computed with the immediate parent's (filteredDp[0]) fields.
val immediateParentIdx = directPath.first() val immediateParentIdx = filteredDp.first()
val immediateParentLevel = filteredLevels.first()
val immediateParent = tree.getNode(immediateParentIdx) val immediateParent = tree.getNode(immediateParentIdx)
val leafParentHash = val leafParentHash =
if (immediateParent is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) { if (immediateParent is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) {
val siblingTreeHash = val siblingTreeHash =
preUpdateSiblingHashes[0] preUpdateSiblingHashes[immediateParentLevel]
?: error("missing pre-update sibling tree hash at leaf level") ?: error("missing pre-update sibling tree hash at level $immediateParentLevel")
MlsCryptoProvider.hash( MlsCryptoProvider.hash(
encodeParentHashInput( encodeParentHashInput(
encryptionKey = immediateParent.parentNode.encryptionKey, encryptionKey = immediateParent.parentNode.encryptionKey,
@@ -1676,8 +1732,12 @@ class MlsGroup private constructor(
updatePath: UpdatePath, updatePath: UpdatePath,
preUpdateSiblingHashes: Map<Int, ByteArray>, preUpdateSiblingHashes: Map<Int, ByteArray>,
): Boolean { ): Boolean {
val directPath = BinaryTree.directPath(senderLeafIndex, tree.leafCount) // Filtered direct path drives the parent_hash chain — a sender
if (directPath.isEmpty()) return true // with an empty filtered direct path has no parents to hash so
// the leaf's parent_hash field should be an empty byte string
// and we can short-circuit verification.
val (filteredDp, _) = tree.filteredDirectPath(senderLeafIndex)
if (filteredDp.isEmpty()) return true
// RFC 9420 §7.9.2: compute what the sender's parent_hash chain // RFC 9420 §7.9.2: compute what the sender's parent_hash chain
// SHOULD have been given the post-update tree state, then compare // SHOULD have been given the post-update tree state, then compare
@@ -266,22 +266,51 @@ class RatchetTree(
return MlsCryptoProvider.hash(writer.toByteArray()) return MlsCryptoProvider.hash(writer.toByteArray())
} }
/**
* RFC 9420 §4.1.2 "filtered direct path":
* the direct path of a leaf node L, with any parent node removed whose
* child on the copath of L has an empty resolution (unmerged_leaves
* count toward the resolution).
*
* Returns parallel lists (filteredDirectPath, filteredCopath). This is
* what openmls / whitenoise-rs use for `UpdatePath.nodes` generation
* and application — omitting a parent whose copath subtree is entirely
* blank is spec-required because encrypting to that parent's key pair
* is equivalent to encrypting to its only non-blank child (which is
* already on the direct path). Relevant in, e.g., a 3-member group
* where the committer removes the middle leaf: the sibling subtree at
* the level above the committer becomes fully blank, and the parent at
* that level is dropped from the UpdatePath.
*/
fun filteredDirectPath(leafIndex: Int): Pair<List<Int>, List<Int>> {
val directPath = BinaryTree.directPath(leafIndex, _leafCount)
val copath = BinaryTree.copath(leafIndex, _leafCount)
val filteredDp = mutableListOf<Int>()
val filteredCp = mutableListOf<Int>()
for (i in directPath.indices) {
if (resolution(copath[i]).isNotEmpty()) {
filteredDp.add(directPath[i])
filteredCp.add(copath[i])
}
}
return filteredDp to filteredCp
}
/** /**
* Apply an UpdatePath to the tree: update parent nodes along the sender's * Apply an UpdatePath to the tree: update parent nodes along the sender's
* direct path with the provided path nodes. * **filtered** direct path (RFC 9420 §7.9) with the provided path nodes.
*/ */
fun applyUpdatePath( fun applyUpdatePath(
senderLeafIndex: Int, senderLeafIndex: Int,
pathNodes: List<UpdatePathNode>, pathNodes: List<UpdatePathNode>,
) { ) {
val directPath = BinaryTree.directPath(senderLeafIndex, _leafCount) val (filteredDp, _) = filteredDirectPath(senderLeafIndex)
require(pathNodes.size == filteredDp.size) {
require(pathNodes.size == directPath.size) { "UpdatePath node count (${pathNodes.size}) doesn't match filtered direct path length (${filteredDp.size})"
"UpdatePath node count (${pathNodes.size}) doesn't match direct path length (${directPath.size})"
} }
for (i in directPath.indices) { for (i in filteredDp.indices) {
val nodeIdx = directPath[i] val nodeIdx = filteredDp[i]
val pathNode = pathNodes[i] val pathNode = pathNodes[i]
setParent( setParent(
@@ -354,6 +354,66 @@ class MlsGroupLifecycleTest {
assertEquals(bob2.leafIndex, aliceSees.senderLeafIndex) assertEquals(bob2.leafIndex, aliceSees.senderLeafIndex)
} }
/**
* Regression: 3-member group, committer removes the MIDDLE leaf while the
* trailing leaf remains occupied. Interop harness Test 06 hit this with
* whitenoise-rs: the interop log showed quartz rejecting wn's commit
* with "Failed to apply commit: UpdatePath node count (1) doesn't match
* direct path length (2)". That error comes from
* `RatchetTree.applyUpdatePath` comparing the received node count against
* the CURRENT tree's direct-path length — the two sides disagree on the
* post-proposal leaf_count, so the path-length invariant breaks.
*
* Scenario Amethyst observed:
* Tree before: [B=leaf 0 (admin/committer), C=leaf 1, A=leaf 2]
* Commit: Remove(leaf 1)
* Tree after: [B=leaf 0, _blank_, A=leaf 2] (leaf 2 still occupied,
* so RFC §7.8 does NOT trim)
*
* Both sender and every receiver should end up at leaf_count=3 post-
* proposal with the sender's direct path at size 2 ([node 1, root 3]).
* If quartz's receiver processes the commit cleanly, the test passes. If
* this reproduction also reports "UpdatePath node count … doesn't match
* direct path length …", we've isolated the bug to the quartz side, not
* a wn-specific oddity.
*/
@Test
fun testRemoveMiddleLeaf_ReceiverAcceptsCommit() {
// Alice creates, adds Bob (→ leaf 1), adds Carol (→ leaf 2).
val alice = MlsGroup.create("alice".encodeToByteArray())
val bobBundle = createStandaloneKeyPackage("bob")
val addBobResult = alice.addMember(bobBundle.keyPackage.toTlsBytes())
val bob = MlsGroup.processWelcome(addBobResult.welcomeBytes!!, bobBundle)
val carolBundle = createStandaloneKeyPackage("carol")
val addCarolResult = alice.addMember(carolBundle.keyPackage.toTlsBytes())
bob.processFramedCommit(addCarolResult.framedCommitBytes)
val carol = MlsGroup.processWelcome(addCarolResult.welcomeBytes!!, carolBundle)
check(alice.memberCount == 3 && bob.memberCount == 3 && carol.memberCount == 3) {
"pre-condition: all three members must be present"
}
// Alice removes Bob (leaf 1) — the MIDDLE leaf. Carol (leaf 2) stays,
// so trailing-blank trim does NOT fire and leaf_count stays at 3.
val removeResult = alice.removeMember(bob.leafIndex)
// Carol, the remaining non-committer, must be able to apply the
// commit. This is where the interop regression surfaces.
carol.processFramedCommit(removeResult.framedCommitBytes)
// After the Remove, Alice and Carol are the only members but the
// tree still has leaf_count=3 (leaf 1 blank, leaf 2 = Carol).
assertEquals(alice.epoch, carol.epoch, "Carol must be at Alice's epoch after remove")
assertEquals(2, alice.memberCount, "Alice: 2 active members after removing Bob")
assertEquals(2, carol.memberCount, "Carol: 2 active members after removing Bob")
// Post-remove forward-secrecy round-trip: Alice sends, Carol decrypts.
val msg = "after removing bob".encodeToByteArray()
assertContentEquals(msg, carol.decrypt(alice.encrypt(msg)).content)
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// 8. Signing key rotation (Update proposal) // 8. Signing key rotation (Update proposal)
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
+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)" step "wn_b's cached view of A's relay lists (SELECT from user_relays)"
local rows 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" \ rows=$(sqlite3 -readonly "$db" \
"SELECT ur.relay_type, r.url FROM user_relays ur "SELECT ur.relay_type, r.url FROM user_relays ur
JOIN users u ON u.id = ur.user_id JOIN users u ON u.id = ur.user_id
JOIN relays r ON r.id = ur.relay_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) ORDER BY ur.relay_type, r.url;" 2>>"$LOG_FILE" || true)
if [[ -z "$rows" ]]; then if [[ -z "$rows" ]]; then
warn "wn has NO cached relay entries for A ($A_HEX)." 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 " → Either Amethyst hasn't published kind:10002/10050/10051 to any relay wn_b"
warn " → Welcomes and gift wraps will not reach Amethyst. Tests 03+ will fail." 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 return
fi fi