radicle-e2e.sh
1 #!/usr/bin/env bash 2 # 3 # Radicle Multi-Device E2E Demo 4 # 5 # Automated (non-interactive) test that exercises the real rad + auths CLIs 6 # together: sets up two Radicle nodes, creates an Auths identity, links both 7 # nodes as devices, creates a project, verifies authorizations, and revokes 8 # a device. 9 # 10 # Prerequisites: rad CLI installed (https://radicle.xyz) 11 # Usage: just e2e-radicle OR bash scripts/radicle-e2e.sh 12 # 13 set -euo pipefail 14 15 # ── Colors ──────────────────────────────────────────────────────────────────── 16 RED='\033[0;31m' 17 GREEN='\033[0;32m' 18 YELLOW='\033[1;33m' 19 BLUE='\033[0;34m' 20 CYAN='\033[0;36m' 21 BOLD='\033[1m' 22 DIM='\033[2m' 23 NC='\033[0m' 24 25 # ── Paths ───────────────────────────────────────────────────────────────────── 26 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 27 REPO_ROOT="$(dirname "$SCRIPT_DIR")" 28 AUTHS_BIN="$REPO_ROOT/target/release/auths" 29 AUTHS_SIGN_BIN="$REPO_ROOT/target/release/auths-sign" 30 DEMO_DIR="$(mktemp -d)" 31 32 # Existing Radicle project to push patches to 33 PROJECT_RID="rad:zTfsUzHQFrAUMsMgTqT5hFwfGon1" 34 35 # rosa.radicle.xyz is PERMISSIVE (seeds all public repos). 36 # seed.radicle.xyz is SELECTIVE (Radicle team repos only) — will not host our project. 37 ROSA_NID="z6Mkmqogy2qEM2ummccUthFEaaHvyYmYBYh3dbe9W4ebScxo" 38 39 # Auths storage 40 AUTHS_HOME="$DEMO_DIR/.auths" 41 42 # Two simulated Radicle nodes 43 RAD_NODE1_HOME="$DEMO_DIR/rad-node-1" 44 RAD_NODE2_HOME="$DEMO_DIR/rad-node-2" 45 46 # ── Headless environment ────────────────────────────────────────────────────── 47 export AUTHS_KEYCHAIN_BACKEND=file 48 export AUTHS_KEYCHAIN_FILE="$DEMO_DIR/keys.enc" 49 export AUTHS_PASSPHRASE=test-e2e-passphrase 50 export RAD_PASSPHRASE="e2e-rad" 51 export GIT_AUTHOR_NAME="E2E Tester" 52 export GIT_AUTHOR_EMAIL="e2e@test.local" 53 export GIT_COMMITTER_NAME="E2E Tester" 54 export GIT_COMMITTER_EMAIL="e2e@test.local" 55 56 # ── Phase tracking ──────────────────────────────────────────────────────────── 57 PHASE_RESULTS=() 58 CURRENT_PHASE="" 59 60 phase_start() { 61 CURRENT_PHASE="$1" 62 echo "" 63 echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" 64 echo -e "${BOLD} $1${NC}" 65 echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" 66 echo "" 67 } 68 69 phase_pass() { 70 echo "" 71 echo -e " ${GREEN}✓ PASS${NC}: $CURRENT_PHASE" 72 PHASE_RESULTS+=("PASS: $CURRENT_PHASE") 73 } 74 75 phase_fail() { 76 local msg="${1:-assertion failed}" 77 echo "" 78 echo -e " ${RED}✗ FAIL${NC}: $CURRENT_PHASE — $msg" 79 PHASE_RESULTS+=("FAIL: $CURRENT_PHASE — $msg") 80 } 81 82 assert_ok() { 83 local desc="$1"; shift 84 if "$@" >/dev/null 2>&1; then 85 echo -e " ${GREEN}✓${NC} $desc" 86 else 87 echo -e " ${RED}✗${NC} $desc" 88 phase_fail "$desc" 89 exit 1 90 fi 91 } 92 93 assert_contains() { 94 local desc="$1" 95 local haystack="$2" 96 local needle="$3" 97 if echo "$haystack" | grep -q "$needle"; then 98 echo -e " ${GREEN}✓${NC} $desc" 99 else 100 echo -e " ${RED}✗${NC} $desc (expected to find: $needle)" 101 phase_fail "$desc" 102 exit 1 103 fi 104 } 105 106 assert_not_contains() { 107 local desc="$1" 108 local haystack="$2" 109 local needle="$3" 110 if echo "$haystack" | grep -q "$needle"; then 111 echo -e " ${RED}✗${NC} $desc (unexpectedly found: $needle)" 112 phase_fail "$desc" 113 exit 1 114 else 115 echo -e " ${GREEN}✓${NC} $desc" 116 fi 117 } 118 119 info() { 120 echo -e " ${CYAN}→${NC} $1" 121 } 122 123 # ── Cleanup ─────────────────────────────────────────────────────────────────── 124 cleanup() { 125 echo "" 126 echo -e "${DIM}Stopping any running Radicle nodes...${NC}" 127 RAD_HOME="$RAD_NODE1_HOME" rad node stop 2>/dev/null || true 128 RAD_HOME="$RAD_NODE2_HOME" rad node stop 2>/dev/null || true 129 echo -e "${DIM}Cleaning up $DEMO_DIR ...${NC}" 130 rm -rf "$DEMO_DIR" 131 } 132 trap cleanup EXIT 133 134 # ══════════════════════════════════════════════════════════════════════════════ 135 # Phase 0 — Prerequisites & Build 136 # ══════════════════════════════════════════════════════════════════════════════ 137 phase_start "Phase 0: Prerequisites & Build" 138 139 if ! command -v rad >/dev/null 2>&1; then 140 echo -e " ${RED}✗${NC} 'rad' CLI not found." 141 echo -e " Install Radicle: ${CYAN}https://radicle.xyz${NC}" 142 exit 1 143 fi 144 echo -e " ${GREEN}✓${NC} rad CLI found: $(command -v rad)" 145 146 if [[ ! -x "$AUTHS_BIN" ]]; then 147 info "Building auths (release mode)..." 148 (cd "$REPO_ROOT" && cargo build --release --package auths_cli 2>&1) \ 149 | grep -E "Compiling|Finished" \ 150 | sed 's/^/ /' || true 151 fi 152 assert_ok "auths binary is executable" test -x "$AUTHS_BIN" 153 assert_ok "auths-sign binary is executable" test -x "$AUTHS_SIGN_BIN" 154 155 info "Demo directory: $DEMO_DIR" 156 mkdir -p "$AUTHS_HOME" "$RAD_NODE1_HOME" "$RAD_NODE2_HOME" 157 158 phase_pass 159 160 # ══════════════════════════════════════════════════════════════════════════════ 161 # Phase 1 — Set up two Radicle nodes 162 # ══════════════════════════════════════════════════════════════════════════════ 163 phase_start "Phase 1: Set up two Radicle nodes" 164 165 # Pre-generate deterministic 32-byte seeds. RAD_KEYGEN_SEED lets us control 166 # the Ed25519 seed that `rad auth` uses, so we know the raw bytes without 167 # having to parse the OpenSSH private-key file that Radicle writes to disk. 168 NODE1_SEED_HEX=$(openssl rand -hex 32) 169 NODE2_SEED_HEX=$(openssl rand -hex 32) 170 171 # TEST-ONLY: Write seed bytes to disk so `auths key import --seed-file` can 172 # read them. In production, `rad auth` will pass the seed directly to the 173 # auths SDK without touching the filesystem. This is the only place where 174 # seed material hits disk, and the temp directory is cleaned up on exit. 175 NODE1_SEED="$DEMO_DIR/node1.seed" 176 NODE2_SEED="$DEMO_DIR/node2.seed" 177 echo -n "$NODE1_SEED_HEX" | xxd -r -p > "$NODE1_SEED" 178 echo -n "$NODE2_SEED_HEX" | xxd -r -p > "$NODE2_SEED" 179 180 info "Initializing Radicle node 1..." 181 RAD_HOME="$RAD_NODE1_HOME" RAD_KEYGEN_SEED="$NODE1_SEED_HEX" rad auth --alias node1 2>&1 | sed 's/^/ /' || true 182 183 info "Initializing Radicle node 2..." 184 RAD_HOME="$RAD_NODE2_HOME" RAD_KEYGEN_SEED="$NODE2_SEED_HEX" rad auth --alias node2 2>&1 | sed 's/^/ /' || true 185 186 # Extract DIDs — rad self --did outputs did:key:z6Mk... on stdout 187 NODE1_DID=$(RAD_HOME="$RAD_NODE1_HOME" rad self --did 2>/dev/null | tr -d '[:space:]') 188 NODE2_DID=$(RAD_HOME="$RAD_NODE2_HOME" rad self --did 2>/dev/null | tr -d '[:space:]') 189 190 assert_ok "node 1 DID is not empty" test -n "$NODE1_DID" 191 assert_ok "node 2 DID is not empty" test -n "$NODE2_DID" 192 193 # Derive NIDs (z6Mk...) from DIDs for rad node connect 194 NODE1_NID="${NODE1_DID#did:key:}" 195 NODE2_NID="${NODE2_DID#did:key:}" 196 197 info "Node 1 DID: $NODE1_DID" 198 info "Node 2 DID: $NODE2_DID" 199 200 assert_ok "node 1 seed is 32 bytes" test "$(wc -c < "$NODE1_SEED" | tr -d ' ')" -eq 32 201 assert_ok "node 2 seed is 32 bytes" test "$(wc -c < "$NODE2_SEED" | tr -d ' ')" -eq 32 202 203 phase_pass 204 205 # ══════════════════════════════════════════════════════════════════════════════ 206 # Phase 2 — Create Auths identity 207 # ══════════════════════════════════════════════════════════════════════════════ 208 phase_start "Phase 2: Create Auths identity" 209 210 # Metadata file for the identity 211 cat > "$DEMO_DIR/metadata.json" <<'METAJSON' 212 { 213 "xyz.radicle.project": { 214 "name": "e2e-radicle-demo" 215 }, 216 "profile": { 217 "name": "Radicle E2E Tester" 218 } 219 } 220 METAJSON 221 222 info "Creating identity (RIP-X layout is the default)..." 223 CREATE_OUTPUT=$("$AUTHS_BIN" --repo "$AUTHS_HOME" id create \ 224 --metadata-file "$DEMO_DIR/metadata.json" \ 225 --local-key-alias identity-key \ 226 2>&1) || true 227 echo "$CREATE_OUTPUT" | sed 's/^/ /' 228 229 # Extract Controller DID from create output, fall back to id show 230 CONTROLLER_DID=$(echo "$CREATE_OUTPUT" | grep 'Controller DID:' | head -1 | awk -F': ' '{print $NF}' | tr -d '[:space:]') 231 if [ -z "$CONTROLLER_DID" ]; then 232 ID_SHOW_OUTPUT=$("$AUTHS_BIN" --repo "$AUTHS_HOME" id show 2>&1 || true) 233 CONTROLLER_DID=$(echo "$ID_SHOW_OUTPUT" | grep 'Controller DID' | head -1 | awk -F': ' '{print $NF}' | tr -d '[:space:]') 234 fi 235 236 assert_ok "controller DID is not empty" test -n "$CONTROLLER_DID" 237 info "Controller DID: $CONTROLLER_DID" 238 239 phase_pass 240 241 # ══════════════════════════════════════════════════════════════════════════════ 242 # Phase 3 — Link device 1 (Radicle node 1) 243 # ══════════════════════════════════════════════════════════════════════════════ 244 phase_start "Phase 3: Link device 1 (Radicle node 1)" 245 246 info "Importing node 1 seed into auths keychain..." 247 IMPORT1_OUTPUT=$("$AUTHS_BIN" key import \ 248 --alias node1-key \ 249 --seed-file "$NODE1_SEED" \ 250 --controller-did "$CONTROLLER_DID" \ 251 2>&1) || { echo "$IMPORT1_OUTPUT" | sed 's/^/ /'; phase_fail "key import node1"; exit 1; } 252 echo "$IMPORT1_OUTPUT" | sed 's/^/ /' 253 254 info "Linking node 1 as a device..." 255 LINK1_OUTPUT=$("$AUTHS_BIN" --repo "$AUTHS_HOME" device link \ 256 --identity-key-alias identity-key \ 257 --device-key-alias node1-key \ 258 --device-did "$NODE1_DID" \ 259 --note "Radicle Node 1" \ 260 2>&1) || { echo "$LINK1_OUTPUT" | sed 's/^/ /'; phase_fail "device link node1"; exit 1; } 261 echo "$LINK1_OUTPUT" | sed 's/^/ /' 262 263 # Verify device 1 appears in the list 264 DEVICE_LIST=$("$AUTHS_BIN" --repo "$AUTHS_HOME" device list 2>/dev/null || true) 265 assert_contains "device list contains node 1 DID" "$DEVICE_LIST" "$NODE1_DID" 266 267 phase_pass 268 269 # ══════════════════════════════════════════════════════════════════════════════ 270 # Phase 4 — Link device 2 (Radicle node 2) 271 # ══════════════════════════════════════════════════════════════════════════════ 272 phase_start "Phase 4: Link device 2 (Radicle node 2)" 273 274 info "Importing node 2 seed into auths keychain..." 275 IMPORT2_OUTPUT=$("$AUTHS_BIN" key import \ 276 --alias node2-key \ 277 --seed-file "$NODE2_SEED" \ 278 --controller-did "$CONTROLLER_DID" \ 279 2>&1) || { echo "$IMPORT2_OUTPUT" | sed 's/^/ /'; phase_fail "key import node2"; exit 1; } 280 echo "$IMPORT2_OUTPUT" | sed 's/^/ /' 281 282 info "Linking node 2 as a device..." 283 LINK2_OUTPUT=$("$AUTHS_BIN" --repo "$AUTHS_HOME" device link \ 284 --identity-key-alias identity-key \ 285 --device-key-alias node2-key \ 286 --device-did "$NODE2_DID" \ 287 --note "Radicle Node 2" \ 288 --capabilities sign_commit \ 289 2>&1) || { echo "$LINK2_OUTPUT" | sed 's/^/ /'; phase_fail "device link node2"; exit 1; } 290 echo "$LINK2_OUTPUT" | sed 's/^/ /' 291 292 # Verify both devices appear 293 DEVICE_LIST=$("$AUTHS_BIN" --repo "$AUTHS_HOME" device list 2>/dev/null || true) 294 assert_contains "device list contains node 1" "$DEVICE_LIST" "$NODE1_DID" 295 assert_contains "device list contains node 2" "$DEVICE_LIST" "$NODE2_DID" 296 297 phase_pass 298 299 # ══════════════════════════════════════════════════════════════════════════════ 300 # Phase 5 — Clone existing Radicle project (from node 1) 301 # ══════════════════════════════════════════════════════════════════════════════ 302 phase_start "Phase 5: Clone existing Radicle project" 303 304 PROJECT_DIR="$DEMO_DIR/e2e-project" 305 E2E_PORT1=19876 306 307 info "Project RID: $PROJECT_RID" 308 info "Starting Radicle node 1 for clone (port $E2E_PORT1)..." 309 RAD_HOME="$RAD_NODE1_HOME" rad node start -- --listen 0.0.0.0:$E2E_PORT1 2>&1 | sed 's/^/ /' || true 310 sleep 2 311 312 info "Connecting node 1 to rosa seed..." 313 RAD_HOME="$RAD_NODE1_HOME" rad node connect "${ROSA_NID}@rosa.radicle.xyz:8776" --timeout 10 2>&1 | sed 's/^/ /' || true 314 315 info "Waiting for seed connection to establish..." 316 for i in $(seq 1 10); do 317 if RAD_HOME="$RAD_NODE1_HOME" rad node sessions 2>/dev/null | grep -q "$ROSA_NID"; then 318 echo -e " ${GREEN}✓${NC} Connected to rosa seed" 319 break 320 fi 321 if [ "$i" -eq 10 ]; then 322 echo -e " ${YELLOW}⚠${NC} Seed connection not confirmed after 10s — attempting clone anyway" 323 fi 324 sleep 1 325 done 326 327 info "Cloning project from network..." 328 CLONE_OK=false 329 for attempt in 1 2 3; do 330 CLONE_OUTPUT=$(RAD_HOME="$RAD_NODE1_HOME" rad clone "$PROJECT_RID" "$PROJECT_DIR" --seed "$ROSA_NID" --timeout 30 2>&1) && { 331 CLONE_OK=true 332 echo "$CLONE_OUTPUT" | sed 's/^/ /' 333 break 334 } 335 echo "$CLONE_OUTPUT" | sed 's/^/ /' 336 if [ "$attempt" -lt 3 ]; then 337 info "Clone attempt $attempt failed, retrying in 5s..." 338 sleep 5 339 fi 340 done 341 342 info "Stopping node 1..." 343 RAD_HOME="$RAD_NODE1_HOME" rad node stop 2>/dev/null || true 344 345 if ! $CLONE_OK || [ ! -d "$PROJECT_DIR/.git" ]; then 346 echo -e " ${RED}✗${NC} Clone failed after 3 attempts — is rosa.radicle.xyz reachable?" 347 phase_fail "clone failed" 348 exit 1 349 fi 350 351 # Configure git identity for commits 352 ( 353 cd "$PROJECT_DIR" 354 git config user.name "E2E Tester" 355 git config user.email "e2e@test.local" 356 git config commit.gpgsign false 357 ) 358 359 info "Radicle project RID: $PROJECT_RID" 360 echo -e " ${GREEN}✓${NC} Radicle project cloned" 361 362 phase_pass 363 364 # ══════════════════════════════════════════════════════════════════════════════ 365 # Phase 6 — Verify both devices are authorized 366 # ══════════════════════════════════════════════════════════════════════════════ 367 phase_start "Phase 6: Verify both devices are authorized" 368 369 DEVICE_LIST=$("$AUTHS_BIN" --repo "$AUTHS_HOME" device list 2>/dev/null || true) 370 371 info "Device list output:" 372 echo "$DEVICE_LIST" | sed 's/^/ /' 373 374 assert_contains "node 1 is active" "$DEVICE_LIST" "$NODE1_DID" 375 assert_contains "node 2 is active" "$DEVICE_LIST" "$NODE2_DID" 376 377 # Count active devices (each DID line = 1 device) 378 DEVICE_COUNT=$(echo "$DEVICE_LIST" | grep -c "did:key:" || true) 379 assert_ok "exactly 2 devices listed" test "$DEVICE_COUNT" -eq 2 380 381 phase_pass 382 383 # ══════════════════════════════════════════════════════════════════════════════ 384 # Phase 6b — Verify storage layout 385 # ══════════════════════════════════════════════════════════════════════════════ 386 phase_start "Phase 6b: Verify storage layout" 387 388 # The CLI stores all state under a single packed ref: refs/auths/registry 389 # Identity, attestations, and KEL events are tree paths within that ref. 390 info "Checking packed registry ref..." 391 REGISTRY_REF_EXISTS=$(git -C "$AUTHS_HOME" show-ref refs/auths/registry 2>/dev/null || true) 392 assert_ok "refs/auths/registry exists" test -n "$REGISTRY_REF_EXISTS" 393 394 info "Checking device attestation entries in registry tree..." 395 # Sanitized DID format: did_key_z6Mk... (non-alphanumeric replaced with underscores) 396 NODE1_SANITIZED=$(echo "$NODE1_DID" | sed 's/[^a-zA-Z0-9]/_/g') 397 NODE2_SANITIZED=$(echo "$NODE2_DID" | sed 's/[^a-zA-Z0-9]/_/g') 398 399 # List the full registry tree to find device entries 400 REGISTRY_TREE=$(git -C "$AUTHS_HOME" ls-tree -r --name-only refs/auths/registry 2>/dev/null || true) 401 402 assert_contains "node 1 device entry in registry" "$REGISTRY_TREE" "$NODE1_SANITIZED" 403 assert_contains "node 2 device entry in registry" "$REGISTRY_TREE" "$NODE2_SANITIZED" 404 405 RESOLVE_OK=true 406 407 info "Resolving device 1 DID to controller..." 408 RESOLVE_OUTPUT_1=$("$AUTHS_BIN" --repo "$AUTHS_HOME" device resolve --device-did "$NODE1_DID" 2>&1) || { 409 echo -e " ${RED}✗${NC} device resolve failed for $NODE1_DID" 410 echo "$RESOLVE_OUTPUT_1" | sed 's/^/ /' 411 phase_fail "device 1 resolution" 412 RESOLVE_OK=false 413 } 414 RESOLVED_DID_1=$(echo "$RESOLVE_OUTPUT_1" | tr -d '[:space:]') 415 416 if $RESOLVE_OK; then 417 if [ "$RESOLVED_DID_1" = "$CONTROLLER_DID" ]; then 418 echo -e " ${GREEN}✓${NC} Device 1 resolves to controller DID" 419 else 420 echo -e " ${RED}✗${NC} Device 1 resolved to '$RESOLVED_DID_1', expected '$CONTROLLER_DID'" 421 phase_fail "device 1 resolution mismatch" 422 RESOLVE_OK=false 423 fi 424 fi 425 426 info "Resolving device 2 DID to controller..." 427 RESOLVE_OUTPUT_2=$("$AUTHS_BIN" --repo "$AUTHS_HOME" device resolve --device-did "$NODE2_DID" 2>&1) || { 428 echo -e " ${RED}✗${NC} device resolve failed for $NODE2_DID" 429 echo "$RESOLVE_OUTPUT_2" | sed 's/^/ /' 430 phase_fail "device 2 resolution" 431 RESOLVE_OK=false 432 } 433 RESOLVED_DID_2=$(echo "$RESOLVE_OUTPUT_2" | tr -d '[:space:]') 434 435 if $RESOLVE_OK; then 436 if [ "$RESOLVED_DID_2" = "$CONTROLLER_DID" ]; then 437 echo -e " ${GREEN}✓${NC} Device 2 resolves to controller DID" 438 else 439 echo -e " ${RED}✗${NC} Device 2 resolved to '$RESOLVED_DID_2', expected '$CONTROLLER_DID'" 440 phase_fail "device 2 resolution mismatch" 441 RESOLVE_OK=false 442 fi 443 fi 444 445 if $RESOLVE_OK; then 446 if [ "$RESOLVED_DID_1" = "$RESOLVED_DID_2" ]; then 447 echo -e " ${GREEN}✓${NC} Both devices resolve to the same controller identity" 448 else 449 echo -e " ${RED}✗${NC} Devices resolved to different identities" 450 phase_fail "identity mismatch between devices" 451 RESOLVE_OK=false 452 fi 453 fi 454 455 if $RESOLVE_OK; then 456 phase_pass 457 fi 458 459 # ══════════════════════════════════════════════════════════════════════════════ 460 # Phase 6c — Verify KEL integrity and attestation anchoring 461 # ══════════════════════════════════════════════════════════════════════════════ 462 phase_start "Phase 6c: Verify KEL integrity and attestation anchoring" 463 464 PHASE6C_OK=true 465 466 # Extract KERI prefix from controller DID 467 KERI_PREFIX="${CONTROLLER_DID#did:keri:}" 468 info "KERI prefix: $KERI_PREFIX" 469 470 # Check KEL exists in the registry tree 471 KEL_ENTRIES=$(git -C "$AUTHS_HOME" ls-tree -r --name-only refs/auths/registry 2>/dev/null | grep "kel" || true) 472 if [ -n "$KEL_ENTRIES" ]; then 473 echo -e " ${GREEN}✓${NC} KEL entries found in registry" 474 else 475 echo -e " ${RED}✗${NC} No KEL entries found in registry" 476 PHASE6C_OK=false 477 fi 478 479 # Verify the controller DID is a valid KERI prefix (starts with E for Blake3) 480 if [[ "$KERI_PREFIX" == E* ]]; then 481 echo -e " ${GREEN}✓${NC} KERI prefix has valid Blake3 derivation code" 482 else 483 echo -e " ${RED}✗${NC} KERI prefix does not start with 'E': $KERI_PREFIX" 484 PHASE6C_OK=false 485 fi 486 487 # Verify both device attestations exist in registry tree 488 if [ -n "$REGISTRY_TREE" ]; then 489 ATT_COUNT=$(echo "$REGISTRY_TREE" | grep -c "attestation\|signature" || true) 490 if [ "$ATT_COUNT" -ge 2 ]; then 491 echo -e " ${GREEN}✓${NC} At least 2 attestation/signature entries found ($ATT_COUNT total)" 492 else 493 echo -e " ${RED}✗${NC} Expected at least 2 attestation entries, found $ATT_COUNT" 494 PHASE6C_OK=false 495 fi 496 fi 497 498 # Cross-validate: resolved controller DID matches the KERI prefix in registry 499 if $RESOLVE_OK; then 500 if [[ "$RESOLVED_DID_1" == *"$KERI_PREFIX"* ]]; then 501 echo -e " ${GREEN}✓${NC} Resolved DID contains KERI prefix from registry" 502 else 503 echo -e " ${RED}✗${NC} Resolved DID '$RESOLVED_DID_1' does not contain KERI prefix '$KERI_PREFIX'" 504 PHASE6C_OK=false 505 fi 506 fi 507 508 if $PHASE6C_OK; then 509 phase_pass 510 else 511 phase_fail "KEL integrity verification" 512 fi 513 514 # ══════════════════════════════════════════════════════════════════════════════ 515 # Phase 7 — Signed commits + Radicle patches from both devices 516 # ══════════════════════════════════════════════════════════════════════════════ 517 phase_start "Phase 7: Signed commits + Radicle patches from both devices" 518 519 PHASE7_OK=true 520 521 # ── Export public keys for allowed_signers ───────────────────────────── 522 info "Exporting public keys for both devices..." 523 PUB1=$("$AUTHS_BIN" key export --alias node1-key --passphrase "$AUTHS_PASSPHRASE" --format pub 2>&1) || { 524 echo -e " ${RED}✗${NC} Failed to export node1-key public key" 525 echo "$PUB1" | sed 's/^/ /' 526 phase_fail "node1 key export" 527 PHASE7_OK=false 528 } 529 PUB2=$("$AUTHS_BIN" key export --alias node2-key --passphrase "$AUTHS_PASSPHRASE" --format pub 2>&1) || { 530 echo -e " ${RED}✗${NC} Failed to export node2-key public key" 531 echo "$PUB2" | sed 's/^/ /' 532 phase_fail "node2 key export" 533 PHASE7_OK=false 534 } 535 536 if $PHASE7_OK; then 537 info "Node 1 pubkey: $PUB1" 538 info "Node 2 pubkey: $PUB2" 539 540 # ── Create allowed_signers file ──────────────────────────────────── 541 ALLOWED_SIGNERS="$DEMO_DIR/allowed_signers" 542 echo "e2e@test.local $PUB1" > "$ALLOWED_SIGNERS" 543 echo "e2e@test.local $PUB2" >> "$ALLOWED_SIGNERS" 544 echo -e " ${GREEN}✓${NC} Created allowed_signers file" 545 546 # ── Configure git for SSH signing in project dir ─────────────────── 547 ( 548 cd "$PROJECT_DIR" 549 git config gpg.format ssh 550 git config gpg.ssh.program "$AUTHS_SIGN_BIN" 551 git config gpg.ssh.allowedSignersFile "$ALLOWED_SIGNERS" 552 git config commit.gpgsign true 553 ) 554 echo -e " ${GREEN}✓${NC} Configured git for auths-sign SSH signing" 555 556 # ── Start Radicle nodes ──────────────────────────────────────────── 557 E2E_PORT1=19876 558 E2E_PORT2=19877 559 560 info "Starting Radicle node 1 (port $E2E_PORT1)..." 561 RAD_HOME="$RAD_NODE1_HOME" rad node start -- --listen 0.0.0.0:$E2E_PORT1 2>&1 | sed 's/^/ /' || true 562 sleep 2 563 564 info "Starting Radicle node 2 (port $E2E_PORT2)..." 565 RAD_HOME="$RAD_NODE2_HOME" rad node start -- --listen 0.0.0.0:$E2E_PORT2 2>&1 | sed 's/^/ /' || true 566 sleep 2 567 568 info "Connecting nodes to rosa seed..." 569 RAD_HOME="$RAD_NODE1_HOME" rad node connect "${ROSA_NID}@rosa.radicle.xyz:8776" --timeout 10 2>&1 | sed 's/^/ /' || true 570 RAD_HOME="$RAD_NODE2_HOME" rad node connect "${ROSA_NID}@rosa.radicle.xyz:8776" --timeout 10 2>&1 | sed 's/^/ /' || true 571 572 info "Connecting node 2 to node 1..." 573 RAD_HOME="$RAD_NODE2_HOME" rad node connect "$NODE1_NID@127.0.0.1:$E2E_PORT1" --timeout 10 2>&1 | sed 's/^/ /' || true 574 sleep 1 575 576 # Seed the project on node 1 (should already be seeded from Phase 5 clone) 577 RAD_HOME="$RAD_NODE1_HOME" rad seed "$PROJECT_RID" 2>/dev/null || true 578 579 # ── Device 1: signed commit + push patch ────────────────────────── 580 info "Device 1: creating a signed commit and pushing a patch..." 581 COMMIT1_OUTPUT=$( 582 cd "$PROJECT_DIR" 583 git config user.signingKey "auths:node1-key" 584 git checkout -b feature-device1 2>/dev/null 585 echo "Change from device 1" >> README.md 586 git add README.md 587 git commit -m "Signed commit from device 1" 2>&1 588 ) || { 589 echo -e " ${RED}✗${NC} Device 1 signed commit failed" 590 echo "$COMMIT1_OUTPUT" | sed 's/^/ /' 591 phase_fail "device 1 signed commit" 592 PHASE7_OK=false 593 } 594 595 if $PHASE7_OK; then 596 echo "$COMMIT1_OUTPUT" | sed 's/^/ /' 597 598 info "Verifying device 1 commit signature..." 599 VERIFY1_OUTPUT=$(cd "$PROJECT_DIR" && git verify-commit HEAD 2>&1) || { 600 echo -e " ${RED}✗${NC} Device 1 commit verification failed" 601 echo "$VERIFY1_OUTPUT" | sed 's/^/ /' 602 phase_fail "device 1 signature verification" 603 PHASE7_OK=false 604 } 605 if $PHASE7_OK; then 606 echo -e " ${GREEN}✓${NC} Device 1 signed commit verified" 607 fi 608 609 info "Pushing device 1 patch to Radicle..." 610 PUSH1_OUTPUT=$( 611 cd "$PROJECT_DIR" 612 export RAD_HOME="$RAD_NODE1_HOME" 613 git push rad HEAD:refs/patches 2>&1 614 ) || true 615 echo "$PUSH1_OUTPUT" | sed 's/^/ /' 616 617 PATCH1_ID=$(echo "$PUSH1_OUTPUT" | grep -oE 'Patch [0-9a-f]{40} opened' | awk '{print $2}') || true 618 if [ -n "$PATCH1_ID" ]; then 619 PATCH1_URL="https://app.radicle.xyz/nodes/rosa.radicle.xyz/${PROJECT_RID}/patches/${PATCH1_ID}" 620 echo -e " ${GREEN}✓${NC} Device 1 patch created: ${CYAN}${PATCH1_ID}${NC}" 621 echo -e " ${DIM}URL: ${PATCH1_URL}${NC}" 622 else 623 echo -e " ${YELLOW}⚠${NC} Could not extract device 1 patch ID" 624 fi 625 fi 626 627 # ── Device 2: clone, signed commit + push patch ─────────────────── 628 if $PHASE7_OK; then 629 NODE2_PROJECT="$DEMO_DIR/e2e-project-node2" 630 631 info "Device 2: cloning project..." 632 RAD_HOME="$RAD_NODE2_HOME" rad clone "$PROJECT_RID" "$NODE2_PROJECT" --seed "$NODE1_NID" --timeout 15 2>&1 | sed 's/^/ /' || true 633 634 if [ -d "$NODE2_PROJECT" ]; then 635 info "Device 2: creating a signed commit and pushing a patch..." 636 COMMIT2_OUTPUT=$( 637 cd "$NODE2_PROJECT" 638 git config user.name "E2E Tester Node2" 639 git config user.email "e2e@test.local" 640 git config gpg.format ssh 641 git config gpg.ssh.program "$AUTHS_SIGN_BIN" 642 git config gpg.ssh.allowedSignersFile "$ALLOWED_SIGNERS" 643 git config commit.gpgsign true 644 git config user.signingKey "auths:node2-key" 645 git checkout -b feature-device2 2>/dev/null 646 echo "Change from device 2" >> README.md 647 git add README.md 648 git commit -m "Signed commit from device 2" 2>&1 649 ) || { 650 echo -e " ${RED}✗${NC} Device 2 signed commit failed" 651 echo "$COMMIT2_OUTPUT" | sed 's/^/ /' 652 phase_fail "device 2 signed commit" 653 PHASE7_OK=false 654 } 655 656 if $PHASE7_OK; then 657 echo "$COMMIT2_OUTPUT" | sed 's/^/ /' 658 659 info "Verifying device 2 commit signature..." 660 VERIFY2_OUTPUT=$(cd "$NODE2_PROJECT" && git verify-commit HEAD 2>&1) || { 661 echo -e " ${RED}✗${NC} Device 2 commit verification failed" 662 echo "$VERIFY2_OUTPUT" | sed 's/^/ /' 663 phase_fail "device 2 signature verification" 664 PHASE7_OK=false 665 } 666 if $PHASE7_OK; then 667 echo -e " ${GREEN}✓${NC} Device 2 signed commit verified" 668 fi 669 670 info "Pushing device 2 patch to Radicle..." 671 PUSH2_OUTPUT=$( 672 cd "$NODE2_PROJECT" 673 export RAD_HOME="$RAD_NODE2_HOME" 674 git push rad HEAD:refs/patches 2>&1 675 ) || true 676 echo "$PUSH2_OUTPUT" | sed 's/^/ /' 677 678 PATCH2_ID=$(echo "$PUSH2_OUTPUT" | grep -oE 'Patch [0-9a-f]{40} opened' | awk '{print $2}') || true 679 if [ -n "$PATCH2_ID" ]; then 680 PATCH2_URL="https://app.radicle.xyz/nodes/rosa.radicle.xyz/${PROJECT_RID}/patches/${PATCH2_ID}" 681 echo -e " ${GREEN}✓${NC} Device 2 patch created: ${CYAN}${PATCH2_ID}${NC}" 682 echo -e " ${DIM}URL: ${PATCH2_URL}${NC}" 683 else 684 echo -e " ${YELLOW}⚠${NC} Could not extract device 2 patch ID" 685 fi 686 fi 687 else 688 echo -e " ${YELLOW}⚠${NC} Node 2 clone failed — skipping device 2 patch" 689 fi 690 fi 691 692 # ── Sync to rosa (best effort) ──────────────────────────────────── 693 info "Syncing to rosa.radicle.xyz (best effort)..." 694 RAD_HOME="$RAD_NODE1_HOME" rad sync --announce "$PROJECT_RID" --seed "$ROSA_NID" --timeout 15 2>&1 | sed 's/^/ /' || true 695 RAD_HOME="$RAD_NODE2_HOME" rad sync --announce "$PROJECT_RID" --seed "$ROSA_NID" --timeout 15 2>&1 | sed 's/^/ /' || true 696 697 # Print summary of patch URLs 698 echo "" 699 echo -e " ${BOLD}Patch URLs (rosa.radicle.xyz — permissive public seed):${NC}" 700 [ -n "${PATCH1_URL:-}" ] && echo -e " Device 1: ${CYAN}${PATCH1_URL}${NC}" 701 [ -n "${PATCH2_URL:-}" ] && echo -e " Device 2: ${CYAN}${PATCH2_URL}${NC}" 702 echo -e " ${DIM}Note: URLs require successful sync. If behind NAT, the seed may not be able to fetch from you.${NC}" 703 704 # Verify at least device 1 pushed a patch 705 if [ -n "${PATCH1_ID:-}" ]; then 706 echo -e " ${GREEN}✓${NC} At least one signed patch pushed successfully" 707 else 708 if $PHASE7_OK; then 709 phase_fail "no patches created" 710 PHASE7_OK=false 711 fi 712 fi 713 714 # ── Stop nodes ───────────────────────────────────────────────────── 715 info "Stopping Radicle nodes..." 716 RAD_HOME="$RAD_NODE1_HOME" rad node stop 2>/dev/null || true 717 RAD_HOME="$RAD_NODE2_HOME" rad node stop 2>/dev/null || true 718 fi 719 720 if $PHASE7_OK; then 721 phase_pass 722 fi 723 724 # ══════════════════════════════════════════════════════════════════════════════ 725 # Phase 8 — Revoke device 2 726 # ══════════════════════════════════════════════════════════════════════════════ 727 phase_start "Phase 8: Revoke device 2" 728 729 info "Revoking node 2..." 730 REVOKE_OUTPUT=$("$AUTHS_BIN" --repo "$AUTHS_HOME" device revoke \ 731 --device-did "$NODE2_DID" \ 732 --identity-key-alias identity-key \ 733 --note "E2E revocation test" \ 734 2>&1) || { echo "$REVOKE_OUTPUT" | sed 's/^/ /'; phase_fail "device revoke node2"; exit 1; } 735 echo "$REVOKE_OUTPUT" | sed 's/^/ /' 736 737 # Without --include-revoked, node 2 should not appear 738 ACTIVE_DEVICES=$("$AUTHS_BIN" --repo "$AUTHS_HOME" device list 2>/dev/null || true) 739 assert_contains "node 1 still active" "$ACTIVE_DEVICES" "$NODE1_DID" 740 assert_not_contains "node 2 not in active list" "$ACTIVE_DEVICES" "$NODE2_DID" 741 742 # With --include-revoked, node 2 should appear as revoked 743 ALL_DEVICES=$("$AUTHS_BIN" --repo "$AUTHS_HOME" device list --include-revoked 2>/dev/null || true) 744 assert_contains "node 2 shows as revoked" "$ALL_DEVICES" "$NODE2_DID" 745 746 info "All-devices list (including revoked):" 747 echo "$ALL_DEVICES" | sed 's/^/ /' 748 749 # Verify device 1 still resolves to controller after revocation of device 2 750 info "Verifying device 1 still resolves post-revocation..." 751 POST_REVOKE_RESOLVE=$("$AUTHS_BIN" --repo "$AUTHS_HOME" device resolve --device-did "$NODE1_DID" 2>&1 || true) 752 POST_REVOKE_DID=$(echo "$POST_REVOKE_RESOLVE" | tr -d '[:space:]') 753 if [ "$POST_REVOKE_DID" = "$CONTROLLER_DID" ]; then 754 echo -e " ${GREEN}✓${NC} Device 1 still resolves to controller after device 2 revocation" 755 else 756 echo -e " ${RED}✗${NC} Device 1 resolution changed after revocation: '$POST_REVOKE_DID'" 757 fi 758 759 phase_pass 760 761 # ══════════════════════════════════════════════════════════════════════════════ 762 # Phase 9 — HTTP API assertions (requires modified radicle-httpd) 763 # ══════════════════════════════════════════════════════════════════════════════ 764 phase_start "Phase 9: HTTP API assertions" 765 766 PHASE9_OK=true 767 HTTPD_PORT=17899 768 769 info "Starting Radicle node 1 for HTTP API testing..." 770 RAD_HOME="$RAD_NODE1_HOME" rad node start -- --listen 0.0.0.0:$E2E_PORT1 2>&1 | sed 's/^/ /' || true 771 sleep 2 772 773 # Detect httpd port from node config, fall back to default 8080 774 DETECTED_PORT=$(RAD_HOME="$RAD_NODE1_HOME" rad config 2>/dev/null | grep -oP '"port":\s*\K[0-9]+' | head -1 || echo "8080") 775 HTTPD_URL="http://127.0.0.1:${DETECTED_PORT}" 776 777 info "Testing httpd at $HTTPD_URL ..." 778 779 # Check if the modified httpd serves the delegates endpoint 780 USER_RESPONSE=$(curl -sf "$HTTPD_URL/api/v1/delegates/$CONTROLLER_DID" 2>/dev/null) || { 781 info "Modified delegates endpoint not available — skipping API assertions" 782 RAD_HOME="$RAD_NODE1_HOME" rad node stop 2>/dev/null || true 783 phase_pass 784 PHASE9_OK="skipped" 785 } 786 787 if [ "$PHASE9_OK" = "true" ]; then 788 # ── Delegates endpoint assertions ────────────────────────────────── 789 info "GET /v1/delegates/$CONTROLLER_DID response:" 790 echo "$USER_RESPONSE" | sed 's/^/ /' 791 792 assert_contains "user response has controllerDid" "$USER_RESPONSE" "controllerDid" 793 assert_contains "user response has isKeri: true" "$USER_RESPONSE" '"isKeri":true' 794 assert_contains "user response has devices array" "$USER_RESPONSE" '"devices":' 795 796 # ── KEL endpoint assertions ──────────────────────────────────────── 797 info "Testing KEL endpoint..." 798 KEL_RESPONSE=$(curl -sf "$HTTPD_URL/api/v1/identity/$CONTROLLER_DID/kel" 2>/dev/null) || { 799 echo -e " ${RED}✗${NC} KEL endpoint failed" 800 PHASE9_OK=false 801 } 802 803 if [ "$PHASE9_OK" = "true" ]; then 804 # KEL should be a non-empty JSON array 805 assert_contains "KEL response is a JSON array" "$KEL_RESPONSE" "[" 806 KEL_LENGTH=$(echo "$KEL_RESPONSE" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "0") 807 if [ "$KEL_LENGTH" -gt 0 ]; then 808 echo -e " ${GREEN}✓${NC} KEL contains $KEL_LENGTH events" 809 else 810 echo -e " ${RED}✗${NC} KEL is empty" 811 PHASE9_OK=false 812 fi 813 fi 814 815 # ── Attestations endpoint assertions ─────────────────────────────── 816 info "Testing attestations endpoint..." 817 ATT_RESPONSE=$(curl -sf "$HTTPD_URL/api/v1/identity/$CONTROLLER_DID/attestations" 2>/dev/null) || { 818 echo -e " ${RED}✗${NC} Attestations endpoint failed" 819 PHASE9_OK=false 820 } 821 822 if [ "$PHASE9_OK" = "true" ]; then 823 assert_contains "attestations response is a JSON array" "$ATT_RESPONSE" "[" 824 ATT_COUNT=$(echo "$ATT_RESPONSE" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "0") 825 826 # After Phase 8 revocation, we expect 1 active device (node1 active, node2 revoked). 827 # The attestation endpoint may return all attestations (including revoked) or only active. 828 if [ "$ATT_COUNT" -gt 0 ]; then 829 echo -e " ${GREEN}✓${NC} Attestations endpoint returned $ATT_COUNT attestation(s)" 830 else 831 echo -e " ${RED}✗${NC} Attestations response is empty" 832 PHASE9_OK=false 833 fi 834 fi 835 836 # ── Stop node ────────────────────────────────────────────────────── 837 info "Stopping node..." 838 RAD_HOME="$RAD_NODE1_HOME" rad node stop 2>/dev/null || true 839 840 if [ "$PHASE9_OK" = "true" ]; then 841 phase_pass 842 else 843 phase_fail "HTTP API assertions" 844 fi 845 fi 846 847 # ══════════════════════════════════════════════════════════════════════════════ 848 # Summary 849 # ══════════════════════════════════════════════════════════════════════════════ 850 echo "" 851 echo -e "${CYAN}╔════════════════════════════════════════════════════════════╗${NC}" 852 echo -e "${CYAN}║${NC}${BOLD} Radicle Multi-Device E2E — Summary ${NC}${CYAN}║${NC}" 853 echo -e "${CYAN}╚════════════════════════════════════════════════════════════╝${NC}" 854 echo "" 855 856 FAILURES=0 857 for result in "${PHASE_RESULTS[@]}"; do 858 if [[ "$result" == PASS:* ]]; then 859 echo -e " ${GREEN}✓${NC} ${result#PASS: }" 860 else 861 echo -e " ${RED}✗${NC} ${result#FAIL: }" 862 FAILURES=$((FAILURES + 1)) 863 fi 864 done 865 866 echo "" 867 if [ "$FAILURES" -eq 0 ]; then 868 echo -e "${GREEN}${BOLD}All ${#PHASE_RESULTS[@]} phases passed.${NC}" 869 exit 0 870 else 871 echo -e "${RED}${BOLD}$FAILURES of ${#PHASE_RESULTS[@]} phases failed.${NC}" 872 exit 1 873 fi