Files
NODEDC_MISSION_CORE/tests/fleet/test_pairing.py
T

389 lines
13 KiB
Python

import base64
import json
import secrets
import time
from datetime import UTC, datetime, timedelta
import pytest
from cryptography import x509
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from fastapi import FastAPI
from fastapi.testclient import TestClient
from k1link.fleet.registry import FleetRegistry
from k1link.fleet.trust import SCHEMA, PairingError, parse_invitation, pem, public_id
from k1link.web.fleet_api import router
def code(value):
return "MCN1." + base64.urlsafe_b64encode(json.dumps(value).encode()).decode().rstrip("=")
@pytest.fixture
def setup(tmp_path, monkeypatch):
fleet = FleetRegistry(tmp_path)
key = Ed25519PrivateKey.generate()
node_id = public_id("node_", key.public_key())
invite = dict(
schema=SCHEMA,
node_id=node_id,
id=secrets.token_urlsafe(32),
secret=secrets.token_urlsafe(32),
expires_at=int(time.time()) + 600,
endpoint="https://192.168.20.4:8781",
)
calls = []
def request(invitation, path, payload):
calls.append((path, payload))
if path.endswith("/inspect"):
return (
dict(schema=SCHEMA, node_id=node_id, name="Test board", host={"os": "Linux"}),
key.public_key(),
"192.168.20.5",
)
if path.endswith("/offer"):
return (
dict(node_id=node_id, receipt=secrets.token_urlsafe(32)),
key.public_key(),
"192.168.20.5",
)
return dict(node_id=node_id), key.public_key(), "192.168.20.5"
monkeypatch.setattr("k1link.fleet.registry.node_request", request)
monkeypatch.setattr(fleet, "listen", lambda address: None)
yield fleet, invite, key, calls
fleet.close()
def create(setup):
fleet, invite, _, _ = setup
preview = fleet.preview(code(invite))
row = fleet.add(preview["preview_id"], "Test vehicle", "ugv")
return row, preview
def heartbeat(row):
return dict(
schema=SCHEMA,
node_id=row["node_id"],
binding_id=row["binding"]["binding_id"],
execution_binding={
"node_id": row["node_id"],
"agent_instance_id": "agent_test",
"platform": "linux",
},
host={"hostname": "synthetic", "usb": [], "networks": []},
devices=[],
)
def cert(row):
return x509.load_pem_x509_certificate(row["binding"]["client_pem"].encode()).public_bytes(
serialization.Encoding.DER
)
def test_preview_add_is_durable_and_idempotent(setup):
fleet, invite, _, calls = setup
public, preview = create(setup)
assert public["enrollment"] == "pending"
assert public["connectivity"] == "offline"
assert fleet.add(preview["preview_id"], "Test vehicle", "ugv")["id"] == public["id"]
fleet.advance(public["id"])
assert fleet.listing()["items"][0]["enrollment"] == "paired"
assert fleet.listing()["items"][0]["connectivity"] == "offline"
row = fleet.find(public["id"])
assert row["invitation"] is None and row["receipt"] is None
assert fleet.receive(cert(row), "/v1/node/heartbeat", heartbeat(row))[0] == 200
listed = fleet.listing()
assert listed["items"][0]["connectivity"] == "online"
assert invite["secret"] not in json.dumps(listed)
assert "client_pem" not in json.dumps(listed)
reopened = FleetRegistry(fleet.root)
try:
assert reopened.trust.core_id == fleet.trust.core_id
assert reopened.listing()["items"][0]["id"] == public["id"]
finally:
reopened.close()
def test_lost_commit_ack_recovers_from_authenticated_heartbeat(setup, monkeypatch):
fleet, _, _, _ = setup
public, _ = create(setup)
original = __import__("k1link.fleet.registry", fromlist=["node_request"]).node_request
def drop_ack(invitation, path, body):
if path.endswith("/commit"):
raise PairingError("Lost acknowledgement")
return original(invitation, path, body)
monkeypatch.setattr("k1link.fleet.registry.node_request", drop_ack)
with pytest.raises(PairingError):
fleet.advance(public["id"])
row = fleet.find(public["id"])
assert row["receipt"] and row["enrollment"] == "pending"
assert fleet.receive(cert(row), "/v1/node/heartbeat", heartbeat(row))[0] == 200
assert fleet.find(public["id"])["enrollment"] == "paired"
def test_revoke_during_network_completion_cannot_resurrect(setup, monkeypatch):
fleet, _, _, _ = setup
public, _ = create(setup)
original = __import__("k1link.fleet.registry", fromlist=["node_request"]).node_request
def revoke_first(invitation, path, body):
value = original(invitation, path, body)
fleet.revoke(public["id"])
return value
monkeypatch.setattr("k1link.fleet.registry.node_request", revoke_first)
fleet.advance(public["id"])
row = fleet.find(public["id"])
assert row["enrollment"] == "revoked"
assert fleet.receive(cert(row), "/v1/node/heartbeat", heartbeat(row))[0] == 410
def test_repair_retains_vehicle_and_rejects_old_binding(setup):
fleet, invite, _, _ = setup
public, _ = create(setup)
old = fleet.find(public["id"])
fleet.revoke(public["id"])
invite["id"] = secrets.token_urlsafe(32)
updated, _ = create(setup)
assert updated["id"] == public["id"] and updated["revision"] == 3
assert fleet.receive(cert(old), "/v1/node/heartbeat", heartbeat(old))[0] == 410
def test_rotation_has_bounded_old_certificate_grace(setup):
fleet, _, key, _ = setup
public, _ = create(setup)
row = fleet.find(public["id"])
original = x509.load_pem_x509_certificate(row["binding"]["client_pem"].encode())
soon = (
x509.CertificateBuilder()
.subject_name(original.subject)
.issuer_name(original.issuer)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.now(UTC) - timedelta(hours=1))
.not_valid_after(datetime.now(UTC) + timedelta(days=1))
.sign(fleet.trust.key, None)
)
row["binding"]["client_pem"] = pem(soon)
fleet.save(row)
status, reply = fleet.receive(cert(row), "/v1/node/heartbeat", heartbeat(row))
assert status == 200 and reply["client_pem"] != pem(soon)
assert fleet.receive(cert(row), "/v1/node/heartbeat", heartbeat(row))[0] == 200
current = fleet.find(public["id"])
current["certificate_previous"]["until"] = time.time() - 1
fleet.save(current)
assert fleet.receive(cert(row), "/v1/node/heartbeat", heartbeat(row))[0] == 403
@pytest.mark.parametrize(
"address",
[
"https://8.8.8.8:8781",
"https://127.0.0.1:8781",
"https://192.168.1.2:22",
"https://user@192.168.1.2:8781",
"https://192.168.1.2:8781/api",
"http://192.168.1.2:8781",
"https://local.test:8781",
],
)
def test_invitation_never_targets_public_loopback_dns_or_other_ports(setup, address):
_, invite, _, _ = setup
invite["endpoint"] = address
with pytest.raises(PairingError):
parse_invitation(code(invite))
def test_expiry_and_node_inventory_admission(setup):
fleet, invite, _, _ = setup
public, _ = create(setup)
row = fleet.find(public["id"])
row["invitation"]["expires_at"] = 0
fleet.save(row)
fleet.advance(public["id"])
assert fleet.find(public["id"])["enrollment"] == "failed"
invite["expires_at"] = 0
with pytest.raises(PairingError):
parse_invitation(code(invite))
def test_operator_api_rejects_cross_origin_and_remote_peer(setup):
fleet, _, _, _ = setup
app = FastAPI()
app.state.fleet = fleet
app.include_router(router)
with TestClient(app, base_url="http://127.0.0.1:8000", client=("127.0.0.1", 55555)) as client:
assert client.get("/api/v1/fleet").status_code == 200
assert (
client.get("/api/v1/fleet", headers={"Origin": "https://attacker.test"}).status_code
== 403
)
assert (
client.get("/api/v1/fleet", headers={"Host": "attacker.test:8000"}).status_code == 403
)
assert client.post("/api/v1/fleet/preview", json={"code": "bad"}).status_code == 409
with TestClient(
app, base_url="http://127.0.0.1:8000", client=("192.168.20.6", 55555)
) as client:
assert client.get("/api/v1/fleet").status_code == 403
@pytest.mark.parametrize("wrong_identity", [False, True])
def test_bootstrap_pin_is_verified_before_secret_is_sent(setup, monkeypatch, wrong_identity):
from cryptography.x509.oid import NameOID
from k1link.fleet.trust import node_request
_, invite, key, _ = setup
if wrong_identity:
key = Ed25519PrivateKey.generate()
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Synthetic Node")])
certificate = (
x509.CertificateBuilder()
.subject_name(name)
.issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.now(UTC) - timedelta(minutes=1))
.not_valid_after(datetime.now(UTC) + timedelta(hours=1))
.sign(key, None)
.public_bytes(serialization.Encoding.DER)
)
sent = []
class Socket:
def getpeercert(self, **_kwargs):
return certificate
def getsockname(self):
return ("192.168.20.5", 41000)
class Response:
status = 200
def read(self, _limit):
return b'{"ok":true}'
class Connection:
sock = Socket()
def __init__(self, *_args, **_kwargs):
pass
def connect(self):
pass
def request(self, *args):
sent.append(args)
def getresponse(self):
return Response()
def close(self):
pass
monkeypatch.setattr("k1link.fleet.trust.http.client.HTTPSConnection", Connection)
if wrong_identity:
with pytest.raises(PairingError):
node_request(invite, "/v1/pair/inspect", {"secret": invite["secret"]})
assert not sent
else:
assert node_request(invite, "/v1/pair/inspect", {"secret": invite["secret"]})[0] == {
"ok": True
}
assert len(sent) == 1
def sensor_inventory(row):
now = datetime.now(UTC).isoformat()
item = {
"id": "rsd455_01234567890123456789012345678901",
"name": "Test camera",
"snapshot": {
"context": {
"session_id": "sensor_test",
"device": {
"device_id": "rsd455_01234567890123456789012345678901",
"model": {
"plugin_id": "missioncore.realsense",
"plugin_version": "0.6.0",
"model_id": "realsense.d455",
},
"stability": "stable",
"basis": "hardware-identifier",
},
"execution": {
"node_id": row["node_id"],
"agent_instance_id": "driver_test",
"platform": "linux",
},
"opened_at": now,
},
"revision": 0,
"enrollment": "empty",
"connectivity": "connected",
"acquisition": "idle",
"observed_at": now,
},
}
value = heartbeat(row)
value["devices"] = [item]
value["sensor_state"] = {"items": [item], "operations": [], "preparation": None}
return value
def test_sensor_commands_require_pairing_and_keep_uncertain_outcome(setup):
from k1link.fleet import sensors
fleet, _, _, _ = setup
public, _ = create(setup)
row = setup[0].find(public["id"])
inventory = sensor_inventory(row)
fleet.receive(cert(row), "/v1/node/heartbeat", inventory)
now = datetime.now(UTC)
command = {
"api_version": "missioncore.nodedc/plugin-sdk/v0alpha2",
"kind": "OperationRequest",
"operation_id": "op_01234567890123456789012345678901",
"idempotency_key": "op_01234567890123456789012345678901",
"session": {"device_id": inventory["devices"][0]["id"], "session_id": "sensor_test"},
"requested_at": now.isoformat(),
"deadline_at": (now + timedelta(seconds=60)).isoformat(),
"action_id": "start",
"parameters": {"record": True},
}
first = sensors.submit(fleet, row["id"], command)
assert first["state"] == "queued"
assert sensors.submit(fleet, row["id"], command) == first
with pytest.raises(PairingError):
sensors.submit(fleet, row["id"], {**command, "action_id": "stop"})
_, response = fleet.receive(cert(row), "/v1/node/heartbeat", inventory)
assert response["sensor_commands"] == [command]
inventory["sensor_results"] = [
{"command": command, "state": "unknown", "error": "test uncertainty"}
]
_, response = fleet.receive(cert(row), "/v1/node/heartbeat", inventory)
assert response["sensor_commands"] == []
assert sensors.operation(fleet, row["id"], command["operation_id"])["state"] == "unknown"
fleet.revoke(row["id"])
with pytest.raises(PairingError):
sensors.submit(fleet, row["id"], command)
def test_sensor_cannot_claim_another_board_identity(setup):
from k1link.fleet.sensors import validate_inventory
public, _ = create(setup)
row = setup[0].find(public["id"])
value = sensor_inventory(row)
value["devices"][0]["snapshot"]["context"]["execution"]["node_id"] = "node_wrong"
with pytest.raises(ValueError):
validate_inventory(value, row["node_id"])