mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
test(cordn): put our MLS and theirs in one group
Tier B runs amy against amy through the reference 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. `interop-client.sh` closes that: `@cordn/cli` (ts-mls) on one end, amy (quartz) on the other, one group, live wire. That half is MIT and comes from npm; only the coordinator underneath it carries the licensing problem, and `stack.sh` now holds that warning in one place for both harnesses. Three directions, and the third is why it was worth building. 1. **Their group, our joiner.** Our engine opens a ts-mls Welcome and reads their GroupContext extensions, metadata and credentials out of it. 2. **Our group, their joiner.** Their engine opens OUR Welcome — the direction no fixture can test, because a fixture we wrote accepts what we emit by construction. 3. **Our later Commit.** Until here 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, and ours are public-framed (wireformat 2) where theirs are private-framed. `CordnGroupManager.invite` has asserted in its KDoc since it was written that their `processMessageBase64` admits both — a claim read off their source and never executed. It holds. All of it passes, and the harness bites: sealing `result.commitBytes` instead of `result.framedCommitBytes` fails direction 3 and the third-member join while **leaving direction 2 green**, because a peer that joined by Welcome never parses that Commit and only stalls once it has to. That is exactly why direction 3 is its own case rather than a variation of 2, and it is now demonstrated instead of argued. `amy cordn invite` gained a `kp_ref` field on the way: the harness needs to tell their client which Welcome to accept, and reporting it is right anyway — a KeyPackage is one-time, so the invite names something the invitee can no longer be invited with by anyone else. One asymmetry found and deliberately left open: the reference client sends kind 25910 **in the clear** where we pin `EncryptionMode.REQUIRED` and always gift-wrap (§8.6). Both work, 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. That is a Tier D vector exchange, not something this harness can settle. `tier-b.sh` is refactored onto `stack.sh` rather than keeping a second copy of the boot; re-run after the refactor and still green. Note on the suite: `Nip46ConsentInfoBuilderTest` failed once mid-session and has not reproduced — not in isolation, not in two full `./gradlew test` runs, not in a `--rerun-tasks` rebuild of that module. Its inputs are constants and its collaborator is injected, so there is no nondeterminism in the test itself; the likeliest cause is a stale incremental artifact, the same failure mode that hit `:commons:jvmTest` earlier today. Recording it rather than calling it a flake, because the report was overwritten before I could read it and I cannot prove which it was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n
This commit is contained in:
+10
-3
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Executable
+229
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
+12
-77
@@ -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"
|
||||
|
||||
+10
-1
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user