389 lines
13 KiB
Python
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"])
|