mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-28 18:11:31 +02:00
729ea78cb2
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>
32 lines
1.6 KiB
JSON
32 lines
1.6 KiB
JSON
{
|
|
"_comment": [
|
|
"SPKI (Subject Public Key Info) pin list for stream.aisstream.io.",
|
|
"",
|
|
"Issue #258: AISStream's Let's Encrypt cert expired on 2026-05-20 due to an",
|
|
"upstream renewal-pipeline failure. Disabling TLS verification entirely",
|
|
"would let any network attacker MITM the AIS WebSocket and inject fake",
|
|
"ship positions onto the operator's map (same class as #199 GDELT MITM).",
|
|
"Instead we pin the leaf certificate's public-key SPKI hash: if normal",
|
|
"TLS validation fails specifically with CERT_HAS_EXPIRED, ais_proxy.js",
|
|
"re-checks the leaf cert's SPKI against this list. A match means the",
|
|
"key is still the genuine AISStream key (Let's Encrypt renewals keep the",
|
|
"same key unless rekey is requested), so we proceed in 'degraded TLS'",
|
|
"mode. A mismatch means a real MITM attempt and we refuse the connection.",
|
|
"",
|
|
"Format: each entry is a SHA-256 hash of the DER-encoded SPKI bytes,",
|
|
"encoded as standard base64 (matches the format produced by:",
|
|
" openssl s_client -connect host:443 | \\",
|
|
" openssl x509 -pubkey -noout | openssl pkey -pubin -outform DER | \\",
|
|
" openssl dgst -sha256 -binary | openssl base64",
|
|
").",
|
|
"",
|
|
"When AISStream rotates their server key (rare — Let's Encrypt renewals",
|
|
"default to keeping the same key), capture the new SPKI and add it to",
|
|
"this list BEFORE removing the old one. That way operators on the old",
|
|
"code still validate against the previous key during the transition."
|
|
],
|
|
"stream.aisstream.io": [
|
|
"GJ10H0UPgLrO+2d3ZXROR/TXSVFXKUfRC3QEI2ibEg4="
|
|
]
|
|
}
|