diff --git a/cli/README.md b/cli/README.md index 9fae3a7dae..d44c08fe9e 100644 --- a/cli/README.md +++ b/cli/README.md @@ -727,9 +727,16 @@ Two things shape every verb: > A group ref is a **locator, not an invitation**: holding one lets you ask to > join, it does not make you a member, and nothing obliges anyone to answer. -Live verification against the reference coordinator is -[`cli/tests/cordn/tier-b.sh`](tests/cordn/tier-b.sh) — read its header first, -it is deliberately not wired into any build. +Two live harnesses, neither wired into any build — read +[`tests/cordn/stack.sh`](tests/cordn/stack.sh) first, it boots an unlicensed +reference coordinator: + +- [`tests/cordn/tier-b.sh`](tests/cordn/tier-b.sh) — amy against amy through + the reference coordinator. Proves the transport and the coordinator client. +- [`tests/cordn/interop-client.sh`](tests/cordn/interop-client.sh) — amy and + the reference client (`@cordn/cli`, MIT) in one group. Proves the MLS layer + against a second implementation, in both directions, including a + public-framed Commit of ours that their engine has to apply. ### Geochat (Bitchat geohash channels) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnGroupCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnGroupCommands.kt index 14b0447141..4513db1de8 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnGroupCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnGroupCommands.kt @@ -226,6 +226,10 @@ internal object CordnGroupCommands { mapOf( "gid" to result.gid, "invited" to result.invited, + // Which of their one-time packages this spent. Their + // client needs it to find the Welcome; nobody else can + // use it again. + "kp_ref" to result.keyPackageRef, "commit_cursor" to result.commitCursor, "welcome_at" to result.welcomeAt, "epoch" to scope.manager.group(gid)?.epoch, diff --git a/cli/tests/cordn/interop-client.sh b/cli/tests/cordn/interop-client.sh new file mode 100755 index 0000000000..7d683b0839 --- /dev/null +++ b/cli/tests/cordn/interop-client.sh @@ -0,0 +1,229 @@ +#!/usr/bin/env bash +# +# interop-client.sh — amy and the REFERENCE CLIENT in one group. +# +# The claim Tier B does not test. `tier-b.sh` runs amy against amy through the +# reference *coordinator*: it proves our transport and our coordinator client, +# but both MLS endpoints are ours, so the ratchet tree, the Welcome and the +# Commit are only ever agreeing with themselves. This script puts +# **`@cordn/cli` (ts-mls)** on one end and **amy (quartz)** on the other, which +# is the actual interop claim: two independent RFC 9420 implementations in one +# group, over a live wire. +# +# `@cordn/cli` is **MIT** and comes from npm, so this half carries no licensing +# problem. The coordinator underneath it still does — see stack.sh, and read it +# before running this. +# +# What it covers, and why each direction is its own test: +# +# 1. Their group, our joiner — our engine opens a ts-mls Welcome, reads +# the group metadata extension and the +# roster out of it, and decrypts their +# application messages. +# 2. Our group, their joiner — their engine opens OUR Welcome. This is +# the direction that tests our output, and +# it is the one a fixture can never check, +# because a fixture we wrote accepts what we +# emit by construction. +# 3. Our later Commit — the sharpest one. Our engine emits +# **public-framed** handshake messages +# (`MlsMessage(PublicMessage)`, wireformat +# 2) while cordn's client emits +# private-framed. `CordnGroupManager.invite` +# asserts in its KDoc that their +# `processMessageBase64` admits both — a +# claim read off their source and never +# executed. Here they must process our +# Commit to stay in the group at all: if +# they cannot, their epoch stalls and every +# later message fails to open. +# +# Prereqs: see stack.sh, plus network access to npm for `@cordn/cli`. +# +# Usage: +# ./cli/tests/cordn/interop-client.sh +# KEEP=1 ./cli/tests/cordn/interop-client.sh # leave the stack up +# +# Exit 0 only if every step passed. + +set -uo pipefail + +WORK="${WORK:-$(mktemp -d)}" +PORT="${PORT:-7452}" +CONTAINER="cordn-interop-client" +# shellcheck source=stack.sh +. "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/stack.sh" + +export AMY_PASSPHRASE="${AMY_PASSPHRASE:-interop}" + +fail=0 +step() { echo; echo "── $*"; } +ok() { echo " ok: $*"; } +bad() { echo " FAIL: $*"; fail=1; } +check() { if [ "$1" = "$2" ]; then ok "$3"; else bad "$3 (expected '$2', got '$1')"; fi; } + +trap stack_down EXIT + +stack_require +command -v npm >/dev/null 2>&1 || { echo "npm is needed to install @cordn/cli"; exit 2; } + +step "boot geode on $RELAY and the reference coordinator" +stack_up +ok "coordinator $COORD" + +step "install @cordn/cli (MIT) from npm" +mkdir -p "$WORK/ref" +npm install --silent --prefix "$WORK/ref" @cordn/cli >"$WORK/npm.log" 2>&1 || { + echo "npm install failed; see $WORK/npm.log" + exit 1 +} +CORDN="$WORK/ref/node_modules/.bin/cordn" +[ -x "$CORDN" ] || { echo "no cordn binary at $CORDN"; exit 1; } +ok "$("$CORDN" --version 2>/dev/null || echo unknown)" + +# ---- the two clients ------------------------------------------------------ +# amy is process-per-command by design; the reference client is driven the +# same way with --command, so neither side gets to hold state in RAM that the +# other cannot see. Whatever agreement they reach went over the wire. +amy() { HOME="$WORK/amy" "$AMY" --account a --secret-backend ncryptsec "$@" 2>/dev/null; } +amy2() { HOME="$WORK/amy2" "$AMY" --account b --secret-backend ncryptsec "$@" 2>/dev/null; } +ref() { + timeout 120 "$CORDN" \ + --private-key-file "$WORK/ref.key" \ + --server-pubkey "$COORD" \ + --relay "$RELAY" \ + --state-file "$WORK/ref-state.json" \ + --command "$1" 2>&1 +} +field() { python3 -c "import json,sys; d=json.load(sys.stdin); v=d$1; print(v if isinstance(v,str) else json.dumps(v))"; } +# Their output comes in two shapes and it is worth having both readers rather +# than one clever one: `group-info` and `available-kps` print `key=value` +# tokens, `status` prints `key: value`. +reffield() { grep -oE "$1=[^ ]+" | head -1 | cut -d= -f2-; } +refcolon() { grep -oE "^$1: .*" | head -1 | cut -d' ' -f2-; } + +step "identities" +mkdir -p "$WORK/amy" "$WORK/amy2" +openssl rand -hex 32 >"$WORK/ref.key" +amy create --json >/dev/null +amy2 create --json >/dev/null +AMY_PK=$(amy whoami --json | field "['hex']") +AMY2_PK=$(amy2 whoami --json | field "['hex']") +REF_PK=$(ref "status" | refcolon "stablePubkey") +[ -n "$REF_PK" ] || { echo "could not read the reference client's pubkey"; exit 1; } +ok "amy $AMY_PK" +ok "amy(2nd) $AMY2_PK" +ok "ts-mls $REF_PK" + +for a in amy amy2; do + $a cordn coordinator add --coordinator "$COORD" --relay "$RELAY" --json >/dev/null +done + +step "both sides publish a KeyPackage" +amy cordn keypackage publish --json >/dev/null +amy2 cordn keypackage publish --json >/dev/null +ref "gen-kp k1" >/dev/null +# Each client has to be able to READ the other's publication off the +# coordinator, which means our KeyPackage has to parse under their zod schema +# and their capability flags have to survive our encoder. +KPS=$(ref "available-kps") +echo "$KPS" | grep -q "$AMY_PK" && ok "ts-mls can read our published KeyPackage" || bad "our KeyPackage is invisible to them" +echo "$KPS" | grep -q "groupMetadataSupport=yes" && ok "and reads our metadata capability" || bad "our capability flags did not survive" + +# --------------------------------------------------------------------------- +step "DIRECTION 1 — their group, our joiner" +# --------------------------------------------------------------------------- +ref "create-group g1 --name TheirGroup" >/dev/null +THEIR_GID=$(ref "group-info g1" | reffield "groupId") +ok "ts-mls created $THEIR_GID" + +ref "add-member g1 $AMY_PK" >/dev/null +PENDING=$(amy cordn welcomes --json) +check "$(echo "$PENDING" | field "['pending'][0]['gid']")" "$THEIR_GID" "our engine opened a ts-mls Welcome" +# Read out of the Welcome itself, so these assert that their GroupContext +# extensions and their credentials decode under our parser. +check "$(echo "$PENDING" | field "['pending'][0]['name']")" "TheirGroup" "and read their metadata extension" +echo "$PENDING" | grep -q "$REF_PK" && ok "and their credential in the roster" || bad "their credential did not decode" + +amy cordn join --all --json >/dev/null +ref "send-to g1 hello from ts-mls" >/dev/null +GOT=$(amy cordn fetch --json) +check "$(echo "$GOT" | field "['messages'][0]['content']")" "hello from ts-mls" "we decrypt their application message" +check "$(echo "$GOT" | field "['messages'][0]['sender']")" "$REF_PK" "and MLS authenticates them as the sender" + +amy cordn send --gid "$THEIR_GID" --text "hello from quartz" --json >/dev/null +ref "sync g1" >/dev/null +ref "messages g1" | grep -q "hello from quartz" && ok "they decrypt ours" || bad "they could not read our message" + +# --------------------------------------------------------------------------- +step "DIRECTION 2 — our group, their joiner" +# --------------------------------------------------------------------------- +# The direction a fixture cannot test: a fixture we wrote accepts what we emit +# by construction, so only a foreign implementation can say our Welcome is +# well formed. +OUR_GID="quartz-side-group" +amy cordn group create --gid "$OUR_GID" --name "OurGroup" --json >/dev/null +ref "gen-kp k2" >/dev/null +INVITE=$(amy cordn invite --gid "$OUR_GID" --pubkey "$REF_PK" --json) +SPENT=$(echo "$INVITE" | field "['kp_ref']") +check "$(echo "$INVITE" | field "['epoch']")" "1" "our commit advanced us to epoch 1" + +ref "fetch-welcomes" >/dev/null +ACCEPTED=$(ref "accept-welcome $SPENT g2") +echo "$ACCEPTED" | grep -q "name=OurGroup" && ok "ts-mls opened OUR Welcome and read our metadata" || bad "ts-mls could not open our Welcome: $ACCEPTED" +check "$(ref "group-info g2" | reffield "groupId")" "$OUR_GID" "and agrees on the gid" + +amy cordn send --gid "$OUR_GID" --text "quartz made this group" --json >/dev/null +ref "sync g2" >/dev/null +ref "messages g2" | grep -q "quartz made this group" && ok "they read ours at epoch 1" || bad "they could not read ours" + +ref "send-to g2 ts-mls replying in a quartz group" >/dev/null +amy cordn fetch --json | grep -q "ts-mls replying in a quartz group" && ok "we read theirs" || bad "we could not read theirs" + +# --------------------------------------------------------------------------- +step "DIRECTION 3 — our LATER commit, which they must process" +# --------------------------------------------------------------------------- +# Until now their epoch came from a Welcome, which carries the group state +# ready-made. This is the first time they have to apply one of our handshake +# messages, and ours are public-framed (wireformat 2) where theirs are +# private-framed. If they cannot parse it their epoch stalls at 1 and the +# message they send afterwards is sealed under a key we do not have. +amy2 cordn keypackage publish --json >/dev/null +COMMIT=$(amy cordn invite --gid "$OUR_GID" --pubkey "$AMY2_PK" --json) +check "$(echo "$COMMIT" | field "['epoch']")" "2" "our second commit advanced us to epoch 2" + +ref "sync g2" >/dev/null +ref "send-to g2 after the quartz commit" >/dev/null +AFTER=$(amy cordn fetch --json) +# The real assertion: a message they sealed at epoch 2 only opens if they +# applied our Commit. A stalled peer would have sealed at epoch 1, and this +# would come back undecryptable instead. +check "$(echo "$AFTER" | field "['messages'][0]['content']")" "after the quartz commit" "ts-mls applied our public-framed Commit" +check "$(echo "$AFTER" | field "['messages'][0]['epoch']")" "2" "and sealed at the new epoch" + +step "the third member joins a group two implementations built" +amy2 cordn join --all --json >/dev/null +THIRD=$(amy2 cordn fetch --json) +echo "$THIRD" | grep -q "after the quartz commit" && ok "reads the ts-mls message it was welcomed into" || bad "third member could not read history at its join epoch" + +step "all three agree" +A_EPOCH=$(amy cordn group info --gid "$OUR_GID" --json | field "['epoch']") +B_EPOCH=$(amy2 cordn group info --gid "$OUR_GID" --json | field "['epoch']") +R_CURSOR=$(ref "group-info g2" | reffield "cursor") +check "$A_EPOCH" "2" "quartz (inviter) at epoch 2" +check "$B_EPOCH" "2" "quartz (invitee) at epoch 2" +[ -n "$R_CURSOR" ] && ok "ts-mls advanced to cursor $R_CURSOR" || bad "ts-mls reported no cursor" + +MEMBERS=$(amy cordn group info --gid "$OUR_GID" --json | field "['members']") +for pk in "$AMY_PK" "$AMY2_PK" "$REF_PK"; do + echo "$MEMBERS" | grep -q "$pk" || bad "roster is missing $pk" +done +ok "roster holds all three credentials" + +echo +if [ "$fail" = "0" ]; then + echo "CLIENT INTEROP PASSED" +else + echo "CLIENT INTEROP FAILED" +fi +exit "$fail" diff --git a/cli/tests/cordn/stack.sh b/cli/tests/cordn/stack.sh new file mode 100644 index 0000000000..d74fcdbeb4 --- /dev/null +++ b/cli/tests/cordn/stack.sh @@ -0,0 +1,100 @@ +# shellcheck shell=bash +# +# stack.sh — boot the cordn test stack: a geode relay plus the REFERENCE +# coordinator in Docker. Sourced by tier-b.sh and interop-client.sh. +# +# ───────────────────────────────────────────────────────────────────────────── +# The reference coordinator (`ghcr.io/cordn-msg/cordn`, and the +# `packages/coordinator` / `packages/server` sources it is built from) ships +# with NO LICENSE — default copyright, all rights reserved. See §7 of +# quartz/plans/2026-09-17-cordn-interop.md. +# +# Nothing here is wired into a build: no Gradle task, no CI job, and nothing +# pulls the image for you. You pull it by hand having decided that is +# something you want to do. Do not add these scripts to a build file. +# ───────────────────────────────────────────────────────────────────────────── +# +# The caller sets WORK (a scratch directory) and may set PORT. After +# `stack_up`, these are exported: +# +# RELAY ws://127.0.0.1:$PORT +# COORD the coordinator's pubkey, read from its own startup log +# +# `stack_down` is registered by the caller's EXIT trap; KEEP=1 skips it. + +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../.." && pwd)" +AMY="$ROOT/cli/build/install/amy/bin/amy" +GEODE="$ROOT/geode/build/install/geode/bin/geode" +IMAGE="ghcr.io/cordn-msg/cordn:latest" + +PORT="${PORT:-7447}" +RELAY="ws://127.0.0.1:$PORT" +CONTAINER="${CONTAINER:-cordn-test}" +GEODE_PID="" + +# Prereqs, each with its own message. A stopped daemon and an unpulled image +# both fail `docker image inspect`, and telling someone to pull an image they +# cannot pull sends them the wrong way. +stack_require() { + for f in "$AMY" "$GEODE"; do + [ -x "$f" ] || { + echo "missing $f — run ./gradlew :cli:installDist :geode:installDist" + exit 2 + } + done + docker info >/dev/null 2>&1 || { + echo "the docker daemon is not reachable — start it (e.g. 'sudo dockerd &' or 'systemctl start docker') and retry" + exit 2 + } + docker image inspect "$IMAGE" >/dev/null 2>&1 || { + echo "missing $IMAGE — pull it by hand, and read the licence note at the top of this file first" + exit 2 + } +} + +stack_up() { + "$GEODE" --port "$PORT" >"$WORK/geode.log" 2>&1 & + GEODE_PID=$! + for _ in $(seq 30); do + curl -sS --noproxy '*' -H 'Accept: application/nostr+json' "http://127.0.0.1:$PORT/" >/dev/null 2>&1 && break + sleep 1 + done + + # --network host so the container reaches a relay on the host's loopback. + # A stable key so the coordinator pubkey survives a re-run against the + # same WORK directory. + [ -f "$WORK/coordinator.key" ] || openssl rand -hex 32 >"$WORK/coordinator.key" + docker rm -f "$CONTAINER" >/dev/null 2>&1 + docker run -d --name "$CONTAINER" --network host \ + -e CORDN_STORAGE_BACKEND=memory \ + -e CORDN_ANNOUNCED=false \ + -e CORDN_RELAY_URLS="$RELAY" \ + -e CORDN_SERVER_PRIVATE_KEY="$(cat "$WORK/coordinator.key")" \ + -e CORDN_SERVER_NAME="cordn-test" \ + "$IMAGE" >/dev/null || { echo "could not start $CONTAINER"; exit 1; } + + # Read the pubkey out of its own startup log rather than deriving it: the + # coordinator is the authority on its identity, and a key we derived + # wrongly would fail later as an unreachable coordinator. + COORD="" + for _ in $(seq 60); do + COORD=$(docker logs "$CONTAINER" 2>&1 | grep -oE 'serverPubkey":"[0-9a-f]{64}' | head -1 | cut -d'"' -f3) + [ -n "$COORD" ] && break + sleep 1 + done + [ -n "$COORD" ] || { + echo "coordinator never announced its pubkey" + docker logs "$CONTAINER" | tail -20 + exit 1 + } +} + +stack_down() { + if [ "${KEEP:-0}" != "1" ]; then + docker rm -f "$CONTAINER" >/dev/null 2>&1 + [ -n "$GEODE_PID" ] && kill "$GEODE_PID" 2>/dev/null + else + echo + echo "KEEP=1: relay on $RELAY, coordinator $CONTAINER ($COORD), state in $WORK" + fi +} diff --git a/cli/tests/cordn/tier-b.sh b/cli/tests/cordn/tier-b.sh index 3970b74f5c..62c8432083 100755 --- a/cli/tests/cordn/tier-b.sh +++ b/cli/tests/cordn/tier-b.sh @@ -8,27 +8,14 @@ # create a group, invite, open the Welcome without joining, join, talk in both # directions, and check both sides agree on epoch and membership. # -# ───────────────────────────────────────────────────────────────────────────── -# READ THIS BEFORE RUNNING IT +# The reference coordinator it runs against is UNLICENSED — read the header of +# stack.sh, which boots it, before running this. Nothing here is wired into a +# build, and it must not become so. # -# The reference coordinator (`ghcr.io/cordn-msg/cordn`, and the -# `packages/coordinator` / `packages/server` sources it is built from) ships -# with NO LICENSE — default copyright, all rights reserved. See §7 of the plan. +# Sibling: interop-client.sh puts amy and the reference CLIENT in one group, +# which is the test this one does not do — here both MLS endpoints are ours. # -# So this script is deliberately NOT wired into anything: no Gradle task, no -# CI job, no `cli/tests` runner references it, and nothing pulls the image for -# you. You pull it by hand, on your own machine, having decided that is -# something you want to do. It is a diagnostic you run when you change the -# ContextVM transport or the coordinator client, not part of the build. -# -# Do not add it to a build file. If Tier B should become routine, the plan says -# what has to happen first: ask upstream for a LICENSE. -# ───────────────────────────────────────────────────────────────────────────── -# -# Prereqs: -# - a running docker daemon (start it if `docker info` fails), and -# `docker pull ghcr.io/cordn-msg/cordn:latest` -# - ./gradlew :cli:installDist :geode:installDist +# Prereqs: see stack.sh. # # Usage: # ./cli/tests/cordn/tier-b.sh # boot everything, run, tear down @@ -38,14 +25,11 @@ set -uo pipefail -ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../.." && pwd)" -AMY="$ROOT/cli/build/install/amy/bin/amy" -GEODE="$ROOT/geode/build/install/geode/bin/geode" WORK="${WORK:-$(mktemp -d)}" PORT="${PORT:-7447}" -RELAY="ws://127.0.0.1:$PORT" -IMAGE="ghcr.io/cordn-msg/cordn:latest" CONTAINER="cordn-tier-b" +# shellcheck source=stack.sh +. "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/stack.sh" export AMY_PASSPHRASE="${AMY_PASSPHRASE:-tier-b}" @@ -60,61 +44,12 @@ alice() { HOME="$WORK/alice" "$AMY" --account alice --secret-backend ncryptsec " bob() { HOME="$WORK/bob" "$AMY" --account bob --secret-backend ncryptsec "$@" 2>/dev/null; } field() { python3 -c "import json,sys; d=json.load(sys.stdin); print(json.dumps(d$1) if not isinstance(d$1,str) else d$1)"; } -cleanup() { - if [ "${KEEP:-0}" != "1" ]; then - docker rm -f "$CONTAINER" >/dev/null 2>&1 - [ -n "${GEODE_PID:-}" ] && kill "$GEODE_PID" 2>/dev/null - else - echo - echo "KEEP=1: relay on $RELAY, coordinator $CONTAINER, state in $WORK" - fi -} -trap cleanup EXIT +trap stack_down EXIT -for f in "$AMY" "$GEODE"; do - [ -x "$f" ] || { echo "missing $f — run ./gradlew :cli:installDist :geode:installDist"; exit 2; } -done -# Two different problems that used to produce the same message. A dead daemon -# and an unpulled image both fail `docker image inspect`, and telling someone -# to pull an image they cannot pull sends them the wrong way. -docker info >/dev/null 2>&1 || { - echo "the docker daemon is not reachable — start it (e.g. 'sudo dockerd &' or 'systemctl start docker') and retry" - exit 2 -} -docker image inspect "$IMAGE" >/dev/null 2>&1 || { - echo "missing $IMAGE — pull it by hand, and read the licence note at the top of this file first" - exit 2 -} +stack_require -step "boot geode on $RELAY" -"$GEODE" --port "$PORT" >"$WORK/geode.log" 2>&1 & -GEODE_PID=$! -for _ in $(seq 30); do - curl -sS --noproxy '*' -H 'Accept: application/nostr+json' "http://127.0.0.1:$PORT/" >/dev/null 2>&1 && break - sleep 1 -done -ok "relay up" - -step "boot the reference coordinator" -# --network host so the container reaches a relay on the host's loopback. -# A stable key so the coordinator pubkey survives a restart of this script. -[ -f "$WORK/coordinator.key" ] || openssl rand -hex 32 >"$WORK/coordinator.key" -docker rm -f "$CONTAINER" >/dev/null 2>&1 -docker run -d --name "$CONTAINER" --network host \ - -e CORDN_STORAGE_BACKEND=memory \ - -e CORDN_ANNOUNCED=false \ - -e CORDN_RELAY_URLS="$RELAY" \ - -e CORDN_SERVER_PRIVATE_KEY="$(cat "$WORK/coordinator.key")" \ - -e CORDN_SERVER_NAME="tier-b" \ - "$IMAGE" >/dev/null || { echo "could not start $CONTAINER"; exit 1; } - -COORD="" -for _ in $(seq 60); do - COORD=$(docker logs "$CONTAINER" 2>&1 | grep -oE 'serverPubkey":"[0-9a-f]{64}' | head -1 | cut -d'"' -f3) - [ -n "$COORD" ] && break - sleep 1 -done -[ -n "$COORD" ] || { echo "coordinator never announced its pubkey"; docker logs "$CONTAINER" | tail -20; exit 1; } +step "boot geode on $RELAY, and the reference coordinator" +stack_up ok "coordinator $COORD" step "two accounts" diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupManager.kt index a4512da5a0..32933a2e97 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupManager.kt @@ -257,7 +257,7 @@ class CordnGroupManager( } persist(gid) - return InviteResult(gid, targetPubKey, posted.cursor, welcomeAt) + return InviteResult(gid, targetPubKey, taken.keyPackageRef, posted.cursor, welcomeAt) } /** @@ -828,6 +828,15 @@ data class SkippedWelcome( data class InviteResult( val gid: String, val invited: HexKey, + /** + * The KeyPackage this invite consumed. + * + * Worth reporting rather than swallowing: a KeyPackage is one-time, so + * this names something the invitee can no longer be invited with by + * anyone else. It is also the handle their client needs to find the + * Welcome we just left them. + */ + val keyPackageRef: String, val commitCursor: Long, val welcomeAt: Long, ) diff --git a/quartz/plans/2026-09-17-cordn-interop.md b/quartz/plans/2026-09-17-cordn-interop.md index cc35f13d77..b69a386b45 100644 --- a/quartz/plans/2026-09-17-cordn-interop.md +++ b/quartz/plans/2026-09-17-cordn-interop.md @@ -675,13 +675,60 @@ create, invite, Welcome opened without joining, join, messages both ways with th traffic reported as echoes rather than gaps, and both sides agreeing on epoch 1 and the same two members. +### 7.2 The other half: our MLS against theirs + +Tier B above runs amy against amy through their coordinator. Both MLS +endpoints are ours, so the ratchet tree, the Welcome and the Commit only ever +agree with themselves — it proves the transport and the coordinator client, +and nothing about RFC 9420 interop. + +`cli/tests/cordn/interop-client.sh` closes that. It puts **`@cordn/cli` +(ts-mls)** on one end and **amy (quartz)** on the other, in one group, over the +live wire. That half carries no licensing problem — `@cordn/cli` is MIT and +comes from npm — though the coordinator underneath it still does. + +Three directions, and they are not redundant: + +1. **Their group, our joiner.** Our engine opens a ts-mls Welcome and reads + their GroupContext extensions, their metadata and their credentials out of + it, then decrypts their application messages. +2. **Our group, their joiner.** Their engine opens **our** Welcome. This is + the direction no fixture can test: a fixture we wrote accepts what we emit + by construction, so only a foreign implementation can say our Welcome is + well formed. +3. **Our later Commit.** The sharpest, and the one worth having built the + harness for. Until direction 3 their epoch came from a Welcome, which + carries the group state ready-made; this is the first time they must apply + one of our handshake messages. Ours are **public-framed** + (`MlsMessage(PublicMessage)`, wireformat 2) where theirs are private-framed, + and `CordnGroupManager.invite` has always asserted in its KDoc that their + `processMessageBase64` admits both — a claim read off their source and never + executed. It holds: they apply our Commit, advance to epoch 2, and seal a + message we then open. + +All of it passes. Mutation-checked rather than trusted: sealing +`result.commitBytes` (the bare RFC 9420 struct) instead of +`result.framedCommitBytes` fails directions 3 and the third-member join and +**leaves direction 2 green**, because a peer that joined by Welcome never +parses that Commit and only stalls once it has to. That is the whole reason +direction 3 exists as its own case, and it is now demonstrated rather than +argued. + +One asymmetry this surfaced and did not resolve: **the reference client sends +kind 25910 in the clear**, unwrapped, where we pin `EncryptionMode.REQUIRED` +and always gift-wrap (§8.6). Both work against the coordinator, so nothing is +broken — but the two clients exercise different halves of CEP-4 against the +same server, and our encrypted path is the one with no second implementation +behind it. Worth a Tier D vector exchange. + ### What Tier B is, and is not These are both **transport and bookkeeping** bugs. Not one byte of the crypto surface moved: the MLS engine, the seal, the envelopes and the group refs were already verified against ts-mls and against cordn's own wire contracts, and Tier B found nothing wrong with any of them. That is the shape to expect from a live tier — it tests the things a fixture cannot model, which are the -things a fixture was written by the same person who wrote the client. +things a fixture was written by the same person who wrote the client. §7.2 then covers the +crypto surface against a foreign implementation, and finds it sound. ## 8. What the coordinator can see