Files
MeshCore/tools/hil/room_webconfig_acceptance.py
T

637 lines
29 KiB
Python

#!/usr/bin/env python3
"""Live WebConfig/room password acceptance checks; never changes board settings.
Run on a gateway with two ALREADY CONFIGURED IPv4 source addresses, while other
authentication attempts and room polling are paused. This deliberately causes
temporary login lockouts and creates WebConfig sessions (evicting the old single
session). It sends only login/logout, config/status reads and room status reads;
it does not post messages, change preferences, reboot, or configure networking.
Room access must already be enabled, with spare browser slots and a poll quota
that permits roughly 25 status reads during one minute (zero disables quotas).
Example (credentials are never command-line arguments)::
python3 tools/hil/room_webconfig_acceptance.py \
--endpoint http://192.0.2.10 --manifest identity.json \
--secrets-file private.json --run-id candidate-lan-01 \
--scenario all --allow-auth-attempts --report sanitized-result.json
Manifest example (replace every placeholder with independently checked values)::
{"source_commit":"<8-to-40-hex>", "firmware_sha256":"<64-hex>",
"status":{"node_id":"<16-hex>", "board":"<exact board>",
"role":"<exact role>", "fw":"<exact /api/status fw>",
"mode":"lan"},
"config_expected":{"radio":{"name":"<expected node name>"}}}
Private JSON must be owned by this user and mode 0600 (or 0400)::
{"admin_password":"<existing password>",
"source_ips":["192.0.2.20","192.0.2.21"],
"room_guest_password":"<optional existing guest password>",
"room_public_read_only":false,
"request_timeout":2.0, "room_timeout":12.0, "timing_guard":0.35}
MESHCORE_AUTH_SECRETS_FILE may supply the private file path instead. The runner
does not print endpoints, source addresses, node names, passwords, cookies,
room tokens or raw responses. The manifest's firmware hash is EXTERNAL evidence,
not a live flash-hash measurement: /api/status has no such field. Network checks
use timing guard bands; exact millisecond boundaries belong to the host tests.
Allow about three minutes for all scenarios. On failure, a partial budget or
30-second lock can remain; the report records that possibility explicitly.
"""
from __future__ import annotations
import argparse
import hashlib
import http.client
from http.cookies import SimpleCookie
import ipaddress
import json
import os
from pathlib import Path
import re
import secrets
import socket
import stat
import sys
import time
from dataclasses import dataclass
from urllib.parse import urlsplit
CLIENT_ERROR = "locked, retry in 30s"
SHARED_ERROR = "too many failed logins, retry shortly"
ROOM_ERROR = "too many wrong room passwords; wait 30 seconds"
ROOM_WRONG = "incorrect room password"
MAX_RESPONSE = 131072
class CheckFailure(RuntimeError):
"""Only fixed, credential-free reason codes may leave the runner."""
def require(condition, code):
if not condition:
raise CheckFailure(code)
def load_json(path, *, private=False):
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
with os.fdopen(descriptor, "r", encoding="utf-8") as stream:
metadata = os.fstat(stream.fileno())
require(stat.S_ISREG(metadata.st_mode), "input_not_regular_file")
require(metadata.st_size <= 65536, "input_file_too_large")
if private:
require(stat.S_IMODE(metadata.st_mode) in (0o600, 0o400),
"private_file_requires_mode_0600_or_0400")
require(metadata.st_uid == os.geteuid(), "private_file_wrong_owner")
value = json.load(stream)
except CheckFailure:
raise
except (OSError, ValueError, UnicodeError):
raise CheckFailure("input_file_unreadable_or_invalid_json") from None
require(isinstance(value, dict), "input_json_must_be_object")
return value
def subset_matches(actual, expected):
if isinstance(expected, dict):
return isinstance(actual, dict) and all(
key in actual and subset_matches(actual[key], value)
for key, value in expected.items())
return type(actual) is type(expected) and actual == expected
def has_expected_leaf(value):
return (any(has_expected_leaf(item) for item in value.values())
if isinstance(value, dict) else True)
def validate_manifest(value):
require(re.fullmatch(r"[0-9a-fA-F]{8,40}", str(value.get("source_commit", "")))
is not None, "manifest_source_commit_required")
require(re.fullmatch(r"[0-9a-fA-F]{64}", str(value.get("firmware_sha256", "")))
is not None, "manifest_firmware_sha256_required")
expected = value.get("status")
require(isinstance(expected, dict), "manifest_status_required")
for key in ("node_id", "board", "role", "fw", "mode"):
require(isinstance(expected.get(key), str) and bool(expected[key]),
"manifest_identity_fields_required")
require(re.fullmatch(r"[0-9a-f]{16}", expected["node_id"]) is not None,
"manifest_node_id_invalid")
require(expected["mode"] in ("lan", "setup"), "manifest_mode_invalid")
require(isinstance(value.get("config_expected"), dict)
and has_expected_leaf(value["config_expected"]),
"manifest_config_expectation_required")
return value
def validate_private(value, scenario):
password = value.get("admin_password")
require(isinstance(password, str) and 0 < len(password.encode("utf-8")) <= 64
and "\0" not in password, "private_admin_password_invalid")
addresses = value.get("source_ips")
require(isinstance(addresses, list) and len(addresses) == 2
and all(isinstance(address, str) for address in addresses),
"two_source_addresses_required")
try:
parsed = [str(ipaddress.IPv4Address(item)) for item in addresses]
except (ValueError, TypeError, ipaddress.AddressValueError):
raise CheckFailure("source_addresses_must_be_ipv4") from None
require(parsed[0] != parsed[1], "source_addresses_must_differ")
for address in parsed:
ip = ipaddress.IPv4Address(address)
require(not ip.is_unspecified and not ip.is_multicast,
"source_address_not_unicast")
value = dict(value, source_ips=parsed)
if scenario in ("room", "all"):
require(type(value.get("room_public_read_only")) is bool,
"room_read_only_expectation_required")
guest = value.get("room_guest_password")
if guest is not None:
require(isinstance(guest, str) and 0 < len(guest.encode("utf-8")) <= 64
and "\0" not in guest and guest != password,
"private_guest_password_invalid_or_same_as_admin")
for key, default, minimum, maximum in (
("request_timeout", 2.0, 0.1, 5.0),
("room_timeout", 12.0, 2.0, 20.0),
("timing_guard", 0.35, 0.2, 1.0)):
number = value.get(key, default)
require(type(number) in (int, float) and minimum <= number <= maximum,
"private_timing_value_invalid")
value[key] = float(number)
return value
@dataclass
class Reply:
status: int
body: dict
headers: list
started: float
finished: float
class HttpClient:
def __init__(self, endpoint, source_ip, timeout, *, connection_factory=None,
clock=time.monotonic):
self.endpoint, self.source_ip, self.timeout = endpoint, source_ip, timeout
self.connection_factory = connection_factory or http.client.HTTPConnection
self.clock = clock
self.cookie = None
def request(self, method, path, payload=None, *, headers=None, use_cookie=True):
require((method, path) in {
("GET", "/api/status"), ("GET", "/api/config"),
("POST", "/api/login"), ("POST", "/api/logout"),
("POST", "/api/room"), ("POST", "/api/room/result")},
"request_outside_read_and_auth_allowlist")
if path == "/api/room":
require(isinstance(payload, dict) and set(payload) == {"op", "password"}
and payload["op"] == "status", "room_operation_must_be_status_read")
elif path == "/api/login":
require(isinstance(payload, dict) and set(payload) == {"password"},
"login_payload_invalid")
else:
require(payload is None or (path == "/api/logout" and payload == {}),
"unexpected_body_on_read_or_logout")
body = None if payload is None else json.dumps(
payload, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
request_headers = {"Host": self.endpoint["host_header"],
"Accept": "application/json", "Connection": "close"}
if body is not None:
request_headers["Content-Type"] = "application/json"
if use_cookie and self.cookie:
request_headers["Cookie"] = self.cookie
request_headers.update(headers or {})
connection = self.connection_factory(
self.endpoint["address"], self.endpoint["port"], timeout=self.timeout,
source_address=(self.source_ip, 0))
started = self.clock()
try:
connection.connect()
require(connection.sock.getsockname()[0] == self.source_ip,
"http_source_binding_not_observed")
connection.request(method, path, body=body, headers=request_headers)
response = connection.getresponse()
require(response.getheader("Content-Type", "").split(";", 1)[0].strip()
== "application/json", "response_not_json_content_type")
raw = response.read(MAX_RESPONSE + 1)
require(len(raw) <= MAX_RESPONSE, "response_too_large")
parsed = json.loads(raw)
require(isinstance(parsed, dict), "response_json_not_object")
return Reply(response.status, parsed, response.getheaders(),
started, self.clock())
except CheckFailure:
raise
except (OSError, ValueError, UnicodeError, http.client.HTTPException):
raise CheckFailure("http_transport_or_json_failure") from None
finally:
connection.close()
def login(self, password):
reply = self.request("POST", "/api/login", {"password": password})
if reply.status == 200:
require(reply.body.get("ok") is True and "error" not in reply.body,
"login_success_contract_invalid")
cookies = SimpleCookie()
try:
for key, value in reply.headers:
if key.lower() == "set-cookie":
cookies.load(value)
except Exception:
raise CheckFailure("session_cookie_invalid") from None
require("wcs" in cookies, "admin_login_did_not_create_session")
cookie = cookies["wcs"]
require(re.fullmatch(r"[0-9a-f]{32}", cookie.value) is not None
and bool(cookie["httponly"])
and cookie["samesite"].lower() == "lax",
"session_cookie_security_contract_invalid")
self.cookie = "wcs=" + cookie.value
return reply
class RoomClient:
def __init__(self, client, timeout, *, clock=time.monotonic, sleep=time.sleep):
self.client, self.timeout, self.clock, self.sleep = client, timeout, clock, sleep
self.token, self.sequence, self.boot = secrets.token_hex(32), 0, None
def status(self, password):
self.sequence += 1
headers = {"X-Room-Token": self.token, "X-Room-Seq": str(self.sequence)}
deadline = self.clock() + self.timeout
started = self.clock()
while True:
require(self.clock() < deadline, "room_admission_timeout")
reply = self.client.request("POST", "/api/room",
{"op": "status", "password": password},
headers=headers, use_cookie=False)
if reply.status == 202:
require(reply.body.get("accepted") is True, "room_admission_contract_invalid")
break
require(reply.status == 429 and reply.body.get("error") == "room service busy",
"room_submission_rejected")
self.sleep(0.1)
while True:
require(self.clock() < deadline, "room_result_timeout")
reply = self.client.request("POST", "/api/room/result", headers=headers,
use_cookie=False)
if reply.status == 202:
require(reply.body.get("pending") is True, "room_pending_contract_invalid")
self.sleep(0.05)
continue
require(reply.status == 200, "room_result_transport_status_invalid")
if reply.body.get("error") in (
"room browser disabled; use set room.web on",
"room browser busy; try later",
"room rate limit reached; try after the next minute"):
raise CheckFailure("room_enabled_capacity_or_quota_precondition_failed")
if "error" not in reply.body:
require(type(reply.body.get("boot")) is int
and type(reply.body.get("role")) is int,
"room_status_identity_fields_missing")
if self.boot is None:
self.boot = reply.body["boot"]
require(reply.body["boot"] == self.boot, "room_reboot_during_test")
return Reply(reply.status, reply.body, reply.headers, started, reply.finished)
def parse_endpoint(value):
try:
url = urlsplit(value)
require(url.scheme == "http" and bool(url.hostname)
and not url.username and not url.password and url.path in ("", "/")
and not url.query and not url.fragment, "endpoint_must_be_plain_http_origin")
port = url.port or 80
require(1 <= port <= 65535, "endpoint_port_invalid")
return {"hostname": url.hostname, "port": port,
"host_header": url.netloc}
except (ValueError, TypeError):
raise CheckFailure("endpoint_invalid") from None
def resolve_endpoint(value):
result = parse_endpoint(value)
try:
addresses = socket.getaddrinfo(result["hostname"], result["port"],
socket.AF_INET, socket.SOCK_STREAM)
except OSError:
raise CheckFailure("endpoint_ipv4_resolution_failed") from None
require(bool(addresses), "endpoint_ipv4_resolution_failed")
# Resolve once, then pin every request to that address. No proxies/redirects.
result["address"] = addresses[0][4][0]
return result
class Acceptance:
def __init__(self, endpoint, manifest, private, record, *, clock=time.monotonic,
sleep=time.sleep):
self.manifest, self.private, self.record = manifest, private, record
self.clock, self.sleep = clock, sleep
self.clients = [HttpClient(endpoint, address, private["request_timeout"], clock=clock)
for address in private["source_ips"]]
self.guard = private["timing_guard"]
self.uptime = None
self.uptime_at = None
self.auth_attempts_started = False
self.phase = "preflight"
self.generated_wrong_passwords = set()
def passed(self, step):
self.record({"step": step, "result": "PASS"})
def until(self, deadline):
while self.clock() < deadline:
self.sleep(min(0.5, deadline - self.clock()))
def quiet_window(self, label):
self.record({"step": label, "result": "WAIT", "seconds": 30 + self.guard})
self.until(self.clock() + 30 + self.guard)
def identity(self):
for client in self.clients:
reply = client.request("GET", "/api/status", use_cookie=False)
require(reply.status == 200 and subset_matches(reply.body, self.manifest["status"]),
"live_status_identity_mismatch")
require(reply.body.get("password_supported") is True
and reply.body.get("needs_setup") is False
and reply.body.get("needs_password") is False
and reply.body.get("auth") is False,
"admin_auth_not_required_or_initial_setup")
uptime, now = reply.body.get("uptime_s"), self.clock()
require(type(uptime) is int and uptime >= 0, "live_uptime_missing")
if self.uptime is not None:
elapsed = now - self.uptime_at
difference = uptime - self.uptime
if difference < 0:
difference += 4294967.296
require(abs(difference - elapsed) < 5.0, "board_uptime_changed_unexpectedly")
self.uptime, self.uptime_at = uptime, now
def authorized(self, client):
self.auth_attempts_started = True
reply = client.login(self.private["admin_password"])
require(reply.status == 200, "valid_admin_login_rejected")
return reply
def config(self, client):
reply = client.request("GET", "/api/config")
require(reply.status == 200 and "error" not in reply.body
and subset_matches(reply.body, self.manifest["config_expected"]),
"protected_config_read_not_authorized_or_changed")
def wrong_password(self):
while True:
candidate = "hil-" + secrets.token_hex(5)
if candidate not in (self.private["admin_password"],
self.private.get("room_guest_password")) \
and candidate not in self.generated_wrong_passwords:
self.generated_wrong_passwords.add(candidate)
return candidate
@staticmethod
def error(reply, status, error, code):
require(reply.status == status and reply.body.get("error") == error, code)
def wrong_login(self, client):
self.auth_attempts_started = True
reply = client.login(self.wrong_password())
self.error(reply, 401, "wrong password", "wrong_login_not_counted_as_failure")
return reply
def webconfig(self):
self.phase = "webconfig"
a, b = self.clients
self.quiet_window("webconfig_initial_quiet_window")
self.authorized(a)
self.authorized(b)
self.config(b)
burst_started = self.clock()
for _ in range(5):
fifth = self.wrong_login(a)
require(self.clock() - burst_started < 3.0, "client_failure_burst_too_slow")
self.error(a.login(self.private["admin_password"]), 429, CLIENT_ERROR,
"fifth_failure_did_not_lock_its_address")
self.config(b)
self.authorized(b)
self.error(a.login(self.private["admin_password"]), 429, CLIENT_ERROR,
"other_address_success_cleared_client_lock")
self.passed("webconfig_five_failures_isolate_address_and_preserve_owner_session")
self.until(fifth.started + 29.0)
self.error(a.login(self.private["admin_password"]), 429, CLIENT_ERROR,
"client_lock_expired_before_guarded_boundary")
self.until(fifth.finished + 30 + self.guard)
self.authorized(a)
self.passed("webconfig_client_lock_recovers_after_guarded_30s_boundary")
self.authorized(b)
self.authorized(a) # A owns the current single session.
burst_started = self.clock()
first = None
for index in range(6):
reply = self.wrong_login(self.clients[index % 2])
if first is None:
first = reply
require(self.clock() - burst_started < 3.0, "shared_failure_burst_too_slow")
self.error(b.login(self.private["admin_password"]), 429, SHARED_ERROR,
"six_failures_did_not_exhaust_shared_budget")
self.config(a)
self.passed("webconfig_shared_budget_blocks_new_logins_preserves_existing_cookie")
self.until(first.started + 5.0)
self.error(b.login(self.private["admin_password"]), 429, SHARED_ERROR,
"shared_budget_refilled_before_guarded_boundary")
self.until(first.finished + 6 + self.guard)
self.authorized(b)
self.wrong_login(a)
self.error(b.login(self.private["admin_password"]), 429, SHARED_ERROR,
"successful_login_refunded_shared_failure_budget")
self.config(b)
self.passed("webconfig_shared_budget_refills_after_guarded_6s_without_success_refund")
self.quiet_window("webconfig_release_lockouts")
self.authorized(a)
self.authorized(b)
self.config(b)
self.identity()
def room_success(self, actor, password, role):
reply = actor.status(password)
require("error" not in reply.body and reply.body.get("role") == role,
"room_valid_credential_or_read_only_policy_mismatch")
return reply
def room_wrong(self, actor, password):
self.auth_attempts_started = True
reply = actor.status(password)
if self.private["room_public_read_only"]:
require("error" not in reply.body and reply.body.get("role") == 0,
"wrong_room_password_not_admitted_as_read_only_guest")
else:
self.error(reply, 200, ROOM_WRONG, "wrong_room_password_not_rejected")
return reply
def room_empty(self, actor):
reply = actor.status("")
if self.private["room_public_read_only"]:
require("error" not in reply.body and reply.body.get("role") == 0,
"empty_room_password_lost_public_read_only_access")
else:
self.error(reply, 200, ROOM_WRONG, "empty_room_password_bypassed_read_only_policy")
def room(self):
self.phase = "room"
self.quiet_window("room_initial_quiet_window")
owner, other = [RoomClient(client, self.private["room_timeout"],
clock=self.clock, sleep=self.sleep)
for client in self.clients]
admin = self.private["admin_password"]
guest = self.private.get("room_guest_password")
self.room_success(owner, admin, 3)
if guest:
self.room_success(other, guest, 2)
self.room_empty(other)
repeated = self.wrong_password()
first = self.room_wrong(other, repeated)
for index in range(5):
self.room_wrong((owner, other)[index % 2], repeated)
self.room_success(owner, admin, 3)
self.passed("room_repeated_wrong_password_across_tokens_counts_once")
for actor in (owner, other, owner):
self.room_wrong(actor, self.wrong_password())
self.room_success(owner, admin, 3)
if guest:
self.room_success(other, guest, 2)
# Separate first and fifth failures enough to detect a window that
# incorrectly starts at its fifth failure, without millisecond claims.
self.until(first.finished + 2.0)
self.room_wrong(other, self.wrong_password())
require(self.clock() - first.started < 10.0, "room_failure_window_setup_too_slow")
for actor, password in ((owner, admin), (other, repeated)):
self.error(actor.status(password), 200, ROOM_ERROR,
"room_shared_window_did_not_block_nonempty_credential")
if guest:
self.error(other.status(guest), 200, ROOM_ERROR,
"room_shared_window_did_not_block_correct_guest_password")
self.room_empty(other)
self.passed("room_five_unique_failures_block_all_nonempty_credentials_without_success_refund")
self.passed("room_empty_password_preserves_configured_read_only_policy_during_lock")
self.until(first.started + 29.0)
self.error(owner.status(admin), 200, ROOM_ERROR,
"room_window_expired_before_guarded_boundary")
self.until(first.finished + 30 + self.guard)
self.room_success(owner, admin, 3)
self.passed("room_window_recovers_30s_after_first_failure_without_lock_extension")
self.identity()
def logout(self):
# Last successful WebConfig login owns the one slot. Identify it with
# a config read; logout is the only additional state change permitted.
found = False
for client in reversed(self.clients):
if not client.cookie:
continue
reply = client.request("GET", "/api/config")
if reply.status == 200:
reply = client.request("POST", "/api/logout", {})
require(reply.status == 200 and reply.body.get("ok") is True,
"owned_session_logout_failed")
found = True
self.passed("owned_webconfig_session_logged_out")
break
require(reply.status == 401, "owned_session_cleanup_unexpected_status")
require(found, "owned_session_not_usable_for_cleanup")
def argument_parser():
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--endpoint", required=True)
parser.add_argument("--manifest", required=True, type=Path)
parser.add_argument("--secrets-file", type=Path,
default=os.environ.get("MESHCORE_AUTH_SECRETS_FILE"))
parser.add_argument("--run-id", required=True)
parser.add_argument("--scenario", choices=("webconfig", "room", "all"), default="all")
parser.add_argument("--allow-auth-attempts", action="store_true")
parser.add_argument("--report", type=Path, help="New sanitized JSON report; never overwritten")
return parser
def main(argv=None):
args = argument_parser().parse_args(argv)
report = {"schema": 1, "run_id": None, "result": "FAIL", "steps": [],
"live_flash_hash_verified": False,
"firmware_sha256_evidence": "externally supplied manifest",
"timing_evidence": "network guard bands; not exact millisecond boundaries",
"board_settings_changed_by_runner": False}
output = None
acceptance = None
started = time.monotonic()
def record(step):
report["steps"].append(step)
print(json.dumps(step, sort_keys=True), flush=True)
try:
require(args.allow_auth_attempts, "explicit_allow_auth_attempts_required")
require(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,79}", args.run_id) is not None,
"run_id_invalid")
report["run_id"] = args.run_id
require(args.secrets_file is not None, "private_secrets_file_required")
manifest = validate_manifest(load_json(args.manifest))
private = validate_private(load_json(args.secrets_file, private=True), args.scenario)
parse_endpoint(args.endpoint) # Validate before creating output/resolving.
if args.report:
try:
output = os.fdopen(os.open(args.report, os.O_WRONLY | os.O_CREAT | os.O_EXCL,
0o600), "w", encoding="utf-8")
except OSError:
raise CheckFailure("report_path_unavailable_or_already_exists") from None
report.update(source_commit=manifest["source_commit"].lower(),
firmware_sha256=manifest["firmware_sha256"].lower(),
scenario=args.scenario, mode=manifest["status"]["mode"],
node_id_sha256=hashlib.sha256(
manifest["status"]["node_id"].encode("ascii")).hexdigest())
endpoint = resolve_endpoint(args.endpoint)
acceptance = Acceptance(endpoint, manifest, private, record)
acceptance.identity()
acceptance.passed("both_source_addresses_match_manifest_identity_and_require_admin_auth")
# Prove the protected config expectation before ANY wrong password.
acceptance.authorized(acceptance.clients[0])
acceptance.config(acceptance.clients[0])
acceptance.passed("protected_config_matches_manifest")
if args.scenario in ("webconfig", "all"):
acceptance.webconfig()
if args.scenario in ("room", "all"):
acceptance.room()
acceptance.logout()
report["result"] = "PASS"
report["temporary_auth_lockout_may_remain"] = False
return_code = 0
except (CheckFailure, KeyboardInterrupt) as error:
report["failure"] = ("operator_interrupted" if isinstance(error, KeyboardInterrupt)
else str(error))
return_code = 130 if isinstance(error, KeyboardInterrupt) else 1
except Exception as error:
# Never expose a third-party exception's URL, response, headers or body.
report["failure"] = "unexpected_" + type(error).__name__
return_code = 1
finally:
report["elapsed_seconds"] = round(time.monotonic() - started, 3)
if report["result"] != "PASS":
report["phase"] = acceptance.phase if acceptance else "input_validation"
report["temporary_auth_lockout_may_remain"] = bool(
acceptance and acceptance.auth_attempts_started)
if output:
json.dump(report, output, indent=2, sort_keys=True)
output.write("\n")
output.close()
print(json.dumps({key: value for key, value in report.items() if key != "steps"},
sort_keys=True), flush=True)
return return_code
if __name__ == "__main__":
sys.exit(main())