fix: edge-control: deregister has no ownership check — any authorized SSH key can take over any project (#1091)
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:
parent
65df00ea6a
commit
0243f546da
4 changed files with 298 additions and 13 deletions
21
bin/disinto
21
bin/disinto
|
|
@ -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:
|
||||||
|
|
|
||||||
243
tests/test-register-deregister.sh
Normal file
243
tests/test-register-deregister.sh
Normal 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 "$@"
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue