Files
Shadowbroker/backend/tests/test_ais_spki_pinning.py
T
Shadowbroker 729ea78cb2 Fix #258: AIS proxy SPKI pinning fallback for expired upstream cert (#262)
External report from @jmleclercq: AISStream's Let's Encrypt cert
expired on 2026-05-20 (verified — their renewal pipeline failed), so
the AIS WebSocket connection dies with CERT_HAS_EXPIRED and the
maritime layer empties out. The reporter worked around it locally by
passing { rejectUnauthorized: false } to the WebSocket constructor and
asked whether we should add an env var for that.

That fix is the wrong fix. Disabling TLS validation entirely lets any
network attacker MITM the WebSocket and inject fake ship positions —
same class as the GDELT plaintext-HTTP MITM we just closed in #199.
Adding an env var for it would be an attractive nuisance: operators
set it once during a bad cert week and then forget, leaving themselves
open to MITM forever.

Right fix: SPKI pinning, same pattern as the Tor bundle digest pinning
in #201. The insight is that Let's Encrypt renewals keep the SAME
public key by default, so the SPKI hash survives normal cert rotation.
We can relax the date check while keeping the identity check.

Mechanics:

  backend/data/aisstream_spki_pins.json (new)
    Pinned SHA-256 hashes of the DER-encoded SPKI bytes for
    stream.aisstream.io. Captured 2026-05-20 from the live cert.
    Format is base64(sha256(pubkey_der)), matching the canonical
    openssl pipeline. Whitelisted in .gitignore alongside the other
    static reference data files (KiwiSDR directory, Tor bundle
    digests).

  backend/ais_proxy.js
    Path A (99.9% of the time): normal TLS validation. Untouched.
    Path B (on CERT_HAS_EXPIRED only): re-handshake with
    rejectUnauthorized=false JUST to read the leaf cert, compute its
    SPKI hash, compare against the pinned list. If match → upstream
    is still the genuine AISStream → re-open the WebSocket with
    rejectUnauthorized=false and log DEGRADED MODE. If no match →
    refuse the connection, log loudly: this would be a real MITM.

    Pin file is looked up in three locations so the same code works
    in the Docker backend, the Tauri desktop runtime, and any
    operator-relocated layout (SHADOWBROKER_AIS_PINS env var).
    Embedded fallback list inside the JS so portable installs that
    haven't shipped the JSON still work.

  backend/services/ais_stream.py
    Captures the proxy's status markers from stdout
    ({"__ais_proxy_status": {"degraded_tls": true}}) into a module-
    level snapshot. Exposes ais_proxy_status() for the health
    endpoint. Doesn't touch the data plane — degraded mode keeps
    receiving vessel data, just with weaker MITM protection.

  backend/routers/health.py + backend/services/schemas.py
    /api/health now includes an ais_proxy block with degraded_tls.
    Top-level status escalates ok -> degraded when AIS is in
    degraded TLS mode (but won't downgrade a worse SLO status).
    Operators get a visible signal that they're in degraded mode
    without needing to grep logs.

Tests: backend/tests/test_ais_spki_pinning.py (7 tests)
  - Pin file structure validation (JSON, host entry, base64 SHA-256)
  - ais_proxy_status() snapshot semantics (starts empty, defensive copy)
  - /api/health surfaces ais_proxy.degraded_tls when set
  - /api/health returns empty ais_proxy when proxy hasn't reported

Node.js syntax check passes (node --check) on both backend/ais_proxy.js
and the Tauri runtime mirror.

When AISStream renews their cert (likely within hours-to-days), the
normal-TLS path succeeds on next reconnect and degraded_tls clears
automatically. No operator action needed. If they instead rotate their
server key, the SPKI check will fail and we'll need to add the new
hash to backend/data/aisstream_spki_pins.json before removing the old
one.

Credit: @jmleclercq for the clear report and the careful workaround
verification (Node version, ws version, manual probe).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 20:31:56 -06:00

119 lines
4.1 KiB
Python

"""Issue #258 — AIS proxy SPKI pinning.
Most of the SPKI logic lives in ``backend/ais_proxy.js`` (Node) and can't
be unit-tested from Python directly. These tests cover the Python-side
glue: ``services.ais_stream.ais_proxy_status()`` (the snapshot the proxy
populates via stdout markers) and ``routers/health.py`` surfacing the
degraded TLS state.
Additionally, the pin-file structure is validated: it must be parseable
JSON, must contain an entry for ``stream.aisstream.io``, and each pin
must look like a base64-encoded SHA-256 hash.
"""
import base64
import json
import re
from pathlib import Path
import pytest
from services import ais_stream
PIN_FILE = (
Path(__file__).resolve().parent.parent / "data" / "aisstream_spki_pins.json"
)
def test_pin_file_exists_and_is_valid_json():
assert PIN_FILE.exists(), f"Expected pin file at {PIN_FILE}"
data = json.loads(PIN_FILE.read_text(encoding="utf-8"))
assert isinstance(data, dict)
def test_pin_file_has_aisstream_entry():
data = json.loads(PIN_FILE.read_text(encoding="utf-8"))
pins = data.get("stream.aisstream.io")
assert isinstance(pins, list)
assert len(pins) >= 1
def test_each_pin_looks_like_a_base64_sha256():
"""SPKI pins must be 44-char base64-encoded SHA-256 digests."""
data = json.loads(PIN_FILE.read_text(encoding="utf-8"))
pins = data["stream.aisstream.io"]
for pin in pins:
assert isinstance(pin, str), f"pin not a string: {pin!r}"
assert len(pin) == 44, f"pin {pin!r} not 44 chars (SHA-256 base64)"
# Must base64-decode to exactly 32 bytes (256 bits)
try:
raw = base64.b64decode(pin)
except Exception as exc:
pytest.fail(f"pin {pin!r} is not valid base64: {exc}")
assert len(raw) == 32, f"pin {pin!r} decodes to {len(raw)} bytes, expected 32"
# Should match the canonical base64 alphabet (no URL-safe variants)
assert re.match(r"^[A-Za-z0-9+/]+=*$", pin), f"pin {pin!r} contains non-base64 chars"
def test_ais_proxy_status_starts_empty():
"""Before the proxy emits any status marker, the snapshot is empty."""
# Clear any stale state from other tests
with ais_stream._vessels_lock:
ais_stream._proxy_status.clear()
status = ais_stream.ais_proxy_status()
assert status == {}
def test_ais_proxy_status_returns_copy_not_reference():
"""ais_proxy_status() must return a defensive copy.
Otherwise a caller could mutate the live dict and confuse later reads.
"""
with ais_stream._vessels_lock:
ais_stream._proxy_status.clear()
ais_stream._proxy_status["degraded_tls"] = True
snapshot = ais_stream.ais_proxy_status()
assert snapshot == {"degraded_tls": True}
snapshot["degraded_tls"] = False # mutate the returned copy
# Original should be untouched
re_snapshot = ais_stream.ais_proxy_status()
assert re_snapshot == {"degraded_tls": True}
# Cleanup so other tests start clean
with ais_stream._vessels_lock:
ais_stream._proxy_status.clear()
def test_health_includes_ais_proxy_field(client):
"""The /api/health response must include the ais_proxy block."""
# Inject a known degraded state
with ais_stream._vessels_lock:
ais_stream._proxy_status.clear()
ais_stream._proxy_status["degraded_tls"] = True
response = client.get("/api/health")
assert response.status_code == 200
payload = response.json()
assert "ais_proxy" in payload
assert payload["ais_proxy"] == {"degraded_tls": True}
# Top-level status should escalate from ok to degraded when AIS is
# in degraded-TLS mode (unless SLOs already report worse).
assert payload["status"] in {"degraded", "error"}
# Cleanup
with ais_stream._vessels_lock:
ais_stream._proxy_status.clear()
def test_health_ais_proxy_field_when_no_status(client):
"""When the proxy hasn't reported anything yet, ais_proxy is empty."""
with ais_stream._vessels_lock:
ais_stream._proxy_status.clear()
response = client.get("/api/health")
assert response.status_code == 200
payload = response.json()
assert payload.get("ais_proxy") == {}