pyhubblenetwork 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hubblenetwork/__init__.py +27 -0
- hubblenetwork/ble.py +93 -0
- hubblenetwork/cli.py +309 -0
- hubblenetwork/cloud.py +180 -0
- hubblenetwork/crypto.py +86 -0
- hubblenetwork/device.py +37 -0
- hubblenetwork/errors.py +124 -0
- hubblenetwork/org.py +87 -0
- hubblenetwork/packets.py +39 -0
- pyhubblenetwork-0.0.1.dist-info/METADATA +322 -0
- pyhubblenetwork-0.0.1.dist-info/RECORD +14 -0
- pyhubblenetwork-0.0.1.dist-info/WHEEL +4 -0
- pyhubblenetwork-0.0.1.dist-info/entry_points.txt +2 -0
- pyhubblenetwork-0.0.1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# hubblenetwork/__init__.py
|
|
2
|
+
"""
|
|
3
|
+
Hubble Python SDK — public API façade.
|
|
4
|
+
Import from here; internal module layout may change without notice.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from . import ble
|
|
8
|
+
from . import cloud
|
|
9
|
+
|
|
10
|
+
from .packets import Location, EncryptedPacket, DecryptedPacket
|
|
11
|
+
from .device import Device
|
|
12
|
+
from .org import Organization
|
|
13
|
+
from .crypto import decrypt
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"ble",
|
|
17
|
+
"cloud",
|
|
18
|
+
"decrypt",
|
|
19
|
+
"Location",
|
|
20
|
+
"EncryptedPacket",
|
|
21
|
+
"DecryptedPacket",
|
|
22
|
+
"Device",
|
|
23
|
+
"Organization",
|
|
24
|
+
"flash_elf",
|
|
25
|
+
"fetch_elf",
|
|
26
|
+
"patch_elf",
|
|
27
|
+
]
|
hubblenetwork/ble.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# hubblenetwork/ble.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Optional
|
|
7
|
+
import geocoder
|
|
8
|
+
|
|
9
|
+
from bleak import BleakScanner
|
|
10
|
+
|
|
11
|
+
# Import your dataclass
|
|
12
|
+
from .packets import (
|
|
13
|
+
Location,
|
|
14
|
+
EncryptedPacket,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
16-bit UUID 0xFCA6 in 128-bit Bluetooth Base UUID form
|
|
19
|
+
|
|
20
|
+
Bluetooth spec defines a base UUID 0000xxxx-0000-1000-8000-00805F9B34FB.
|
|
21
|
+
Any 16-bit (or 32-bit) UUID is expanded into that base by substituting xxxx.
|
|
22
|
+
|
|
23
|
+
Libraries normalize to consistent 128-bit strings so you don’t have to guess
|
|
24
|
+
whether a platform will report 16- vs 128-bit in scan results.
|
|
25
|
+
|
|
26
|
+
In bleak, AdvertisementData.service_uuids and the keys in AdvertisementData.service_data
|
|
27
|
+
are 128-bit strings. So matching against the normalized 128-bit form is the most portable.
|
|
28
|
+
"""
|
|
29
|
+
_TARGET_UUID = "0000fca6-0000-1000-8000-00805f9b34fb"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_location() -> Location:
|
|
33
|
+
geo = geocoder.ip("me")
|
|
34
|
+
lat, lon = geo.latlng
|
|
35
|
+
return Location(lat=lat, lon=lon)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def scan(timeout: float) -> Optional[EncryptedPacket]:
|
|
39
|
+
"""
|
|
40
|
+
Scan for a BLE advertisement that includes service data for UUID 0xFCA6 and
|
|
41
|
+
return it as an EncryptedPacket (payload=data bytes, rssi from the adv).
|
|
42
|
+
Returns None if nothing is found within `timeout` seconds.
|
|
43
|
+
|
|
44
|
+
This is a synchronous convenience wrapper around an asyncio scanner.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
async def _scan_async(ttl: float) -> List[EncryptedPacket]:
|
|
48
|
+
done = asyncio.Event()
|
|
49
|
+
packets = []
|
|
50
|
+
|
|
51
|
+
def on_detect(device, adv_data) -> None:
|
|
52
|
+
nonlocal packets
|
|
53
|
+
# Normalize to a dict; bleak provides service_data as {uuid_str: bytes}
|
|
54
|
+
service_data = getattr(adv_data, "service_data", None) or {}
|
|
55
|
+
payload = None
|
|
56
|
+
|
|
57
|
+
# Keys are 128-bit UUID strings; compare lowercased
|
|
58
|
+
for uuid_str, data in service_data.items():
|
|
59
|
+
if (uuid_str or "").lower() == _TARGET_UUID:
|
|
60
|
+
payload = bytes(data)
|
|
61
|
+
break
|
|
62
|
+
|
|
63
|
+
if payload is not None:
|
|
64
|
+
rssi = getattr(adv_data, "rssi", getattr(device, "rssi", 0)) or 0
|
|
65
|
+
packets.append(
|
|
66
|
+
EncryptedPacket(
|
|
67
|
+
timestamp=int(datetime.now(timezone.utc).timestamp()),
|
|
68
|
+
location=_get_location(),
|
|
69
|
+
payload=payload,
|
|
70
|
+
rssi=int(rssi),
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Start scanning and wait for first match or timeout
|
|
75
|
+
async with BleakScanner(detection_callback=on_detect):
|
|
76
|
+
try:
|
|
77
|
+
await asyncio.wait_for(done.wait(), timeout=ttl)
|
|
78
|
+
except asyncio.TimeoutError:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
return packets
|
|
82
|
+
|
|
83
|
+
# Run the async scanner. If there's already a running event loop (e.g., Jupyter),
|
|
84
|
+
# you can adapt this to use `await _scan_async(timeout)` instead.
|
|
85
|
+
try:
|
|
86
|
+
return asyncio.run(_scan_async(timeout))
|
|
87
|
+
except RuntimeError:
|
|
88
|
+
# Fallback for environments with an active loop (e.g., notebooks/async apps)
|
|
89
|
+
loop = asyncio.get_event_loop()
|
|
90
|
+
if loop.is_running():
|
|
91
|
+
# Create a task and block until it’s done via a new Future
|
|
92
|
+
return loop.run_until_complete(_scan_async(timeout)) # type: ignore[func-returns-value]
|
|
93
|
+
return loop.run_until_complete(_scan_async(timeout))
|
hubblenetwork/cli.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# hubblenetwork/cli.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
import os
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
import base64
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import timezone, datetime
|
|
11
|
+
from typing import Optional
|
|
12
|
+
from hubblenetwork import Organization
|
|
13
|
+
from hubblenetwork import Device, DecryptedPacket, EncryptedPacket
|
|
14
|
+
from hubblenetwork import ble as ble_mod
|
|
15
|
+
from hubblenetwork import decrypt
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_env_or_fail(name: str) -> str:
|
|
19
|
+
val = os.getenv(name)
|
|
20
|
+
if not val:
|
|
21
|
+
raise click.ClickException(f"[ERROR] {name} environment variable not set")
|
|
22
|
+
return val
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_org_and_token(org_id, token) -> tuple[str, str]:
|
|
26
|
+
"""
|
|
27
|
+
Helper function that checks if the given token and/or org
|
|
28
|
+
are None and gets the env var if not
|
|
29
|
+
"""
|
|
30
|
+
if not token:
|
|
31
|
+
token = _get_env_or_fail("HUBBLE_API_TOKEN")
|
|
32
|
+
if not org_id:
|
|
33
|
+
org_id = _get_env_or_fail("HUBBLE_ORG_ID")
|
|
34
|
+
return org_id, token
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _print_packets_pretty(pkts) -> None:
|
|
38
|
+
"""Pretty-print an EncryptedPacket."""
|
|
39
|
+
for pkt in pkts:
|
|
40
|
+
ts = datetime.fromtimestamp(pkt.timestamp).strftime("%c")
|
|
41
|
+
loc = pkt.location
|
|
42
|
+
loc_str = (
|
|
43
|
+
f"{loc.lat:.6f},{loc.lon:.6f}"
|
|
44
|
+
if getattr(loc, "lat", None) is not None
|
|
45
|
+
else "unknown"
|
|
46
|
+
)
|
|
47
|
+
click.echo(click.style("=== BLE packet ===", bold=True))
|
|
48
|
+
click.echo(f"time: {ts}")
|
|
49
|
+
click.echo(f"rssi: {pkt.rssi} dBm")
|
|
50
|
+
click.echo(f"loc: {loc_str}")
|
|
51
|
+
# Show both hex and length
|
|
52
|
+
if isinstance(pkt, DecryptedPacket):
|
|
53
|
+
click.echo(f'payload: "{pkt.payload}"')
|
|
54
|
+
elif isinstance(pkt, EncryptedPacket):
|
|
55
|
+
click.echo(f"payload: {pkt.payload.hex()} ({len(pkt.payload)} bytes)")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _print_packets_csv(pkts) -> None:
|
|
59
|
+
for pkt in pkts:
|
|
60
|
+
ts = datetime.fromtimestamp(pkt.timestamp).strftime("%c")
|
|
61
|
+
if isinstance(pkt, DecryptedPacket):
|
|
62
|
+
payload = pkt.payload
|
|
63
|
+
elif isinstance(pkt, EncryptedPacket):
|
|
64
|
+
payload = pkt.payload.hex()
|
|
65
|
+
click.echo(f"{ts}, {pkt.location.lat:.6f}, {pkt.location.lon:.6f}, {payload}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _print_packets_kepler(pkts) -> None:
|
|
69
|
+
"""
|
|
70
|
+
https://kepler.gl/demo
|
|
71
|
+
|
|
72
|
+
Can ingest this JSON to visualize a travel path for a device.
|
|
73
|
+
"""
|
|
74
|
+
data = {
|
|
75
|
+
"type": "FeatureCollection",
|
|
76
|
+
"features": [
|
|
77
|
+
{
|
|
78
|
+
"type": "Feature",
|
|
79
|
+
"properties": {"vendor": "A"},
|
|
80
|
+
"geometry": {"type": "LineString", "coordinates": []},
|
|
81
|
+
}
|
|
82
|
+
],
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for pkt in pkts:
|
|
86
|
+
row = [pkt.location.lon, pkt.location.lat, 0, pkt.timestamp]
|
|
87
|
+
data["features"][0]["geometry"]["coordinates"].append(row)
|
|
88
|
+
click.echo(json.dumps(data))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _print_packets(pkts, output: str = "pretty") -> None:
|
|
92
|
+
func_name = f"_print_packets_{output.lower().strip()}"
|
|
93
|
+
func = getattr(sys.modules[__name__], func_name, None)
|
|
94
|
+
if callable(func):
|
|
95
|
+
func(pkts)
|
|
96
|
+
else:
|
|
97
|
+
_print_packets_pretty(pkts)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _print_device(dev: Device) -> None:
|
|
101
|
+
click.echo(f'id: "{dev.id}", ', nl=False)
|
|
102
|
+
click.echo(f'name: "{dev.name}", ', nl=False)
|
|
103
|
+
click.echo(f"tags: {str(dev.tags)}, ", nl=False)
|
|
104
|
+
ts = datetime.fromtimestamp(dev.created_ts).strftime("%c")
|
|
105
|
+
click.echo(f'created: "{ts}", ', nl=False)
|
|
106
|
+
click.echo(f"active: {str(dev.active)}", nl=False)
|
|
107
|
+
if dev.key:
|
|
108
|
+
click.secho(f', key: "{dev.key}"')
|
|
109
|
+
else:
|
|
110
|
+
click.echo("")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
114
|
+
def cli() -> None:
|
|
115
|
+
"""Hubble SDK CLI."""
|
|
116
|
+
# top-level group; subcommands are added below
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@cli.group()
|
|
120
|
+
def ble() -> None:
|
|
121
|
+
"""BLE utilities."""
|
|
122
|
+
# subgroup for BLE-related commands
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@ble.command("scan")
|
|
126
|
+
@click.option(
|
|
127
|
+
"--timeout",
|
|
128
|
+
"-t",
|
|
129
|
+
type=int,
|
|
130
|
+
default=5,
|
|
131
|
+
show_default=False,
|
|
132
|
+
help="Timeout when scanning",
|
|
133
|
+
)
|
|
134
|
+
@click.option(
|
|
135
|
+
"--key",
|
|
136
|
+
"-k",
|
|
137
|
+
type=str,
|
|
138
|
+
default=None,
|
|
139
|
+
show_default=False,
|
|
140
|
+
help="Attempt to decrypt any received packet with the given key",
|
|
141
|
+
)
|
|
142
|
+
@click.option("--ingest", is_flag=True)
|
|
143
|
+
def ble_scan(timeout, ingest: bool = False, key: str = None) -> None:
|
|
144
|
+
"""
|
|
145
|
+
Scan for UUID 0xFCA6 and print the first packet found within TIMEOUT seconds.
|
|
146
|
+
|
|
147
|
+
Example:
|
|
148
|
+
hubblenetwork ble scan 1
|
|
149
|
+
"""
|
|
150
|
+
click.secho(
|
|
151
|
+
f"[INFO] Scanning for Hubble devices (timeout={timeout}s)... ", nl=False
|
|
152
|
+
)
|
|
153
|
+
pkts = ble_mod.scan(timeout=timeout)
|
|
154
|
+
if len(pkts) == 0:
|
|
155
|
+
click.secho(f"[WARNING] No packet found within {timeout:.2f}s", fg="yellow")
|
|
156
|
+
raise SystemExit(1)
|
|
157
|
+
click.echo("[COMPLETE]")
|
|
158
|
+
|
|
159
|
+
click.echo("\n[INFO] Encrypted packets received:")
|
|
160
|
+
_print_packets(pkts)
|
|
161
|
+
|
|
162
|
+
# If we have a key, attempt to decrypt
|
|
163
|
+
if key:
|
|
164
|
+
key = bytearray(base64.b64decode(key))
|
|
165
|
+
decrypted_pkts = []
|
|
166
|
+
for pkt in pkts:
|
|
167
|
+
decrypted_pkt = decrypt(key, pkt)
|
|
168
|
+
if decrypted_pkt:
|
|
169
|
+
decrypted_pkts.append(decrypted_pkt)
|
|
170
|
+
if len(decrypted_pkts) > 0:
|
|
171
|
+
click.echo("\n[INFO] Locally decrypted packets:")
|
|
172
|
+
_print_packets(decrypted_pkts)
|
|
173
|
+
else:
|
|
174
|
+
click.secho("\n[WARNING] No locally decryptable packets found", fg="yellow")
|
|
175
|
+
|
|
176
|
+
if ingest:
|
|
177
|
+
click.echo("[INFO] Ingesting packet(s) into the backend... ", nl=False)
|
|
178
|
+
org = Organization(
|
|
179
|
+
org_id=_get_env_or_fail("HUBBLE_ORG_ID"),
|
|
180
|
+
api_token=_get_env_or_fail("HUBBLE_API_TOKEN"),
|
|
181
|
+
)
|
|
182
|
+
for pkt in pkts:
|
|
183
|
+
org.ingest_packet(pkt)
|
|
184
|
+
click.echo("[SUCCESS]")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@cli.group()
|
|
188
|
+
def org() -> None:
|
|
189
|
+
"""Organization utilities."""
|
|
190
|
+
# subgroup for organization-related commands
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@click.option(
|
|
194
|
+
"--org-id",
|
|
195
|
+
"-o",
|
|
196
|
+
type=str,
|
|
197
|
+
default=None,
|
|
198
|
+
show_default=False,
|
|
199
|
+
help="Organization ID (if not using HUBBLE_ORG_ID env var)",
|
|
200
|
+
)
|
|
201
|
+
@click.option(
|
|
202
|
+
"--token",
|
|
203
|
+
"-t",
|
|
204
|
+
type=str,
|
|
205
|
+
default=None,
|
|
206
|
+
show_default=False, # show default in --help
|
|
207
|
+
help="Token (if not using HUBBLE_API_TOKEN env var)",
|
|
208
|
+
)
|
|
209
|
+
@org.command("list-devices")
|
|
210
|
+
def list_devices(org_id, token) -> None:
|
|
211
|
+
org_id, token = _get_org_and_token(org_id, token)
|
|
212
|
+
|
|
213
|
+
org = Organization(org_id=org_id, api_token=token)
|
|
214
|
+
devices = org.list_devices()
|
|
215
|
+
for device in devices:
|
|
216
|
+
_print_device(device)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@click.option(
|
|
220
|
+
"--org-id",
|
|
221
|
+
"-o",
|
|
222
|
+
type=str,
|
|
223
|
+
default=None,
|
|
224
|
+
show_default=False,
|
|
225
|
+
help="Organization ID (if not using HUBBLE_ORG_ID env var)",
|
|
226
|
+
)
|
|
227
|
+
@click.option(
|
|
228
|
+
"--token",
|
|
229
|
+
"-t",
|
|
230
|
+
type=str,
|
|
231
|
+
default=None,
|
|
232
|
+
show_default=False, # show default in --help
|
|
233
|
+
help="Token (if not using HUBBLE_API_TOKEN env var)",
|
|
234
|
+
)
|
|
235
|
+
@org.command("register_device")
|
|
236
|
+
def register_device(org_id, token) -> None:
|
|
237
|
+
org_id, token = _get_org_and_token(org_id, token)
|
|
238
|
+
|
|
239
|
+
org = Organization(org_id=org_id, api_token=token)
|
|
240
|
+
click.secho(str(org.register_device()))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@org.command("get-packets")
|
|
244
|
+
@click.argument("device-id", type=str)
|
|
245
|
+
@click.option(
|
|
246
|
+
"--org-id",
|
|
247
|
+
"-o",
|
|
248
|
+
type=str,
|
|
249
|
+
default=None,
|
|
250
|
+
show_default=False,
|
|
251
|
+
help="Organization ID (if not using HUBBLE_ORG_ID env var)",
|
|
252
|
+
)
|
|
253
|
+
@click.option(
|
|
254
|
+
"--token",
|
|
255
|
+
"-t",
|
|
256
|
+
type=str,
|
|
257
|
+
default=None,
|
|
258
|
+
show_default=False, # show default in --help
|
|
259
|
+
help="Token (if not using HUBBLE_API_TOKEN env var)",
|
|
260
|
+
)
|
|
261
|
+
@click.option(
|
|
262
|
+
"--output",
|
|
263
|
+
type=str,
|
|
264
|
+
default=None,
|
|
265
|
+
show_default=False, # show default in --help
|
|
266
|
+
help="Output format (None, pretty, csv)",
|
|
267
|
+
)
|
|
268
|
+
@click.option(
|
|
269
|
+
"--days",
|
|
270
|
+
"-d",
|
|
271
|
+
type=int,
|
|
272
|
+
default=7,
|
|
273
|
+
show_default=False, # show default in --help
|
|
274
|
+
help="Number of days to query back (from now)",
|
|
275
|
+
)
|
|
276
|
+
def get_packets(device_id, org_id, token, output: str = None, days: int = 7) -> None:
|
|
277
|
+
org_id, token = _get_org_and_token(org_id, token)
|
|
278
|
+
|
|
279
|
+
org = Organization(org_id=org_id, api_token=token)
|
|
280
|
+
device = Device(id=device_id)
|
|
281
|
+
packets = org.retrieve_packets(device, days=days)
|
|
282
|
+
_print_packets(packets, output)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@cli.group()
|
|
286
|
+
def demo() -> None:
|
|
287
|
+
"""Demo functionality"""
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
291
|
+
"""
|
|
292
|
+
Entry point used by console_scripts.
|
|
293
|
+
|
|
294
|
+
Returns a process exit code instead of letting Click call sys.exit for easier testing.
|
|
295
|
+
"""
|
|
296
|
+
try:
|
|
297
|
+
# standalone_mode=False prevents Click from calling sys.exit itself.
|
|
298
|
+
cli.main(args=argv, prog_name="hubble", standalone_mode=False)
|
|
299
|
+
except SystemExit as e:
|
|
300
|
+
return int(e.code)
|
|
301
|
+
except Exception as e: # safety net to avoid tracebacks in user CLI
|
|
302
|
+
click.secho(f"Unexpected error: {e}", fg="red", err=True)
|
|
303
|
+
return 2
|
|
304
|
+
return 0
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
if __name__ == "__main__":
|
|
308
|
+
# Forward command-line args (excluding the program name) to main()
|
|
309
|
+
raise SystemExit(main())
|
hubblenetwork/cloud.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# hubble/cloud_api.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import httpx
|
|
4
|
+
import time
|
|
5
|
+
import base64
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
from collections.abc import MutableMapping
|
|
8
|
+
from .packets import EncryptedPacket, DecryptedPacket
|
|
9
|
+
from .device import Device
|
|
10
|
+
from .errors import (
|
|
11
|
+
BackendError,
|
|
12
|
+
NetworkError,
|
|
13
|
+
APITimeout,
|
|
14
|
+
raise_for_response,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
_API_BASE: str = "https://api.hubble.com/api"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _auth_headers(api_token: str) -> dict[str, str]:
|
|
21
|
+
return {
|
|
22
|
+
"Authorization": f"Bearer {api_token}",
|
|
23
|
+
"Accept": "application/json",
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _list_devices_endpoint(org_id: str) -> str:
|
|
29
|
+
return f"/org/{org_id}/devices"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _register_device_endpoint(org_id: str) -> str:
|
|
33
|
+
return f"/v2/org/{org_id}/devices"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _retrive_org_packets_endpoint(org_id: str) -> str:
|
|
37
|
+
return f"/org/{org_id}/packets"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _ingest_packets_endpoint(org_id: str) -> str:
|
|
41
|
+
return f"/org/{org_id}/packets"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def cloud_request(
|
|
45
|
+
method: str,
|
|
46
|
+
path: str,
|
|
47
|
+
*,
|
|
48
|
+
api_token: Optional[str] = None,
|
|
49
|
+
json: Any = None,
|
|
50
|
+
timeout_s: float = 10.0,
|
|
51
|
+
params: Optional[MutableMapping[str, Any]] = None,
|
|
52
|
+
) -> Any:
|
|
53
|
+
"""
|
|
54
|
+
Make a single HTTP request to the Hubble Cloud API and return parsed JSON.
|
|
55
|
+
|
|
56
|
+
- `method`: "GET", "POST", etc.
|
|
57
|
+
- `path`: endpoint path (e.g., "/devices" or "orgs/{id}/devices")
|
|
58
|
+
- `api_token`: API token for auth (optional, but recommended)
|
|
59
|
+
- `org_id`: if provided, will be added as query param `orgId=<org_id>`
|
|
60
|
+
(skip or embed in `path` if your endpoint uses a path param instead)
|
|
61
|
+
- `json`: request JSON body (for POST/PUT/PATCH)
|
|
62
|
+
- `timeout_s`: request timeout in seconds
|
|
63
|
+
- `params`: optional HTTP request parameters
|
|
64
|
+
"""
|
|
65
|
+
path = path.lstrip("/")
|
|
66
|
+
url = f"{_API_BASE}/{path}"
|
|
67
|
+
|
|
68
|
+
# headers
|
|
69
|
+
headers: MutableMapping[str, str] = {
|
|
70
|
+
"Accept": "application/json",
|
|
71
|
+
"Content-Type": "application/json",
|
|
72
|
+
}
|
|
73
|
+
if api_token:
|
|
74
|
+
headers["Authorization"] = f"Bearer {api_token}"
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
with httpx.Client(timeout=timeout_s) as client:
|
|
78
|
+
resp = client.request(
|
|
79
|
+
method.upper(), url, params=params, headers=headers, json=json
|
|
80
|
+
)
|
|
81
|
+
except httpx.TimeoutException as e:
|
|
82
|
+
raise APITimeout(f"Request timed out: {method} {url}") from e
|
|
83
|
+
except httpx.HTTPError as e:
|
|
84
|
+
raise NetworkError(f"Network error: {method} {url}: {e}") from e
|
|
85
|
+
|
|
86
|
+
if resp.status_code != 200:
|
|
87
|
+
body = None
|
|
88
|
+
try:
|
|
89
|
+
body = resp.json()
|
|
90
|
+
except Exception:
|
|
91
|
+
body = resp.text
|
|
92
|
+
raise_for_response(resp.status_code, body=body)
|
|
93
|
+
|
|
94
|
+
# Parse JSON body
|
|
95
|
+
try:
|
|
96
|
+
return resp.json()
|
|
97
|
+
except ValueError as e:
|
|
98
|
+
# Server said "application/json" but body isn't JSON
|
|
99
|
+
raise BackendError(f"Non-JSON response from {url}") from e
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def ingest(*, org_id: str, api_token: str, packet: EncryptedPacket) -> None:
|
|
103
|
+
"""Push an encrypted packet to the cloud."""
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def register_device(
|
|
108
|
+
*, org_id: str, api_token: str, name: Optional[str] = None
|
|
109
|
+
) -> Device:
|
|
110
|
+
"""Create a new device and return it."""
|
|
111
|
+
data = {
|
|
112
|
+
"n_devices": 1,
|
|
113
|
+
"encryption": "AES-256-CTR",
|
|
114
|
+
}
|
|
115
|
+
return cloud_request(
|
|
116
|
+
method="POST",
|
|
117
|
+
path=_register_device_endpoint(org_id),
|
|
118
|
+
api_token=api_token,
|
|
119
|
+
json=data,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def list_devices(*, org_id: str, api_token: str) -> list[Device]:
|
|
124
|
+
"""
|
|
125
|
+
List devices for the org (keys typically omitted).
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
json response from server
|
|
129
|
+
|
|
130
|
+
"""
|
|
131
|
+
return cloud_request(
|
|
132
|
+
method="GET",
|
|
133
|
+
path=_list_devices_endpoint(org_id),
|
|
134
|
+
api_token=api_token,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def retrieve_packets(
|
|
139
|
+
*, org_id: str, api_token: str, device_id: Optional[str] = None, days: int = 7
|
|
140
|
+
) -> Optional[DecryptedPacket]:
|
|
141
|
+
"""Fetch decrypted packets for a device."""
|
|
142
|
+
params = {"start": (int(time.time()) - (days * 24 * 60 * 60))}
|
|
143
|
+
if device_id:
|
|
144
|
+
params["device_id"] = device_id
|
|
145
|
+
return cloud_request(
|
|
146
|
+
method="GET",
|
|
147
|
+
path=_retrive_org_packets_endpoint(org_id),
|
|
148
|
+
api_token=api_token,
|
|
149
|
+
params=params,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def ingest_packet(*, org_id: str, api_token: str, packet: EncryptedPacket) -> None:
|
|
154
|
+
body = {
|
|
155
|
+
"ble_locations": [
|
|
156
|
+
{
|
|
157
|
+
"location": {
|
|
158
|
+
"latitude": packet.location.lat,
|
|
159
|
+
"longitude": packet.location.lon,
|
|
160
|
+
"timestamp": packet.timestamp,
|
|
161
|
+
"horizontal_accuracy": 42,
|
|
162
|
+
"altitude": 42,
|
|
163
|
+
"vertical_accuracy": 42,
|
|
164
|
+
},
|
|
165
|
+
"adv": [
|
|
166
|
+
{
|
|
167
|
+
"payload": base64.b64encode(packet.payload).decode("utf-8"),
|
|
168
|
+
"rssi": packet.rssi,
|
|
169
|
+
"timestamp": packet.timestamp,
|
|
170
|
+
}
|
|
171
|
+
],
|
|
172
|
+
}
|
|
173
|
+
]
|
|
174
|
+
}
|
|
175
|
+
return cloud_request(
|
|
176
|
+
method="POST",
|
|
177
|
+
path=_ingest_packets_endpoint(org_id),
|
|
178
|
+
api_token=api_token,
|
|
179
|
+
json=body,
|
|
180
|
+
)
|
hubblenetwork/crypto.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from Crypto.Cipher import AES
|
|
3
|
+
from Crypto.Hash import CMAC
|
|
4
|
+
from Crypto.Protocol.KDF import SP800_108_Counter
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
from .packets import EncryptedPacket, DecryptedPacket
|
|
8
|
+
|
|
9
|
+
# Valid values are 16 and 32, respectively for AES-128 and AES-256
|
|
10
|
+
_HUBBLE_AES_KEY_SIZE = 32
|
|
11
|
+
|
|
12
|
+
_HUBBLE_AES_NONCE_SIZE = 12
|
|
13
|
+
_HUBBLE_AES_TAG_SIZE = 4
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _generate_kdf_key(key: bytes, key_size: int, label: str, context: int) -> bytes:
|
|
17
|
+
label = label.encode()
|
|
18
|
+
context = str(context).encode()
|
|
19
|
+
|
|
20
|
+
return SP800_108_Counter(
|
|
21
|
+
key,
|
|
22
|
+
key_size,
|
|
23
|
+
lambda session_key, data: CMAC.new(session_key, data, AES).digest(),
|
|
24
|
+
label=label,
|
|
25
|
+
context=context,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _get_nonce(key: bytes, time_counter: int, counter: int) -> bytes:
|
|
30
|
+
nonce_key = _generate_kdf_key(key, _HUBBLE_AES_KEY_SIZE, "NonceKey", time_counter)
|
|
31
|
+
|
|
32
|
+
return _generate_kdf_key(nonce_key, _HUBBLE_AES_NONCE_SIZE, "Nonce", counter)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_encryption_key(key: bytes, time_counter: int, counter: int) -> bytes:
|
|
36
|
+
encryption_key = _generate_kdf_key(
|
|
37
|
+
key, _HUBBLE_AES_KEY_SIZE, "EncryptionKey", time_counter
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
return _generate_kdf_key(encryption_key, _HUBBLE_AES_KEY_SIZE, "Key", counter)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_auth_tag(key: bytes, ciphertext: bytes) -> bytes:
|
|
44
|
+
computed_cmac = CMAC.new(key, ciphertext, AES).digest()
|
|
45
|
+
|
|
46
|
+
return computed_cmac[:_HUBBLE_AES_TAG_SIZE]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _aes_decrypt(key: bytes, session_nonce: bytes, ciphertext: bytes) -> bytes:
|
|
50
|
+
cipher = AES.new(key, AES.MODE_CTR, nonce=session_nonce)
|
|
51
|
+
|
|
52
|
+
return cipher.decrypt(ciphertext)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def decrypt(
|
|
56
|
+
key: bytes, encrypted_pkt: EncryptedPacket, days: int = 2
|
|
57
|
+
) -> Optional[DecryptedPacket]:
|
|
58
|
+
ble_adv = encrypted_pkt.payload
|
|
59
|
+
seq_no = int.from_bytes(ble_adv[0:2], "big") & 0x3FF
|
|
60
|
+
device_id = ble_adv[2:6].hex()
|
|
61
|
+
auth_tag = ble_adv[6:10]
|
|
62
|
+
encrypted_payload = ble_adv[10:]
|
|
63
|
+
day_offset = 0
|
|
64
|
+
|
|
65
|
+
time_counter = int(datetime.now(timezone.utc).timestamp()) // 86400
|
|
66
|
+
|
|
67
|
+
for t in range(-days, days + 1):
|
|
68
|
+
daily_key = _get_encryption_key(key, time_counter + t, seq_no)
|
|
69
|
+
tag = _get_auth_tag(daily_key, encrypted_payload)
|
|
70
|
+
|
|
71
|
+
if tag == auth_tag:
|
|
72
|
+
day_offset = t
|
|
73
|
+
nonce = _get_nonce(key, time_counter, seq_no)
|
|
74
|
+
decrypted_payload = _aes_decrypt(daily_key, nonce, encrypted_payload)
|
|
75
|
+
return DecryptedPacket(
|
|
76
|
+
timestamp=encrypted_pkt.timestamp,
|
|
77
|
+
device_id="",
|
|
78
|
+
device_name="",
|
|
79
|
+
location=encrypted_pkt.location,
|
|
80
|
+
tags=[],
|
|
81
|
+
payload=decrypted_payload,
|
|
82
|
+
rssi=encrypted_pkt.rssi,
|
|
83
|
+
counter=None,
|
|
84
|
+
sequence=None,
|
|
85
|
+
)
|
|
86
|
+
return None
|