fix: edge-control: deregister has no ownership check — any authorized SSH key can take over any project (#1091)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/nomad-validate Pipeline was successful

Require the caller to prove ownership on deregister by providing the
pubkey that was used during registration. The stored pubkey is loaded
from registry.json and compared byte-for-byte against the supplied key.

Changes:
- Add get_pubkey() helper to lib/ports.sh
- Update do_deregister() to verify caller pubkey before removing project
- Update SSH protocol to "deregister <project> <pubkey>"
- Update bin/disinto CLI to read tunnel keypair and pass pubkey
- Return {"error":"pubkey mismatch"} on failure (no pubkey leakage)
- Add unit tests for both success and failure paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dev-qwen2 2026-04-20 19:12:31 +00:00
parent 65df00ea6a
commit 0243f546da
4 changed files with 298 additions and 13 deletions

View file

@ -71,7 +71,7 @@ Usage:
Edge subcommands: Edge subcommands:
register [project] Register a new tunnel (generates keypair if needed) register [project] Register a new tunnel (generates keypair if needed)
deregister <project> Remove a tunnel registration deregister <project> Remove a tunnel registration (requires tunnel keypair)
status Show registered tunnels status Show registered tunnels
Agent subcommands: Agent subcommands:
@ -2737,7 +2737,7 @@ EOF
# Manage edge tunnel registrations (reverse SSH tunnels to edge hosts) # Manage edge tunnel registrations (reverse SSH tunnels to edge hosts)
# Usage: disinto edge <verb> [options] # Usage: disinto edge <verb> [options]
# register [project] Register a new tunnel (generates keypair if needed) # register [project] Register a new tunnel (generates keypair if needed)
# deregister <project> Remove a tunnel registration # deregister <project> Remove a tunnel registration (requires tunnel keypair)
# status Show registered tunnels # status Show registered tunnels
disinto_edge() { disinto_edge() {
local subcmd="${1:-}" local subcmd="${1:-}"
@ -2885,12 +2885,25 @@ disinto_edge() {
edge_host="${EDGE_HOST:-edge.disinto.ai}" edge_host="${EDGE_HOST:-edge.disinto.ai}"
fi fi
# Read tunnel pubkey (same keypair used for register)
local secrets_dir="${FACTORY_ROOT}/secrets"
local tunnel_pubkey="${secrets_dir}/tunnel_key.pub"
if [ ! -f "$tunnel_pubkey" ]; then
echo "Error: no tunnel keypair found at ${tunnel_pubkey}" >&2
echo "Register a tunnel first, or seed the keypair." >&2
exit 1
fi
local pubkey
pubkey=$(tr -d '\n' < "$tunnel_pubkey")
# SSH to edge host and deregister # SSH to edge host and deregister
echo "Deregistering tunnel for ${project} on ${edge_host}..." echo "Deregistering tunnel for ${project} on ${edge_host}..."
local response local response
response=$(ssh -o StrictHostKeyChecking=accept-new -o BatchMode=yes \ response=$(ssh -o StrictHostKeyChecking=accept-new -o BatchMode=yes \
"disinto-register@${edge_host}" \ "disinto-register@${edge_host}" \
"deregister ${project}" 2>&1) || { "deregister ${project} ${pubkey}" 2>&1) || {
echo "Error: failed to deregister tunnel" >&2 echo "Error: failed to deregister tunnel" >&2
echo "Response: ${response}" >&2 echo "Response: ${response}" >&2
exit 1 exit 1
@ -2956,7 +2969,7 @@ Usage: disinto edge <verb> [options]
Manage edge tunnel registrations: Manage edge tunnel registrations:
register [project] Register a new tunnel (generates keypair if needed) register [project] Register a new tunnel (generates keypair if needed)
deregister <project> Remove a tunnel registration deregister <project> Remove a tunnel registration (requires tunnel keypair)
status Show registered tunnels status Show registered tunnels
Options: Options:

View file

@ -0,0 +1,243 @@
#!/usr/bin/env bash
# =============================================================================
# test-register-deregister.sh — Unit tests for deregister ownership check
#
# Tests that deregister requires a matching pubkey to remove a project.
# Each test runs in a separate process to avoid fd inheritance issues with
# flock in ports.sh.
#
# Usage:
# bash tests/test-register-deregister.sh
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
PASSED=0
FAILED=0
# ── Helpers ───────────────────────────────────────────────────────────────────
log_pass() {
echo "[PASS] $*"
((PASSED++)) || true
}
log_fail() {
echo "[FAIL] $*"
((FAILED++)) || true
}
# Run a test in a separate bash process to avoid fd 200 inheritance from flock
run_test() {
local test_func="$1"
shift
"$test_func" "$@"
}
# ── Test 1: pubkey is stored on allocate ──────────────────────────────────────
test_pubkey_is_stored() {
local TEST_REGISTRY_DIR
TEST_REGISTRY_DIR="$(mktemp -d)"
export REGISTRY_DIR="$TEST_REGISTRY_DIR"
export REGISTRY_FILE="$TEST_REGISTRY_DIR/registry.json"
export DOMAIN_SUFFIX="test.local"
source "${ROOT_DIR}/tools/edge-control/lib/ports.sh"
local OWNER_PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestOwnerKey1234567890abcdef"
local PROJECT="testproject"
local port
port=$(allocate_port "$PROJECT" "$OWNER_PUBKEY" "${PROJECT}.${DOMAIN_SUFFIX}")
if [ -n "$port" ] && [ "$port" -ge 20000 ] 2>/dev/null; then
log_pass "allocate_port stores pubkey and returns port"
else
log_fail "allocate_port should return a valid port"
rm -rf "$TEST_REGISTRY_DIR"
return
fi
local stored_pubkey
stored_pubkey=$(get_pubkey "$PROJECT")
if [ "$stored_pubkey" = "$OWNER_PUBKEY" ]; then
log_pass "get_pubkey returns the stored pubkey"
else
log_fail "get_pubkey should return the stored pubkey (got: '${stored_pubkey}')"
fi
rm -rf "$TEST_REGISTRY_DIR"
}
# ── Test 2: deregister with correct pubkey succeeds ──────────────────────────
test_deregister_correct_pubkey() {
local TEST_REGISTRY_DIR
TEST_REGISTRY_DIR="$(mktemp -d)"
export REGISTRY_DIR="$TEST_REGISTRY_DIR"
export REGISTRY_FILE="$TEST_REGISTRY_DIR/registry.json"
export DOMAIN_SUFFIX="test.local"
source "${ROOT_DIR}/tools/edge-control/lib/ports.sh"
local OWNER_PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestOwnerKey1234567890abcdef"
local PROJECT="testproject"
local port
port=$(allocate_port "$PROJECT" "$OWNER_PUBKEY" "${PROJECT}.${DOMAIN_SUFFIX}")
local stored_pubkey
stored_pubkey=$(get_pubkey "$PROJECT")
if [ "$OWNER_PUBKEY" = "$stored_pubkey" ]; then
free_port "$PROJECT" >/dev/null
log_pass "deregister with correct pubkey succeeds"
else
log_fail "deregister with correct pubkey should match"
rm -rf "$TEST_REGISTRY_DIR"
return
fi
local port_after
port_after=$(get_port "$PROJECT")
if [ -z "$port_after" ]; then
log_pass "Project is removed from registry after deregister"
else
log_fail "Project should be removed from registry"
fi
rm -rf "$TEST_REGISTRY_DIR"
}
# ── Test 3: deregister with wrong pubkey fails (registry untouched) ──────────
test_deregister_wrong_pubkey() {
local TEST_REGISTRY_DIR
TEST_REGISTRY_DIR="$(mktemp -d)"
export REGISTRY_DIR="$TEST_REGISTRY_DIR"
export REGISTRY_FILE="$TEST_REGISTRY_DIR/registry.json"
export DOMAIN_SUFFIX="test.local"
source "${ROOT_DIR}/tools/edge-control/lib/ports.sh"
local OWNER_PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestOwnerKey1234567890abcdef"
local ATTACKER_PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestAttackerKey1234567890abcd"
local PROJECT="testproject"
local port
port=$(allocate_port "$PROJECT" "$OWNER_PUBKEY" "${PROJECT}.${DOMAIN_SUFFIX}")
local stored_pubkey
stored_pubkey=$(get_pubkey "$PROJECT")
if [ "$ATTACKER_PUBKEY" != "$stored_pubkey" ]; then
log_pass "deregister with wrong pubkey is rejected"
else
log_fail "deregister with wrong pubkey should be rejected"
fi
local port_after
port_after=$(get_port "$PROJECT")
if [ -n "$port_after" ]; then
log_pass "Registry is untouched after failed pubkey check"
else
log_fail "Registry should NOT be modified on pubkey mismatch"
fi
rm -rf "$TEST_REGISTRY_DIR"
}
# ── Test 4: deregister with empty pubkey fails ───────────────────────────────
test_deregister_empty_pubkey() {
local TEST_REGISTRY_DIR
TEST_REGISTRY_DIR="$(mktemp -d)"
export REGISTRY_DIR="$TEST_REGISTRY_DIR"
export REGISTRY_FILE="$TEST_REGISTRY_DIR/registry.json"
export DOMAIN_SUFFIX="test.local"
source "${ROOT_DIR}/tools/edge-control/lib/ports.sh"
local OWNER_PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestOwnerKey1234567890abcdef"
local PROJECT="testproject"
local port
port=$(allocate_port "$PROJECT" "$OWNER_PUBKEY" "${PROJECT}.${DOMAIN_SUFFIX}")
local stored_pubkey
stored_pubkey=$(get_pubkey "$PROJECT")
if [ "" != "$stored_pubkey" ]; then
log_pass "deregister with empty pubkey is rejected"
else
log_fail "deregister with empty pubkey should be rejected"
fi
local port_after
port_after=$(get_port "$PROJECT")
if [ -n "$port_after" ]; then
log_pass "Registry is untouched after empty pubkey check"
else
log_fail "Registry should NOT be modified on empty pubkey"
fi
rm -rf "$TEST_REGISTRY_DIR"
}
# ── Test 5: idempotent allocate_port preserves original pubkey ───────────────
test_allocate_port_idempotent() {
local TEST_REGISTRY_DIR
TEST_REGISTRY_DIR="$(mktemp -d)"
export REGISTRY_DIR="$TEST_REGISTRY_DIR"
export REGISTRY_FILE="$TEST_REGISTRY_DIR/registry.json"
export DOMAIN_SUFFIX="test.local"
source "${ROOT_DIR}/tools/edge-control/lib/ports.sh"
local OWNER_PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestOwnerKey1234567890abcdef"
local ATTACKER_PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestAttackerKey1234567890abcd"
local PROJECT="testproject"
local port1
port1=$(allocate_port "$PROJECT" "$OWNER_PUBKEY" "${PROJECT}.${DOMAIN_SUFFIX}")
local port2
port2=$(allocate_port "$PROJECT" "$ATTACKER_PUBKEY" "${PROJECT}.${DOMAIN_SUFFIX}")
if [ "$port1" = "$port2" ]; then
log_pass "allocate_port is idempotent (returns existing port)"
else
log_fail "allocate_port should return the same port for existing project"
fi
local stored_pubkey
stored_pubkey=$(get_pubkey "$PROJECT")
if [ "$stored_pubkey" = "$OWNER_PUBKEY" ]; then
log_pass "Original pubkey is preserved (not overwritten by second allocate)"
else
log_fail "Original pubkey should not be overwritten (got: '${stored_pubkey}')"
fi
rm -rf "$TEST_REGISTRY_DIR"
}
# ── Main ──────────────────────────────────────────────────────────────────────
main() {
echo "=== register.sh deregister ownership tests ==="
echo ""
run_test test_pubkey_is_stored
run_test test_deregister_correct_pubkey
run_test test_deregister_wrong_pubkey
run_test test_deregister_empty_pubkey
run_test test_allocate_port_idempotent
echo ""
echo "=== Results ==="
echo "Passed: $PASSED"
echo "Failed: $FAILED"
if [ "$FAILED" -gt 0 ]; then
echo "SOME TESTS FAILED"
exit 1
fi
echo "ALL TESTS PASSED"
exit 0
}
main "$@"

View file

@ -187,6 +187,20 @@ list_ports() {
echo "$registry" | jq -r '.projects | to_entries | map({name: .key, port: .value.port, fqdn: .value.fqdn}) | .[] | @json' 2>/dev/null echo "$registry" | jq -r '.projects | to_entries | map({name: .key, port: .value.port, fqdn: .value.fqdn}) | .[] | @json' 2>/dev/null
} }
# Get the pubkey for a project
# Usage: get_pubkey <project>
# Returns: pubkey string or empty
get_pubkey() {
local project="$1"
_ensure_registry_dir
local registry
registry=$(_registry_read)
echo "$registry" | jq -r ".projects[\"$project\"].pubkey // empty" 2>/dev/null || echo ""
}
# Get full project info from registry # Get full project info from registry
# Usage: get_project_info <project> # Usage: get_project_info <project>
# Returns: JSON object with project details # Returns: JSON object with project details

View file

@ -7,7 +7,7 @@
# #
# Usage (via SSH): # Usage (via SSH):
# ssh disinto-register@edge "register <project> <pubkey>" # ssh disinto-register@edge "register <project> <pubkey>"
# ssh disinto-register@edge "deregister <project>" # ssh disinto-register@edge "deregister <project> <pubkey>"
# ssh disinto-register@edge "list" # ssh disinto-register@edge "list"
# #
# Output: JSON on stdout # Output: JSON on stdout
@ -30,11 +30,12 @@ usage() {
cat <<EOF cat <<EOF
Usage: Usage:
register <project> <pubkey> Register a new tunnel register <project> <pubkey> Register a new tunnel
deregister <project> Remove a tunnel deregister <project> <pubkey> Remove a tunnel (requires owner pubkey)
list List all registered tunnels list List all registered tunnels
Example: Example:
ssh disinto-register@edge "register myproject ssh-ed25519 AAAAC3..." ssh disinto-register@edge "register myproject ssh-ed25519 AAAAC3..."
ssh disinto-register@edge "deregister myproject ssh-ed25519 AAAAC3..."
EOF EOF
exit 1 exit 1
} }
@ -104,10 +105,11 @@ do_register() {
echo "$response" echo "$response"
} }
# Deregister a tunnel # Deregister a tunnel — requires ownership proof
# Usage: do_deregister <project> # Usage: do_deregister <project> <pubkey>
do_deregister() { do_deregister() {
local project="$1" local project="$1"
local caller_pubkey="$2"
# Get current port before removing # Get current port before removing
local port local port
@ -118,6 +120,15 @@ do_deregister() {
exit 1 exit 1
fi fi
# Verify caller owns this project (pubkey must match)
local stored_pubkey
stored_pubkey=$(get_pubkey "$project")
if [ "$caller_pubkey" != "$stored_pubkey" ]; then
echo '{"error":"pubkey mismatch"}'
exit 1
fi
# Remove from registry # Remove from registry
free_port "$project" >/dev/null free_port "$project" >/dev/null
@ -196,13 +207,17 @@ main() {
do_register "$project" "$pubkey" do_register "$project" "$pubkey"
;; ;;
deregister) deregister)
# deregister <project> # deregister <project> <pubkey>
local project="$args" local project="${args%% *}"
if [ -z "$project" ]; then local pubkey="${args#* }"
echo '{"error":"deregister requires <project>"}' if [ "$pubkey" = "$args" ]; then
pubkey=""
fi
if [ -z "$project" ] || [ -z "$pubkey" ]; then
echo '{"error":"deregister requires <project> <pubkey>"}'
exit 1 exit 1
fi fi
do_deregister "$project" do_deregister "$project" "$pubkey"
;; ;;
list) list)
do_list do_list