mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 05:23:41 +08:00
386 lines
18 KiB
Python
386 lines
18 KiB
Python
"""Private credentials, actual HTTP contracts, durable retries and RFC push crypto."""
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
import json
|
|
import struct
|
|
import threading
|
|
from types import SimpleNamespace
|
|
from urllib.parse import parse_qs
|
|
|
|
from cryptography.hazmat.primitives import hashes
|
|
from cryptography.hazmat.primitives.asymmetric import ec, utils
|
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
import pytest
|
|
|
|
from openpilot.starpilot.sentry_mode.notifications import NotificationOwner, NotificationUnavailable
|
|
from openpilot.starpilot.sentry_mode import web_push
|
|
|
|
|
|
def store(events=()):
|
|
return SimpleNamespace(snapshot=lambda: {'events': list(events)})
|
|
|
|
|
|
def configure(owner, channel, url='https://example.test/private-secret', enabled=True):
|
|
return owner.action({'action': 'configure', 'channel': channel, 'enabled': enabled, 'url': url, 'token': 'private-token'})
|
|
|
|
|
|
def test_private_configuration_redaction_validation_and_exclusive_owner(tmp_path):
|
|
owner = NotificationOwner(tmp_path / 'notifications', store())
|
|
try:
|
|
snapshot = configure(owner, 'ntfy')
|
|
assert snapshot['channels']['ntfy']['enabled'] and snapshot['channels']['ntfy']['configured']
|
|
assert 'private-secret' not in json.dumps(snapshot) and 'private-token' not in json.dumps(snapshot)
|
|
assert (tmp_path / 'notifications/state.json').stat().st_mode & 0o777 == 0o600
|
|
duplicate = NotificationOwner(tmp_path / 'notifications', store())
|
|
with pytest.raises(NotificationUnavailable):
|
|
duplicate.snapshot()
|
|
duplicate.close()
|
|
for url in ('http://example.test/a', 'https://u:p@example.test', 'https://example.test/#x', 'https://example.test/\n'):
|
|
with pytest.raises(ValueError):
|
|
configure(owner, 'webhook', url)
|
|
before = owner.snapshot()
|
|
with pytest.raises(PermissionError):
|
|
configure_kwargs = {'action': 'forget', 'channel': 'ntfy'}
|
|
owner.action(configure_kwargs, permitted=lambda: False)
|
|
assert owner.snapshot() == before
|
|
finally:
|
|
owner.close()
|
|
|
|
|
|
def test_event_scan_durable_dedupe_retry_and_disable(tmp_path):
|
|
clock = [100.]
|
|
calls = []
|
|
outcomes = [503, 202]
|
|
events = []
|
|
def transport(*args):
|
|
calls.append(args)
|
|
return outcomes.pop(0)
|
|
root = tmp_path / 'notifications'
|
|
owner = NotificationOwner(root, store(events), clock=lambda: clock[0], transport=transport)
|
|
configure(owner, 'webhook')
|
|
events.append({'eventId': 'a'*32, 'kind': 'alarm', 'wallTimeNs': int(101e9)})
|
|
clock[0] = 101.
|
|
owner.tick()
|
|
assert len(calls) == 1 and owner.snapshot()['channels']['webhook']['lastError'] == 'http'
|
|
owner.close()
|
|
owner = NotificationOwner(root, store(events), clock=lambda: clock[0], transport=transport)
|
|
owner.tick()
|
|
assert len(calls) == 1
|
|
clock[0] = 132.
|
|
owner.tick()
|
|
assert len(calls) == 2 and calls[0][4] == calls[1][4]
|
|
assert owner.snapshot()['channels']['webhook']['lastState'] == 'sent'
|
|
owner.tick()
|
|
assert len(calls) == 2
|
|
configure(owner, 'webhook', enabled=False)
|
|
events.append({'eventId': 'b'*32, 'kind': 'warning', 'wallTimeNs': int(133e9)})
|
|
clock[0] = 133.
|
|
owner.tick()
|
|
assert len(calls) == 2
|
|
owner.close()
|
|
|
|
|
|
def test_real_http_contracts_and_independent_channels(tmp_path):
|
|
received = []
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def do_POST(self):
|
|
received.append((self.path, self.headers, self.rfile.read(int(self.headers['Content-Length']))))
|
|
self.send_response(200)
|
|
self.send_header('Content-Length', '100000000')
|
|
self.end_headers()
|
|
self.wfile.write(b'x') # Sender must read status only, not this unbounded/incomplete body.
|
|
def log_message(self, *args, **kwargs):
|
|
pass
|
|
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
owner = NotificationOwner(tmp_path / 'notifications', store(), allow_http=True)
|
|
try:
|
|
for name in ('discord', 'webhook', 'ntfy'):
|
|
configure(owner, name, f'http://127.0.0.1:{server.server_port}/{name}')
|
|
owner.action({'action': 'test', 'channel': name})
|
|
owner.action({'action': 'pushKey'})
|
|
receiver = ec.generate_private_key(ec.SECP256R1())
|
|
owner.action({'action': 'subscribe', 'subscription': {'endpoint': f'http://127.0.0.1:{server.server_port}/push',
|
|
'keys': {'p256dh': web_push.b64(web_push.public(receiver)), 'auth': web_push.b64(b'0123456789abcdef')}}})
|
|
owner.action({'action': 'configure', 'channel': 'webPush', 'enabled': True, 'url': '', 'token': ''})
|
|
owner.action({'action': 'test', 'channel': 'webPush'})
|
|
for _ in range(4):
|
|
owner.tick()
|
|
assert len(received) == 4
|
|
paths = {path: (headers, body) for path, headers, body in received}
|
|
assert json.loads(paths['/discord'][1])['content'].startswith('StarPilot Sentry Mode:')
|
|
assert json.loads(parse_qs(paths['/webhook'][1].decode())['event'][0])['kind'] == 'test'
|
|
assert paths['/ntfy'][0]['Priority'] == 'urgent'
|
|
assert paths['/ntfy'][1].startswith(b'This is a test')
|
|
for path, (headers, _) in paths.items():
|
|
assert len(headers['Idempotency-Key']) == 64
|
|
if path != '/push':
|
|
assert headers['Authorization'] == 'Bearer private-token'
|
|
assert paths['/push'][0]['Authorization'].startswith('vapid t=')
|
|
assert paths['/push'][0]['Content-Encoding'] == 'aes128gcm'
|
|
assert len(paths['/push'][1]) > 100 and b'This is a test' not in paths['/push'][1]
|
|
assert all(owner.snapshot()['channels'][name]['lastState'] == 'sent' for name in ('webPush', 'discord', 'webhook', 'ntfy'))
|
|
finally:
|
|
owner.close()
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=5)
|
|
|
|
|
|
def test_push_encrypts_decryptable_payload_and_valid_signed_vapid():
|
|
receiver = ec.generate_private_key(ec.SECP256R1())
|
|
auth = b'0123456789abcdef'
|
|
subscription = {'endpoint': 'https://push.example.test/private', 'keys': {'p256dh': web_push.b64(web_push.public(receiver)), 'auth': web_push.b64(auth)}}
|
|
pem = web_push.new_key()
|
|
message = {'eventId': 'a'*32, 'body': 'Parked motion detected'}
|
|
body, headers = web_push.request(subscription, message, pem, now=100)
|
|
salt, rs, key_length = body[:16], struct.unpack('!I', body[16:20])[0], body[20]
|
|
assert rs == 4096 and key_length == 65
|
|
sender_public = body[21:86]
|
|
shared = receiver.exchange(ec.ECDH(), ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), sender_public))
|
|
ikm = HKDF(algorithm=hashes.SHA256(), length=32, salt=auth, info=b'WebPush: info\x00' + web_push.public(receiver) + sender_public).derive(shared)
|
|
cek = HKDF(algorithm=hashes.SHA256(), length=16, salt=salt, info=b'Content-Encoding: aes128gcm\x00').derive(ikm)
|
|
nonce = HKDF(algorithm=hashes.SHA256(), length=12, salt=salt, info=b'Content-Encoding: nonce\x00').derive(ikm)
|
|
plaintext = AESGCM(cek).decrypt(nonce, body[86:], None)
|
|
assert plaintext[-1] == 2 and json.loads(plaintext[:-1]) == message
|
|
assert b'Parked motion' not in body
|
|
token = headers['Authorization'].split('t=', 1)[1].split(',', 1)[0]
|
|
head, claims, sig = token.split('.')
|
|
claim_values = json.loads(web_push.unb64(claims))
|
|
assert claim_values['aud'] == 'https://push.example.test' and claim_values['exp'] == 43300
|
|
raw_signature = web_push.unb64(sig)
|
|
der = utils.encode_dss_signature(int.from_bytes(raw_signature[:32], 'big'), int.from_bytes(raw_signature[32:], 'big'))
|
|
web_push.load_key(pem).public_key().verify(der, (head + '.' + claims).encode(), ec.ECDSA(hashes.SHA256()))
|
|
|
|
|
|
def test_bounded_retry_and_expired_push_subscription(tmp_path):
|
|
clock = [100.]
|
|
calls = []
|
|
owner = NotificationOwner(tmp_path / 'notifications', store(), clock=lambda: clock[0], transport=lambda *args: calls.append(args) or 503)
|
|
try:
|
|
configure(owner, 'ntfy')
|
|
owner.action({'action': 'test', 'channel': 'ntfy'})
|
|
for stamp in (100., 130., 250., 850., 2000.):
|
|
clock[0] = stamp
|
|
owner.tick()
|
|
assert len(calls) == 4 and owner.snapshot()['channels']['ntfy']['lastState'] == 'failed'
|
|
owner.action({'action': 'pushKey'})
|
|
receiver = ec.generate_private_key(ec.SECP256R1())
|
|
owner.action({'action': 'subscribe', 'subscription': {'endpoint': 'https://push.example.test/private',
|
|
'keys': {'p256dh': web_push.b64(web_push.public(receiver)), 'auth': web_push.b64(b'0123456789abcdef')}}})
|
|
owner.action({'action': 'configure', 'channel': 'webPush', 'enabled': True, 'url': '', 'token': ''})
|
|
owner.action({'action': 'test', 'channel': 'webPush'})
|
|
owner.transport = lambda *args: 410
|
|
owner.tick()
|
|
assert owner.snapshot()['subscriptionCount'] == 0
|
|
assert owner.snapshot()['channels']['webPush']['lastError'] == 'expired'
|
|
finally:
|
|
owner.close()
|
|
|
|
|
|
def test_published_rfc8291_section5_exact_ciphertext():
|
|
# Independent published wire vector, https://www.rfc-editor.org/rfc/rfc8291#section-5
|
|
subscription = {'endpoint': 'https://push.example.net/push/test', 'keys': {
|
|
'auth': 'BTBZMqHH6r4Tts7J_aSIgg',
|
|
'p256dh': 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4'}}
|
|
sender = ec.derive_private_key(int.from_bytes(web_push.unb64('yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw'), 'big'), ec.SECP256R1())
|
|
expected = web_push.unb64('DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml' +
|
|
'mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPT' +
|
|
'pK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN')
|
|
actual = web_push.encrypt(subscription, b'When I grow up, I want to be a watermelon', sender=sender, salt=expected[:16])
|
|
assert actual == expected
|
|
|
|
|
|
@pytest.mark.parametrize('broken', [None, [], {}, 'bad', float('nan')])
|
|
def test_corrupt_saved_generation_and_timestamp_never_repaired_or_sent(tmp_path, broken):
|
|
root = tmp_path / 'notifications'
|
|
owner = NotificationOwner(root, store())
|
|
configure(owner, 'webhook')
|
|
owner.close()
|
|
path = root / 'state.json'
|
|
value = json.loads(path.read_text())
|
|
value['channels']['webhook']['generation'] = broken
|
|
original = json.dumps(value).encode()
|
|
path.write_bytes(original)
|
|
calls = []
|
|
owner = NotificationOwner(root, store(), transport=lambda *args: calls.append(args) or 202)
|
|
try:
|
|
with pytest.raises(NotificationUnavailable):
|
|
owner.snapshot()
|
|
owner.tick()
|
|
assert calls == [] and path.read_bytes() == original
|
|
finally:
|
|
owner.close()
|
|
|
|
|
|
def test_motion_alerts_attach_saved_camera_images(tmp_path):
|
|
received = []
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def do_POST(self):
|
|
received.append((self.path, self.headers, self.rfile.read(int(self.headers['Content-Length']))))
|
|
self.send_response(204)
|
|
self.end_headers()
|
|
def log_message(self, *args, **kwargs):
|
|
pass
|
|
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
jpgs = {'wide': b'\xff\xd8wide\xff\xd9', 'cabin': b'\xff\xd8cabin\xff\xd9'}
|
|
clock = [1000.0]
|
|
event = {'eventId': 'a' * 32, 'kind': 'warning', 'wallTimeNs': int((clock[0] + 1) * 1e9)} # after configure
|
|
events = SimpleNamespace(snapshot=lambda: {'events': [event]}, images=lambda event_id: list(jpgs),
|
|
image=lambda event_id, camera: jpgs[camera])
|
|
owner = NotificationOwner(tmp_path / 'notifications', events, clock=lambda: clock[0], allow_http=True)
|
|
try:
|
|
for name in ('discord', 'webhook'):
|
|
configure(owner, name, f'http://127.0.0.1:{server.server_port}/{name}')
|
|
clock[0] += 2
|
|
for _ in range(4):
|
|
owner.tick()
|
|
paths = {path: (headers, body) for path, headers, body in received}
|
|
assert set(paths) == {'/discord', '/webhook'}
|
|
for _path, (headers, body) in paths.items():
|
|
assert headers['Content-Type'].startswith('multipart/form-data')
|
|
assert b'name="files[0]"; filename="wide.jpg"' in body and jpgs['wide'] in body
|
|
assert b'name="files[1]"; filename="cabin.jpg"' in body and jpgs['cabin'] in body
|
|
assert b'Parked motion detected (warning).' in body
|
|
assert b'name="payload_json"' in paths['/discord'][1]
|
|
assert b'name="event"' in paths['/webhook'][1]
|
|
finally:
|
|
owner.close()
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=5)
|
|
|
|
|
|
def test_unreadable_image_still_sends_text(tmp_path):
|
|
def image(event_id, camera):
|
|
raise OSError('gone')
|
|
events = SimpleNamespace(snapshot=lambda: {'events': []}, images=lambda event_id: ['wide'], image=image)
|
|
owner = NotificationOwner(tmp_path / 'notifications', events)
|
|
try:
|
|
assert owner._attachments({'eventId': 'a' * 32, 'kind': 'warning'}) == []
|
|
assert owner._attachments({'eventId': 'a' * 32, 'kind': 'test'}) == []
|
|
finally:
|
|
owner.close()
|
|
|
|
|
|
@pytest.mark.parametrize('name,kind', [('ntfy', 'warning'), ('discord', 'test'), ('webhook', 'test')])
|
|
def test_text_only_delivery_does_not_load_event_images(tmp_path, monkeypatch, name, kind):
|
|
reads = []
|
|
calls = []
|
|
def images(event_id):
|
|
reads.append(event_id)
|
|
return ['wide']
|
|
events = SimpleNamespace(snapshot=lambda: {'events': []}, images=images, image=lambda *args: b'private')
|
|
response = SimpleNamespace(status_code=204, close=lambda: None)
|
|
monkeypatch.setattr('openpilot.starpilot.sentry_mode.notifications.requests.post', lambda *args, **kwargs: calls.append(kwargs) or response)
|
|
owner = NotificationOwner(tmp_path / 'notifications', events)
|
|
try:
|
|
owner._send(name, {'token': '', 'url': 'https://example.test'}, None,
|
|
{'eventId': 'a' * 32, 'kind': kind, 'created': 1000.0}, 'key', '')
|
|
assert reads == []
|
|
assert len(calls) == 1 and not calls[0].get('files')
|
|
finally:
|
|
owner.close()
|
|
|
|
|
|
@pytest.mark.parametrize('failure', ['listing', 'reading'])
|
|
@pytest.mark.parametrize('name', ['discord', 'webhook'])
|
|
def test_missing_saved_images_send_original_text_contract(tmp_path, monkeypatch, failure, name):
|
|
calls = []
|
|
def images(event_id):
|
|
if failure == 'listing':
|
|
raise OSError('gone')
|
|
return ['wide', 'cabin']
|
|
def image(event_id, camera):
|
|
raise OSError('gone')
|
|
events = SimpleNamespace(snapshot=lambda: {'events': []}, images=images, image=image)
|
|
response = SimpleNamespace(status_code=204, close=lambda: None)
|
|
monkeypatch.setattr('openpilot.starpilot.sentry_mode.notifications.requests.post', lambda *args, **kwargs: calls.append(kwargs) or response)
|
|
owner = NotificationOwner(tmp_path / 'notifications', events)
|
|
try:
|
|
assert owner._send(name, {'token': 'token', 'url': 'https://example.test'}, None,
|
|
{'eventId': 'a' * 32, 'kind': 'warning', 'created': 1000.0}, 'key', '') == 204
|
|
sent = calls[0]
|
|
assert not sent.get('files')
|
|
if name == 'discord':
|
|
assert sent['json']['content'] == 'StarPilot Sentry Mode: Parked motion detected (warning).'
|
|
else:
|
|
assert sent['data']['content'] == 'StarPilot Sentry Mode: Parked motion detected (warning).'
|
|
assert json.loads(sent['data']['event']) == {'eventId': 'a' * 32, 'kind': 'warning', 'systemTimeMs': 1000000}
|
|
finally:
|
|
owner.close()
|
|
|
|
|
|
def test_app_subscription_dispatch_redaction_and_expired_removal(tmp_path):
|
|
calls = []
|
|
clock = [100.]
|
|
owner = NotificationOwner(tmp_path / 'notifications', store(), clock=lambda: clock[0], transport=lambda *args: calls.append(args) or 410)
|
|
try:
|
|
snapshot = owner.action({'action': 'appSubscribe', 'token': 'private-fcm-token', 'appId': 'com.starpilot.galaxy', 'label': 'Phone'})
|
|
sub_id = snapshot['appSubscriptions'][0]['id']
|
|
assert len(sub_id) == 12 and 'private-fcm-token' not in json.dumps(snapshot)
|
|
assert not snapshot['appPushAvailable']
|
|
owner.action({'action': 'configure', 'channel': 'appPush', 'enabled': True, 'url': 'https://relay.example', 'token': ''})
|
|
with pytest.raises(LookupError):
|
|
owner.action({'action': 'test', 'target': 'app', 'id': 'missing'})
|
|
owner.action({'action': 'test', 'target': 'app', 'id': sub_id})
|
|
owner.tick()
|
|
assert calls[0][0] == 'appPush' and calls[0][2]['token'] == 'private-fcm-token'
|
|
assert owner.snapshot()['appSubscriptions'] == []
|
|
finally:
|
|
owner.close()
|
|
|
|
|
|
def test_app_success_dedupe_selfie_and_migration(tmp_path):
|
|
calls = []
|
|
clock = [100.]
|
|
events = []
|
|
root = tmp_path / 'notifications'
|
|
owner = NotificationOwner(root, store(events), clock=lambda: clock[0], transport=lambda *args: calls.append(args) or 200)
|
|
owner.action({'action': 'appSubscribe', 'token': 'token', 'appId': 'com.starpilot.galaxy', 'label': 'Phone'})
|
|
owner.action({'action': 'configure', 'channel': 'appPush', 'enabled': True, 'url': 'https://relay.example', 'token': ''})
|
|
events.extend([{'eventId': 'a' * 32, 'kind': 'selfie', 'wallTimeNs': int(101e9)}, {'eventId': 'b' * 32, 'kind': 'warning', 'wallTimeNs': int(101e9)}])
|
|
clock[0] = 102.
|
|
owner.tick()
|
|
owner.tick()
|
|
assert len(calls) == 1
|
|
assert owner.snapshot()['appSubscriptions'][0]['lastResult'] == 'Delivered to relay'
|
|
owner.close()
|
|
reopened = NotificationOwner(root, store(events), clock=lambda: clock[0])
|
|
assert reopened.available and len(reopened.snapshot()['appSubscriptions']) == 1
|
|
reopened.close()
|
|
state = json.loads((root / 'state.json').read_text())
|
|
state.pop('appSubscriptions')
|
|
state['channels'].pop('appPush')
|
|
state['jobs'] = {}
|
|
(root / 'state.json').write_text(json.dumps(state))
|
|
migrated = NotificationOwner(root, store())
|
|
assert migrated.available and migrated.snapshot()['appSubscriptions'] == []
|
|
migrated.close()
|
|
|
|
|
|
def test_webhook_job_survives_restart_with_empty_subscription_id(tmp_path):
|
|
root = tmp_path / 'notifications'
|
|
owner = NotificationOwner(root, store())
|
|
configure(owner, 'webhook')
|
|
owner.action({'action': 'test', 'channel': 'webhook'})
|
|
owner.close()
|
|
reopened = NotificationOwner(root, store())
|
|
try:
|
|
assert reopened.available
|
|
assert reopened.snapshot()['channels']['webhook']['pending'] == 1
|
|
finally:
|
|
reopened.close()
|
|
|
|
|
|
def test_ios_bundle_id_can_subscribe(tmp_path):
|
|
owner = NotificationOwner(tmp_path / 'notifications', store(), transport=lambda *args: 200)
|
|
try:
|
|
snapshot = owner.action({'action': 'appSubscribe', 'token': 'ios-fcm-token', 'appId': 'link.galaxy.bluetooth.natehuby76', 'label': 'iPhone'})
|
|
assert snapshot['appSubscriptions'][0]['appId'] == 'link.galaxy.bluetooth.natehuby76'
|
|
finally:
|
|
owner.close()
|