mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 21:43:44 +08:00
a531afd02a
Expose pairing readiness and coordinate adapter hand-offs with Android Auto. Show app pairings separately, support individual removal, and improve pairing controls on both comma screens. Add Galaxy companion endpoints and web asset manifests, preserve the plain camera look, and fix headless speed-limit shapes.
247 lines
10 KiB
Python
247 lines
10 KiB
Python
import hashlib
|
|
import hmac
|
|
import json
|
|
from pathlib import Path
|
|
import struct
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "bridge"))
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "vendor"))
|
|
from Crypto.Cipher import AES
|
|
from Crypto.PublicKey import ECC
|
|
from dbus_next import DBusError, Variant
|
|
|
|
import claim
|
|
from pairing import ensure_store, load_keys, load_phones, remove_phone
|
|
from protocol import Assembler, packets, seal
|
|
from server import Gateway
|
|
|
|
|
|
class Proxy:
|
|
calls = 0
|
|
def handle(self, request):
|
|
self.calls += 1
|
|
return {"id": request["id"], "status": 200, "headers": {}, "body": ""}
|
|
|
|
|
|
def phone_request(name="Pixel 8"):
|
|
private = ECC.generate(curve="P-256")
|
|
public = b"\x04" + int(private.pointQ.x).to_bytes(32, "big") + int(private.pointQ.y).to_bytes(32, "big")
|
|
return private, claim.REQUEST_MAGIC + public + name.encode()
|
|
|
|
|
|
def phone_open(private, body, challenge):
|
|
"""What an app does with the reply: the same derive/unseal on the phone side."""
|
|
assert body[:4] == claim.REPLY_MAGIC
|
|
server_public, nonce, rest = body[4:69], body[69:81], body[81:]
|
|
key = claim.derive(int(private.d), server_public, challenge)
|
|
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
|
cipher.update(claim.AAD)
|
|
return json.loads(cipher.decrypt_and_verify(rest[:-16], rest[-16:]))
|
|
|
|
|
|
class ClaimWindowTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.dir = tempfile.TemporaryDirectory()
|
|
self.path = Path(self.dir.name) / "claim.json"
|
|
|
|
def tearDown(self):
|
|
self.dir.cleanup()
|
|
|
|
def test_window_opens_expires_and_is_private(self):
|
|
self.assertIsNone(claim.window(self.path))
|
|
claim.arm("AA:BB:CC:DD:EE:FF", "comma", self.path, seconds=60, now=1000)
|
|
self.assertEqual(self.path.stat().st_mode & 0o777, 0o600)
|
|
self.assertIsNotNone(claim.window(self.path, now=1059))
|
|
self.assertIsNone(claim.window(self.path, now=1060))
|
|
|
|
def test_finish_closes_the_window_once(self):
|
|
claim.arm("AA:BB:CC:DD:EE:FF", "comma", self.path)
|
|
self.assertTrue(claim.finish("Pixel 8", "0a1b2c3d", self.path))
|
|
self.assertFalse(claim.finish("Other", "ffffffff", self.path))
|
|
self.assertIsNone(claim.window(self.path))
|
|
self.assertEqual(claim.status(self.path), {"claimed": "Pixel 8", "paired": False})
|
|
|
|
def test_app_and_music_in_one_window_link_once_in_either_order(self):
|
|
for first in ("app", "music"):
|
|
claim.arm("AA:BB:CC:DD:EE:FF", "comma", self.path, now=time.monotonic())
|
|
steps = {"app": lambda: claim.finish("Pixel 8", "0a1b2c3d", self.path),
|
|
"music": lambda: claim.note_paired("11:22:33:44:55:66", self.path)}
|
|
steps[first]()
|
|
self.assertIsNone(claim.take_link(self.path))
|
|
steps["music" if first == "app" else "app"]()
|
|
self.assertEqual(claim.take_link(self.path), ("0a1b2c3d", "11:22:33:44:55:66"))
|
|
self.assertIsNone(claim.take_link(self.path))
|
|
|
|
def test_music_pairing_after_the_window_ends_is_late_but_ignored_when_very_late(self):
|
|
claim.arm("AA:BB:CC:DD:EE:FF", "comma", self.path, seconds=60, now=1000)
|
|
self.assertTrue(claim.note_paired("11:22:33:44:55:66", self.path, now=1100))
|
|
self.assertFalse(claim.note_paired("11:22:33:44:55:66", self.path, now=1200))
|
|
|
|
def test_view_counts_down_and_reports_the_claim(self):
|
|
self.assertEqual(claim.view(self.path, now=1000), {"open": False, "left": 0, "claimed": "", "paired": False})
|
|
claim.arm("AA:BB:CC:DD:EE:FF", "comma", self.path, seconds=120, now=1000)
|
|
self.assertEqual(claim.view(self.path, now=1000.5), {"open": True, "left": 120, "claimed": "", "paired": False})
|
|
start = time.monotonic()
|
|
claim.arm("AA:BB:CC:DD:EE:FF", "comma", self.path, seconds=120, now=start)
|
|
claim.finish("Pixel 8", "0a1b2c3d", self.path)
|
|
claim.note_paired("11:22:33:44:55:66", self.path, now=start + 10)
|
|
self.assertEqual(claim.view(self.path, now=start + 60), {"open": True, "left": 60, "claimed": "Pixel 8", "paired": True})
|
|
self.assertEqual(claim.view(self.path, now=start + 130)["open"], False)
|
|
self.assertEqual(claim.view(self.path, now=start + 181), {"open": False, "left": 0, "claimed": "", "paired": False})
|
|
|
|
def test_clear_and_missing_file(self):
|
|
claim.arm("AA:BB:CC:DD:EE:FF", "comma", self.path)
|
|
claim.clear(self.path)
|
|
claim.clear(self.path)
|
|
self.assertIsNone(claim.window(self.path))
|
|
self.assertIsNone(claim.window(None))
|
|
|
|
def test_requests_must_be_well_formed(self):
|
|
_, good = phone_request()
|
|
self.assertEqual(claim.parse_request(good)[1], "Pixel 8")
|
|
for bad in (b"GCL1", b"XXXX" + good[4:], good[:4] + b"\x02" + good[5:], good[:30]):
|
|
with self.assertRaises(ValueError):
|
|
claim.parse_request(bad)
|
|
with self.assertRaises(ValueError):
|
|
claim.validate(b"\x04" + bytes(64))
|
|
|
|
|
|
class ClaimGatewayTests(unittest.IsolatedAsyncioTestCase):
|
|
def setUp(self):
|
|
self.dir = tempfile.TemporaryDirectory()
|
|
root = Path(self.dir.name)
|
|
self.keys = root / "pairing.json"
|
|
self.claim = root / "claim.json"
|
|
ensure_store(self.keys)
|
|
self.proxy = Proxy()
|
|
self.gateway = Gateway(load_keys(self.keys), self.proxy, self.keys, self.claim)
|
|
self.options = {"device": Variant("o", "/org/bluez/hci0/dev_01_02_03_04_05_06"), "mtu": Variant("q", 185)}
|
|
|
|
def tearDown(self):
|
|
self.dir.cleanup()
|
|
|
|
async def send(self, data):
|
|
state = self.gateway.session(self.options, fresh=True)
|
|
for packet in packets(struct.pack(">I", len(data)) + data):
|
|
self.gateway.write(packet, self.options)
|
|
await state.task
|
|
return state
|
|
|
|
def read_reply(self, state):
|
|
assembler, result = Assembler(), None
|
|
while state.index < len(state.response):
|
|
result = assembler.add(self.gateway.read(self.options))
|
|
return result
|
|
|
|
async def test_no_window_means_no_session_and_no_key(self):
|
|
with self.assertRaises(DBusError):
|
|
self.gateway.session(self.options, fresh=True)
|
|
self.assertEqual(load_phones(self.keys), [])
|
|
|
|
async def test_window_hands_one_phone_its_key(self):
|
|
claim.arm("AA:BB:CC:DD:EE:FF", "comma", self.claim)
|
|
private, data = phone_request()
|
|
state = await self.send(data)
|
|
reply = phone_open(private, self.read_reply(state), state.challenge)
|
|
self.assertEqual(reply["adapterAddress"], "AA:BB:CC:DD:EE:FF")
|
|
phones = load_phones(self.keys)
|
|
self.assertEqual([(p["id"], p["key"].hex(), p["label"]) for p in phones], [(reply["id"], reply["key"], "Pixel 8")])
|
|
self.assertEqual(claim.status(self.claim), {"claimed": "Pixel 8", "paired": False})
|
|
# The same connection keeps working with the new key, and the key change does not close it.
|
|
self.gateway.refresh_key()
|
|
self.assertIs(self.gateway.sessions[self.options["device"].value], state)
|
|
# A second phone in the same window is refused.
|
|
_, second = phone_request("Intruder")
|
|
state2 = await self.send(second)
|
|
self.assertFalse(state2.response)
|
|
self.assertEqual(len(load_phones(self.keys)), 1)
|
|
|
|
async def test_key_refresh_during_claim_keeps_reply_session(self):
|
|
claim.arm("AA:BB:CC:DD:EE:FF", "comma", self.claim)
|
|
private, data = phone_request()
|
|
|
|
async def interleaved_work(function, *args):
|
|
result = function(*args)
|
|
self.gateway.refresh_key()
|
|
return result
|
|
|
|
with patch("server.asyncio.to_thread", side_effect=interleaved_work):
|
|
state = await self.send(data)
|
|
self.assertFalse(state.closed)
|
|
self.assertIs(self.gateway.sessions[self.options["device"].value], state)
|
|
reply = phone_open(private, self.read_reply(state), state.challenge)
|
|
self.assertEqual(load_keys(self.keys)[reply["id"]].hex(), reply["key"])
|
|
|
|
async def test_key_removed_during_claim_is_not_delivered(self):
|
|
claim.arm("AA:BB:CC:DD:EE:FF", "comma", self.claim)
|
|
_, data = phone_request()
|
|
|
|
async def interleaved_work(function, *args):
|
|
result = function(*args)
|
|
if function.__name__ == "add_phone":
|
|
remove_phone(result[0], self.keys)
|
|
self.gateway.refresh_key()
|
|
return result
|
|
|
|
with patch("server.asyncio.to_thread", side_effect=interleaved_work):
|
|
state = await self.send(data)
|
|
self.assertTrue(state.closed)
|
|
self.assertIsNone(state.phone)
|
|
self.assertFalse(state.response)
|
|
|
|
async def test_music_bond_made_first_links_the_claimed_phone(self):
|
|
claim.arm("AA:BB:CC:DD:EE:FF", "comma", self.claim)
|
|
claim.note_paired("11:22:33:44:55:66", self.claim)
|
|
private, data = phone_request()
|
|
state = await self.send(data)
|
|
reply = phone_open(private, self.read_reply(state), state.challenge)
|
|
self.assertEqual(load_phones(self.keys)[0]["bt_address"], "11:22:33:44:55:66")
|
|
self.assertEqual(load_phones(self.keys)[0]["id"], reply["id"])
|
|
|
|
async def test_claimed_key_passes_the_normal_handshake(self):
|
|
claim.arm("AA:BB:CC:DD:EE:FF", "comma", self.claim)
|
|
private, data = phone_request()
|
|
state = await self.send(data)
|
|
key = bytes.fromhex(phone_open(private, self.read_reply(state), state.challenge)["key"])
|
|
self.gateway.refresh_key()
|
|
state = self.gateway.session(self.options, fresh=True)
|
|
request = {"id": "a", "session": state.challenge.hex(), "counter": 1, "path": "/_bridge/health", "method": "GET", "body": ""}
|
|
for packet in packets(seal(request, key, "request")):
|
|
self.gateway.write(packet, self.options)
|
|
await state.task
|
|
self.assertTrue(state.response)
|
|
|
|
async def test_expired_window_and_bad_points_are_refused(self):
|
|
claim.arm("AA:BB:CC:DD:EE:FF", "comma", self.claim, seconds=60, now=time.monotonic() - 61)
|
|
with self.assertRaises(DBusError):
|
|
self.gateway.session(self.options, fresh=True)
|
|
claim.arm("AA:BB:CC:DD:EE:FF", "comma", self.claim)
|
|
state = await self.send(claim.REQUEST_MAGIC + b"\x04" + bytes(64))
|
|
self.assertFalse(state.response)
|
|
self.assertEqual(load_phones(self.keys), [])
|
|
self.assertIsNotNone(claim.window(self.claim)) # a bad request does not use up the window
|
|
|
|
async def test_gateway_without_claim_path_is_unchanged(self):
|
|
plain = Gateway({}, self.proxy, self.keys)
|
|
with self.assertRaises(DBusError):
|
|
plain.session(self.options, fresh=True)
|
|
|
|
def test_derive_matches_rfc5869(self):
|
|
a, b = ECC.generate(curve="P-256"), ECC.generate(curve="P-256")
|
|
def pub(k):
|
|
return b"\x04" + int(k.pointQ.x).to_bytes(32, "big") + int(k.pointQ.y).to_bytes(32, "big")
|
|
salt = bytes(range(16))
|
|
self.assertEqual(claim.derive(int(a.d), pub(b), salt), claim.derive(int(b.d), pub(a), salt))
|
|
secret = int((b.pointQ * int(a.d)).x).to_bytes(32, "big")
|
|
prk = hmac.new(salt, secret, hashlib.sha256).digest()
|
|
self.assertEqual(claim.derive(int(a.d), pub(b), salt), hmac.new(prk, b"galaxy-claim-v1\x01", hashlib.sha256).digest())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|