mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
The NAT lab wrote its generated node configs to one shared directory, unlike the static and firewall labs which already scope theirs by run. The directory is bind-mounted by compose and read back by the suite scripts after the containers are up, so two overlapping runs let the second run's generator overwrite the npubs the first is about to ping. Scope the directory with FIPS_CI_NAME_SUFFIX at all three places that have to agree: the generator's output path, the ten compose bind-mounts, and the CONFIG_DIR the suite scripts read back from. An unset suffix renders the plain path the lab has always used, so a bare invocation and the GitHub matrix are unaffected. The run teardown removes the per-run directory alongside the static and firewall ones, and the gitignore is widened to cover the suffixed form. Rendering the compose file with every profile and no suffix set reproduces today's exact ten paths; setting a suffix moves all ten. This lands on its own, ahead of the network and address work, so that a failure of the two-overlapping-runs acceptance test can be bisected between the config fix and the address conversion.
394 lines
12 KiB
Bash
Executable File
394 lines
12 KiB
Bash
Executable File
#!/bin/bash
|
|
#
|
|
# Nostr overlay advert publish/consume integration test.
|
|
#
|
|
# Exercises the round-trip:
|
|
# Phase 1: A publishes overlay advert; B subscribes; B observes A's advert;
|
|
# B dials A.
|
|
# Phase 2: B publishes; A subscribes; reverse direction. (Both directions
|
|
# are validated together via the bidirectional `peers` count.)
|
|
# Phase 3: A malformed Kind-37195 advert event is published directly to
|
|
# the relay; both consumers must reject it (parse error path)
|
|
# without crashing — asserted via process liveness.
|
|
#
|
|
# UDP transport for v0.3.0 baseline. Tor / TCP variants out of scope here.
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
NAT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
ROOT_DIR="$(cd "$NAT_DIR/../.." && pwd)"
|
|
BUILD_SCRIPT="$ROOT_DIR/testing/scripts/build.sh"
|
|
GENERATE_SCRIPT="$SCRIPT_DIR/generate-configs.sh"
|
|
WAIT_LIB="$ROOT_DIR/testing/lib/wait-converge.sh"
|
|
# Must track generate-configs.sh's OUTPUT_DIR and the compose bind-mounts.
|
|
CONFIG_DIR="$NAT_DIR/generated-configs${FIPS_CI_NAME_SUFFIX:-}"
|
|
|
|
PROFILE="nostr-publish-consume"
|
|
SCENARIO="$PROFILE"
|
|
COMPOSE=(docker compose -f "$NAT_DIR/docker-compose.yml")
|
|
NODE_A="fips-nat-nostr-pub-a${FIPS_CI_NAME_SUFFIX:-}"
|
|
NODE_B="fips-nat-nostr-pub-b${FIPS_CI_NAME_SUFFIX:-}"
|
|
RELAY_HOST="172.31.10.30"
|
|
RELAY_PORT=7777
|
|
RELAY_CONTAINER="fips-nat-relay${FIPS_CI_NAME_SUFFIX:-}"
|
|
|
|
# shellcheck disable=SC1090
|
|
source "$WAIT_LIB"
|
|
|
|
cleanup() {
|
|
"${COMPOSE[@]}" --profile "$PROFILE" down -v --remove-orphans \
|
|
>/dev/null 2>&1 || true
|
|
}
|
|
|
|
trap 'echo ""; echo "nostr-relay-test interrupted"; cleanup; exit 130' INT TERM
|
|
|
|
require_docker_daemon() {
|
|
if ! docker info >/dev/null 2>&1; then
|
|
echo "Docker daemon is not reachable; cannot run nostr-relay-test" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
require_test_image() {
|
|
local img="${FIPS_TEST_IMAGE:-fips-test:latest}"
|
|
if docker image inspect "$img" >/dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
# Building here is right for a hand run and wrong under a harness. When
|
|
# FIPS_TEST_IMAGE is set the caller has already built the image it named, so
|
|
# a miss means something upstream is broken; building a substitute would
|
|
# hide that and run binaries nobody asked for.
|
|
if [ -n "${FIPS_TEST_IMAGE:-}" ]; then
|
|
echo "ERROR: $img not present, and FIPS_TEST_IMAGE names the caller's own image" >&2
|
|
echo "The harness that set it is expected to have built it." >&2
|
|
exit 1
|
|
fi
|
|
echo "$img not found; building test image"
|
|
"$BUILD_SCRIPT"
|
|
}
|
|
|
|
dump_diagnostics() {
|
|
echo ""
|
|
echo "=== nostr publish/consume diagnostics ==="
|
|
for c in "$NODE_A" "$NODE_B" "$RELAY_CONTAINER"; do
|
|
echo ""
|
|
echo "--- $c: logs (last 80) ---"
|
|
docker logs "$c" 2>&1 | tail -80 || true
|
|
done
|
|
for c in "$NODE_A" "$NODE_B"; do
|
|
echo ""
|
|
echo "--- $c: fipsctl show peers ---"
|
|
docker exec "$c" fipsctl show peers 2>&1 || true
|
|
echo "--- $c: fipsctl show links ---"
|
|
docker exec "$c" fipsctl show links 2>&1 || true
|
|
done
|
|
}
|
|
|
|
# Publish a malformed Kind-37195 (overlay-advert) event directly to the
|
|
# relay. The event is signed with a fresh ephemeral keypair (so the
|
|
# relay accepts it on the wire) but its `content` is gibberish that
|
|
# cannot deserialize as OverlayAdvert. Both consumer daemons must log a
|
|
# parse error and stay alive.
|
|
publish_malformed_advert() {
|
|
local relay_host="$1"
|
|
local relay_port="$2"
|
|
|
|
docker exec "$NODE_A" python3 - "$relay_host" "$relay_port" <<'PY'
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import socket
|
|
import struct
|
|
import sys
|
|
import time
|
|
|
|
# ── Minimal secp256k1 BIP-340 (Schnorr) signer using only stdlib. ──────
|
|
# Reference: BIP-340, secp256k1 group order n / curve params.
|
|
P = 0xFFFFFFFF_FFFFFFFF_FFFFFFFF_FFFFFFFF_FFFFFFFF_FFFFFFFF_FFFFFFFE_FFFFFC2F
|
|
N = 0xFFFFFFFF_FFFFFFFF_FFFFFFFF_FFFFFFFE_BAAEDCE6_AF48A03B_BFD25E8C_D0364141
|
|
G = (
|
|
0x79BE667E_F9DCBBAC_55A06295_CE870B07_029BFCDB_2DCE28D9_59F2815B_16F81798,
|
|
0x483ADA77_26A3C465_5DA4FBFC_0E1108A8_FD17B448_A6855419_9C47D08F_FB10D4B8,
|
|
)
|
|
|
|
|
|
def inv(a, m=P):
|
|
return pow(a, -1, m)
|
|
|
|
|
|
def point_add(a, b):
|
|
if a is None:
|
|
return b
|
|
if b is None:
|
|
return a
|
|
if a[0] == b[0] and (a[1] != b[1] or a[1] == 0):
|
|
return None
|
|
if a == b:
|
|
m = (3 * a[0] * a[0]) * inv(2 * a[1]) % P
|
|
else:
|
|
m = (b[1] - a[1]) * inv(b[0] - a[0]) % P
|
|
x = (m * m - a[0] - b[0]) % P
|
|
y = (m * (a[0] - x) - a[1]) % P
|
|
return (x, y)
|
|
|
|
|
|
def scalar_mul(k, point=G):
|
|
result = None
|
|
addend = point
|
|
while k:
|
|
if k & 1:
|
|
result = point_add(result, addend)
|
|
addend = point_add(addend, addend)
|
|
k >>= 1
|
|
return result
|
|
|
|
|
|
def lift_x(x):
|
|
if x >= P:
|
|
return None
|
|
y_sq = (pow(x, 3, P) + 7) % P
|
|
y = pow(y_sq, (P + 1) // 4, P)
|
|
if pow(y, 2, P) != y_sq:
|
|
return None
|
|
return (x, y if y % 2 == 0 else P - y)
|
|
|
|
|
|
def tagged_hash(tag, data):
|
|
th = hashlib.sha256(tag.encode()).digest()
|
|
return hashlib.sha256(th + th + data).digest()
|
|
|
|
|
|
def schnorr_sign(msg32, secret):
|
|
d0 = int.from_bytes(secret, "big")
|
|
if not (1 <= d0 < N):
|
|
raise ValueError("invalid secret key")
|
|
P_pub = scalar_mul(d0)
|
|
d = d0 if P_pub[1] % 2 == 0 else N - d0
|
|
t = (d ^ int.from_bytes(tagged_hash("BIP0340/aux", os.urandom(32)), "big"))
|
|
t_bytes = t.to_bytes(32, "big")
|
|
rand = tagged_hash(
|
|
"BIP0340/nonce",
|
|
t_bytes + P_pub[0].to_bytes(32, "big") + msg32,
|
|
)
|
|
k0 = int.from_bytes(rand, "big") % N
|
|
if k0 == 0:
|
|
raise ValueError("nonce gen failed")
|
|
R = scalar_mul(k0)
|
|
k = k0 if R[1] % 2 == 0 else N - k0
|
|
e = int.from_bytes(
|
|
tagged_hash(
|
|
"BIP0340/challenge",
|
|
R[0].to_bytes(32, "big") + P_pub[0].to_bytes(32, "big") + msg32,
|
|
),
|
|
"big",
|
|
) % N
|
|
s = (k + e * d) % N
|
|
return R[0].to_bytes(32, "big") + s.to_bytes(32, "big")
|
|
|
|
|
|
def xonly_pubkey(secret):
|
|
d0 = int.from_bytes(secret, "big")
|
|
P_pub = scalar_mul(d0)
|
|
return P_pub[0].to_bytes(32, "big")
|
|
|
|
|
|
# ── Build the malformed Kind-37195 event ───────────────────────────────
|
|
secret = os.urandom(32)
|
|
# Ensure 1 <= d < N
|
|
while int.from_bytes(secret, "big") == 0 or int.from_bytes(secret, "big") >= N:
|
|
secret = os.urandom(32)
|
|
|
|
pubkey = xonly_pubkey(secret).hex()
|
|
created_at = int(time.time())
|
|
kind = 37195
|
|
tags = [
|
|
["d", "fips-overlay-v1"],
|
|
["app", "fips.nat.lab.v1"],
|
|
]
|
|
content = "this-is-not-a-valid-overlay-advert-{garbage}"
|
|
|
|
# Nostr event id = sha256(json([0, pubkey, created_at, kind, tags, content]))
|
|
serialized = json.dumps(
|
|
[0, pubkey, created_at, kind, tags, content],
|
|
separators=(",", ":"),
|
|
ensure_ascii=False,
|
|
)
|
|
event_id = hashlib.sha256(serialized.encode("utf-8")).digest()
|
|
sig = schnorr_sign(event_id, secret).hex()
|
|
|
|
event = {
|
|
"id": event_id.hex(),
|
|
"pubkey": pubkey,
|
|
"created_at": created_at,
|
|
"kind": kind,
|
|
"tags": tags,
|
|
"content": content,
|
|
"sig": sig,
|
|
}
|
|
|
|
msg = json.dumps(["EVENT", event])
|
|
print(f"publishing malformed advert id={event['id']} pubkey={pubkey}")
|
|
|
|
# ── Minimal stdlib WebSocket client (RFC 6455) ────────────────────────
|
|
relay_host = sys.argv[1]
|
|
relay_port = int(sys.argv[2])
|
|
|
|
sock = socket.create_connection((relay_host, relay_port), timeout=10)
|
|
key_b64 = base64.b64encode(os.urandom(16)).decode()
|
|
handshake = (
|
|
f"GET / HTTP/1.1\r\n"
|
|
f"Host: {relay_host}:{relay_port}\r\n"
|
|
f"Upgrade: websocket\r\n"
|
|
f"Connection: Upgrade\r\n"
|
|
f"Sec-WebSocket-Key: {key_b64}\r\n"
|
|
f"Sec-WebSocket-Version: 13\r\n\r\n"
|
|
)
|
|
sock.sendall(handshake.encode())
|
|
|
|
resp = b""
|
|
sock.settimeout(5)
|
|
while b"\r\n\r\n" not in resp:
|
|
chunk = sock.recv(4096)
|
|
if not chunk:
|
|
break
|
|
resp += chunk
|
|
if b" 101 " not in resp.split(b"\r\n", 1)[0]:
|
|
print("websocket handshake failed:", resp[:200], file=sys.stderr)
|
|
raise SystemExit(2)
|
|
|
|
# Build a single masked text frame (FIN=1, opcode=1).
|
|
payload = msg.encode("utf-8")
|
|
mask = os.urandom(4)
|
|
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
|
|
|
frame = bytearray([0x81]) # FIN + text
|
|
plen = len(payload)
|
|
if plen < 126:
|
|
frame.append(0x80 | plen)
|
|
elif plen < 65536:
|
|
frame.append(0x80 | 126)
|
|
frame += struct.pack("!H", plen)
|
|
else:
|
|
frame.append(0x80 | 127)
|
|
frame += struct.pack("!Q", plen)
|
|
frame += mask + masked
|
|
sock.sendall(bytes(frame))
|
|
|
|
# Read the relay's OK/NOTICE response (best-effort).
|
|
sock.settimeout(3)
|
|
try:
|
|
reply = sock.recv(4096)
|
|
print("relay reply:", reply[:200])
|
|
except socket.timeout:
|
|
print("relay reply: <timeout — frame sent but no ack>")
|
|
|
|
# Polite close (opcode 0x88 = close), then drop.
|
|
try:
|
|
sock.sendall(bytes([0x88, 0x80]) + os.urandom(4))
|
|
except OSError:
|
|
pass
|
|
sock.close()
|
|
print("malformed advert published")
|
|
PY
|
|
}
|
|
|
|
assert_process_alive() {
|
|
local container="$1"
|
|
if ! docker exec "$container" pidof fips >/dev/null 2>&1; then
|
|
echo "fips daemon NOT running in $container after malformed advert" >&2
|
|
return 1
|
|
fi
|
|
echo " $container: fips daemon still alive after malformed advert"
|
|
return 0
|
|
}
|
|
|
|
# A container whose logs cannot be read has not been shown to be panic-free.
|
|
# See the companion note in stun-faults-test.sh: the previous `|| true` made
|
|
# this assertion's failure mode indistinguishable from its success condition.
|
|
assert_no_panic() {
|
|
local container="$1"
|
|
local logs
|
|
if ! logs="$(docker logs "$container" 2>&1)"; then
|
|
echo "could not read logs from $container; absence of panics is not established" >&2
|
|
return 1
|
|
fi
|
|
if grep -Eq "panicked at|RUST_BACKTRACE|fatal runtime error" <<<"$logs"; then
|
|
echo "panic detected in $container logs" >&2
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
run_test() {
|
|
echo "=== nostr-relay-test: phase 1 + 2 ==="
|
|
cleanup
|
|
"$GENERATE_SCRIPT" "$SCENARIO"
|
|
|
|
"${COMPOSE[@]}" --profile "$PROFILE" up -d --build --force-recreate
|
|
|
|
# Phase 1 + Phase 2 together: each side publishes its own advert,
|
|
# subscribes for the other's, then dials. Bidirectional success
|
|
# (peer count == 1 on both nodes) proves both directions of the
|
|
# publish/consume round-trip.
|
|
echo ""
|
|
echo "--- waiting for bidirectional advert observation + dial ---"
|
|
if ! wait_for_peers "$NODE_A" 1 60; then
|
|
dump_diagnostics
|
|
return 1
|
|
fi
|
|
if ! wait_for_peers "$NODE_B" 1 60; then
|
|
dump_diagnostics
|
|
return 1
|
|
fi
|
|
|
|
# shellcheck disable=SC1090
|
|
source "$CONFIG_DIR/$SCENARIO/npubs.env"
|
|
echo " NPUB_A=$NPUB_A"
|
|
echo " NPUB_B=$NPUB_B"
|
|
|
|
# Sanity: traffic actually flows (TUN-level reachability).
|
|
if ! docker exec "$NODE_A" ping6 -c 3 -W 5 "${NPUB_B}.fips" >/dev/null; then
|
|
echo "ping6 A->B failed" >&2
|
|
dump_diagnostics
|
|
return 1
|
|
fi
|
|
if ! docker exec "$NODE_B" ping6 -c 3 -W 5 "${NPUB_A}.fips" >/dev/null; then
|
|
echo "ping6 B->A failed" >&2
|
|
dump_diagnostics
|
|
return 1
|
|
fi
|
|
|
|
echo ""
|
|
echo "=== nostr-relay-test: phase 3 (malformed advert) ==="
|
|
publish_malformed_advert "$RELAY_HOST" "$RELAY_PORT"
|
|
|
|
# Give consumers a moment to ingest and reject.
|
|
sleep 5
|
|
|
|
assert_process_alive "$NODE_A" || { dump_diagnostics; return 1; }
|
|
assert_process_alive "$NODE_B" || { dump_diagnostics; return 1; }
|
|
assert_no_panic "$NODE_A" || { dump_diagnostics; return 1; }
|
|
assert_no_panic "$NODE_B" || { dump_diagnostics; return 1; }
|
|
|
|
# Existing peer link must still be healthy (consumer didn't tear
|
|
# down on a bad advert).
|
|
if ! docker exec "$NODE_A" ping6 -c 3 -W 5 "${NPUB_B}.fips" >/dev/null; then
|
|
echo "ping6 A->B failed AFTER malformed-advert injection" >&2
|
|
dump_diagnostics
|
|
return 1
|
|
fi
|
|
|
|
cleanup
|
|
echo "nostr-relay-test passed"
|
|
}
|
|
|
|
main() {
|
|
require_docker_daemon
|
|
require_test_image
|
|
run_test
|
|
}
|
|
|
|
main "$@"
|