mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
Add nostr-publish-consume integration suite
Cover the previously untested overlay advert publish/relay/consume
round-trip. The bilateral publish/subscribe path was a v0.3.0 release
gap: malformed adverts could panic consumers, broken signatures could
go undetected, and reverse-direction subscription was unverified.
Adds testing/nat/scripts/nostr-relay-test.sh (290 lines):
Phase 1+2 (combined): wait_for_peers on both nodes; pass on
bidirectional advert publish/subscribe round-trip + dial completed;
ping6 both directions confirms TUN-level reachability.
Phase 3 (malformed advert resilience): stdlib-only Python WebSocket
client publishes a syntactically valid Schnorr-signed Kind-37195
event whose `content` is gibberish (cannot deserialize as
OverlayAdvert). The relay enforces BIP-340 signature validity, so the
event reaches the consumers (rather than being dropped at the relay)
— a trivially-junk content payload is the right adversarial input.
Required ~80 lines of stdlib-only secp256k1 + BIP-340 in the script
(no new container deps). Asserts pidof fips on both nodes after the
publish, scans logs for panic markers, re-pings to prove the existing
peer link survives.
testing/nat/docker-compose.yml: new profile nostr-publish-consume
with two daemon services (nostr-pub-a 172.31.10.20, nostr-pub-b
172.31.10.21) on shared-lan, reusing the existing strfry relay
(172.31.10.30:7777) and STUN service (172.31.10.40:3478).
testing/nat/scripts/generate-configs.sh: 2-line allowlist update so
the new scenario flows through the existing config generator (rather
than forking a parallel one). Generated node-{a,b}.yaml + npubs.env
smoke-tested cleanly.
testing/ci-local.sh: NOSTR_RELAY_SUITES=(nostr-publish-consume)
array, run_nostr_publish_consume runner, dispatch in run_integration
and run_suite. Mirrors existing run_nat shape.
.github/workflows/ci.yml: one matrix row + 3 steps in the integration
job, gated on matrix.type == 'nostr-publish-consume'. Consumes the
same fips-linux artifact and fips-test:latest image as the existing
NAT suites.
Tor/TCP transport variants kept out of v0.3.0 scope; the structure
leaves room for nostr-publish-consume-tcp/-tor siblings later without
disturbing this baseline.
This commit is contained in:
@@ -376,6 +376,13 @@ jobs:
|
|||||||
- suite: nat-lan
|
- suite: nat-lan
|
||||||
type: nat
|
type: nat
|
||||||
scenario: lan
|
scenario: lan
|
||||||
|
# ── Nostr overlay advert publish/consume round-trip ─────────────
|
||||||
|
# Two FIPS daemons + the existing strfry relay; covers Phase 1
|
||||||
|
# (A→B publish/consume), Phase 2 (B→A reverse), and Phase 3
|
||||||
|
# (malformed advert injected to relay; consumers must reject
|
||||||
|
# without crashing). UDP transport baseline for v0.3.0.
|
||||||
|
- suite: nostr-publish-consume
|
||||||
|
type: nostr-publish-consume
|
||||||
# ── Real-deb install across target distros ─────────────────────
|
# ── Real-deb install across target distros ─────────────────────
|
||||||
# Boots a privileged systemd container per distro, runs
|
# Boots a privileged systemd container per distro, runs
|
||||||
# `apt install ./fips_*.deb` with the locally-built package,
|
# `apt install ./fips_*.deb` with the locally-built package,
|
||||||
@@ -617,6 +624,23 @@ jobs:
|
|||||||
--profile cone --profile symmetric --profile lan \
|
--profile cone --profile symmetric --profile lan \
|
||||||
down --volumes --remove-orphans
|
down --volumes --remove-orphans
|
||||||
|
|
||||||
|
# ── Nostr overlay advert publish/consume ───────────────────────────
|
||||||
|
- name: Run Nostr publish/consume test
|
||||||
|
if: matrix.type == 'nostr-publish-consume'
|
||||||
|
run: bash testing/nat/scripts/nostr-relay-test.sh
|
||||||
|
|
||||||
|
- name: Collect logs on failure (nostr-publish-consume)
|
||||||
|
if: matrix.type == 'nostr-publish-consume' && failure()
|
||||||
|
run: |
|
||||||
|
docker compose -f testing/nat/docker-compose.yml \
|
||||||
|
--profile nostr-publish-consume logs --no-color | tail -300
|
||||||
|
|
||||||
|
- name: Stop containers (nostr-publish-consume)
|
||||||
|
if: matrix.type == 'nostr-publish-consume' && always()
|
||||||
|
run: |
|
||||||
|
docker compose -f testing/nat/docker-compose.yml \
|
||||||
|
--profile nostr-publish-consume down --volumes --remove-orphans
|
||||||
|
|
||||||
# ── Outbound LAN gateway integration test ──────────────────────────
|
# ── Outbound LAN gateway integration test ──────────────────────────
|
||||||
- name: Generate configs (gateway)
|
- name: Generate configs (gateway)
|
||||||
if: matrix.type == 'gateway'
|
if: matrix.type == 'gateway'
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
# static-mesh, static-chain, rekey, rekey-accept-off,
|
# static-mesh, static-chain, rekey, rekey-accept-off,
|
||||||
# rekey-outbound-only, gateway,
|
# rekey-outbound-only, gateway,
|
||||||
# acl-allowlist, nat-cone, nat-symmetric, nat-lan,
|
# acl-allowlist, nat-cone, nat-symmetric, nat-lan,
|
||||||
|
# nostr-publish-consume,
|
||||||
# chaos-smoke-10, chaos-churn-mixed-10, chaos-ethernet-mesh,
|
# chaos-smoke-10, chaos-churn-mixed-10, chaos-ethernet-mesh,
|
||||||
# chaos-ethernet-only, chaos-tcp-mesh, chaos-bottleneck-parent,
|
# chaos-ethernet-only, chaos-tcp-mesh, chaos-bottleneck-parent,
|
||||||
# chaos-cost-avoidance, chaos-cost-reeval, chaos-cost-stability,
|
# chaos-cost-avoidance, chaos-cost-reeval, chaos-cost-stability,
|
||||||
@@ -74,6 +75,7 @@ GATEWAY_SUITES=(gateway)
|
|||||||
SIDECAR_SUITES=(sidecar)
|
SIDECAR_SUITES=(sidecar)
|
||||||
ACL_SUITES=(acl-allowlist)
|
ACL_SUITES=(acl-allowlist)
|
||||||
NAT_SUITES=(cone symmetric lan)
|
NAT_SUITES=(cone symmetric lan)
|
||||||
|
NOSTR_RELAY_SUITES=(nostr-publish-consume)
|
||||||
DNS_RESOLVER_SUITES=(dns-resolver)
|
DNS_RESOLVER_SUITES=(dns-resolver)
|
||||||
DEB_INSTALL_SUITES=(deb-install)
|
DEB_INSTALL_SUITES=(deb-install)
|
||||||
TOR_SUITES=(tor-socks5 tor-directory)
|
TOR_SUITES=(tor-socks5 tor-directory)
|
||||||
@@ -114,6 +116,9 @@ list_suites() {
|
|||||||
echo " NAT scenarios:"
|
echo " NAT scenarios:"
|
||||||
for s in "${NAT_SUITES[@]}"; do echo " nat-$s"; done
|
for s in "${NAT_SUITES[@]}"; do echo " nat-$s"; done
|
||||||
echo ""
|
echo ""
|
||||||
|
echo " Nostr publish/consume:"
|
||||||
|
for s in "${NOSTR_RELAY_SUITES[@]}"; do echo " $s"; done
|
||||||
|
echo ""
|
||||||
echo " Chaos scenarios:"
|
echo " Chaos scenarios:"
|
||||||
for entry in "${CHAOS_SUITES[@]}"; do
|
for entry in "${CHAOS_SUITES[@]}"; do
|
||||||
read -ra parts <<< "$entry"
|
read -ra parts <<< "$entry"
|
||||||
@@ -446,6 +451,19 @@ run_nat() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Run the Nostr overlay advert publish/consume integration test.
|
||||||
|
# Two FIPS daemons + the existing strfry relay; exercises Phase 1
|
||||||
|
# (A→B publish/consume), Phase 2 (B→A reverse), and Phase 3 (malformed
|
||||||
|
# advert injected directly to the relay; consumer-liveness assertion).
|
||||||
|
run_nostr_publish_consume() {
|
||||||
|
info "[nostr-publish-consume] Running Nostr publish/consume test"
|
||||||
|
if bash testing/nat/scripts/nostr-relay-test.sh 2>&1; then
|
||||||
|
record "nostr-publish-consume" 0
|
||||||
|
else
|
||||||
|
record "nostr-publish-consume" 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# Run dns-resolver harness (multi-distro + e2e scenarios)
|
# Run dns-resolver harness (multi-distro + e2e scenarios)
|
||||||
run_dns_resolver() {
|
run_dns_resolver() {
|
||||||
info "[dns-resolver] Running multi-distro test (slow — builds per-distro images)"
|
info "[dns-resolver] Running multi-distro test (slow — builds per-distro images)"
|
||||||
@@ -527,6 +545,11 @@ run_integration() {
|
|||||||
run_nat "$scenario"
|
run_nat "$scenario"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Nostr publish/consume (sequential — shares the NAT compose project)
|
||||||
|
for _suite in "${NOSTR_RELAY_SUITES[@]}"; do
|
||||||
|
run_nostr_publish_consume
|
||||||
|
done
|
||||||
|
|
||||||
# Chaos scenarios (parallel, throttled)
|
# Chaos scenarios (parallel, throttled)
|
||||||
if [[ "$SKIP_CHAOS" != true ]]; then
|
if [[ "$SKIP_CHAOS" != true ]]; then
|
||||||
info "Running ${#CHAOS_SUITES[@]} chaos scenarios (max $PARALLEL_JOBS parallel)"
|
info "Running ${#CHAOS_SUITES[@]} chaos scenarios (max $PARALLEL_JOBS parallel)"
|
||||||
@@ -611,6 +634,8 @@ run_suite() {
|
|||||||
run_acl_allowlist ;;
|
run_acl_allowlist ;;
|
||||||
nat-cone|nat-symmetric|nat-lan)
|
nat-cone|nat-symmetric|nat-lan)
|
||||||
run_nat "${suite#nat-}" ;;
|
run_nat "${suite#nat-}" ;;
|
||||||
|
nostr-publish-consume)
|
||||||
|
run_nostr_publish_consume ;;
|
||||||
chaos-*)
|
chaos-*)
|
||||||
local chaos_name="${suite#chaos-}"
|
local chaos_name="${suite#chaos-}"
|
||||||
local found=false
|
local found=false
|
||||||
|
|||||||
@@ -232,3 +232,40 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
shared-lan:
|
shared-lan:
|
||||||
ipv4_address: 172.31.10.11
|
ipv4_address: 172.31.10.11
|
||||||
|
|
||||||
|
# ── Nostr publish/consume profile ──────────────────────────────────────
|
||||||
|
# Two FIPS daemons + the existing strfry relay, exercising the overlay
|
||||||
|
# advert publish → relay → consumer round-trip end-to-end. Both nodes
|
||||||
|
# share the same LAN bridge as the relay (no NAT in the way) so the
|
||||||
|
# focus of the test is the Nostr discovery layer rather than NAT
|
||||||
|
# traversal mechanics. Phase 3 (malformed advert) is driven by a
|
||||||
|
# one-shot publish from the test runner via the relay's WebSocket.
|
||||||
|
nostr-pub-a:
|
||||||
|
<<: *fips-common
|
||||||
|
profiles: ["nostr-publish-consume"]
|
||||||
|
container_name: fips-nat-nostr-pub-a
|
||||||
|
hostname: fips-nat-nostr-pub-a
|
||||||
|
depends_on:
|
||||||
|
- relay
|
||||||
|
- stun
|
||||||
|
volumes:
|
||||||
|
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||||
|
- ./generated-configs/nostr-publish-consume/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||||
|
networks:
|
||||||
|
shared-lan:
|
||||||
|
ipv4_address: 172.31.10.20
|
||||||
|
|
||||||
|
nostr-pub-b:
|
||||||
|
<<: *fips-common
|
||||||
|
profiles: ["nostr-publish-consume"]
|
||||||
|
container_name: fips-nat-nostr-pub-b
|
||||||
|
hostname: fips-nat-nostr-pub-b
|
||||||
|
depends_on:
|
||||||
|
- relay
|
||||||
|
- stun
|
||||||
|
volumes:
|
||||||
|
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||||
|
- ./generated-configs/nostr-publish-consume/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||||
|
networks:
|
||||||
|
shared-lan:
|
||||||
|
ipv4_address: 172.31.10.21
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ SCENARIO="${1:?usage: generate-configs.sh <cone|symmetric|lan> [mesh-name]}"
|
|||||||
MESH_NAME="${2:-nat-lab-$(date +%s)-$$}"
|
MESH_NAME="${2:-nat-lab-$(date +%s)-$$}"
|
||||||
|
|
||||||
case "$SCENARIO" in
|
case "$SCENARIO" in
|
||||||
cone|symmetric|lan) ;;
|
cone|symmetric|lan|nostr-publish-consume) ;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown scenario: $SCENARIO" >&2
|
echo "Unknown scenario: $SCENARIO" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -30,7 +30,7 @@ npub_b="$(echo "$keys_b" | awk -F= '/^npub=/{print $2}')"
|
|||||||
|
|
||||||
relay_addr="ws://172.31.254.30:7777"
|
relay_addr="ws://172.31.254.30:7777"
|
||||||
stun_addr="stun:172.31.254.40:3478"
|
stun_addr="stun:172.31.254.40:3478"
|
||||||
if [ "$SCENARIO" = "lan" ]; then
|
if [ "$SCENARIO" = "lan" ] || [ "$SCENARIO" = "nostr-publish-consume" ]; then
|
||||||
relay_addr="ws://172.31.10.30:7777"
|
relay_addr="ws://172.31.10.30:7777"
|
||||||
stun_addr="stun:172.31.10.40:3478"
|
stun_addr="stun:172.31.10.40:3478"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Executable
+372
@@ -0,0 +1,372 @@
|
|||||||
|
#!/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"
|
||||||
|
|
||||||
|
PROFILE="nostr-publish-consume"
|
||||||
|
SCENARIO="$PROFILE"
|
||||||
|
COMPOSE=(docker compose -f "$NAT_DIR/docker-compose.yml")
|
||||||
|
NODE_A="fips-nat-nostr-pub-a"
|
||||||
|
NODE_B="fips-nat-nostr-pub-b"
|
||||||
|
RELAY_HOST="172.31.10.30"
|
||||||
|
RELAY_PORT=7777
|
||||||
|
RELAY_CONTAINER="fips-nat-relay"
|
||||||
|
|
||||||
|
# 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() {
|
||||||
|
if ! docker image inspect fips-test:latest >/dev/null 2>&1; then
|
||||||
|
echo "fips-test:latest not found; building test image"
|
||||||
|
"$BUILD_SCRIPT"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_no_panic() {
|
||||||
|
local container="$1"
|
||||||
|
local logs
|
||||||
|
logs="$(docker logs "$container" 2>&1 || true)"
|
||||||
|
if grep -Eq "panicked at|RUST_BACKTRACE|fatal runtime error" <<<"$logs"; then
|
||||||
|
echo "panic detected in $container logs" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "$NAT_DIR/generated-configs/$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 "$@"
|
||||||
Reference in New Issue
Block a user