pyhubblenetwork 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/.gitignore +4 -1
- {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/PKG-INFO +14 -2
- {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/README.md +13 -1
- {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/pyproject.toml +2 -1
- {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/src/hubblenetwork/cli.py +124 -59
- {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/src/hubblenetwork/crypto.py +17 -7
- {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/src/hubblenetwork/device.py +12 -0
- pyhubblenetwork-0.3.0/tests/test_cli_payload.py +249 -0
- pyhubblenetwork-0.3.0/tests/test_counter_eid.py +214 -0
- {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/LICENSE +0 -0
- {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/plugins/pyhubblenetwork/skills/hubble-ready-test/README.md +0 -0
- {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/src/hubblenetwork/__init__.py +0 -0
- {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/src/hubblenetwork/ble.py +0 -0
- {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/src/hubblenetwork/cloud.py +0 -0
- {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/src/hubblenetwork/errors.py +0 -0
- {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/src/hubblenetwork/org.py +0 -0
- {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/src/hubblenetwork/packets.py +0 -0
- {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/src/hubblenetwork/ready.py +0 -0
- {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/tests/__init__.py +0 -0
- {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/tests/conftest.py +0 -0
- {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/tests/test_cloud_integration.py +0 -0
|
@@ -157,4 +157,7 @@ cython_debug/
|
|
|
157
157
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
158
158
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
159
159
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
160
|
-
#.idea/
|
|
160
|
+
#.idea/
|
|
161
|
+
|
|
162
|
+
# Git worktrees
|
|
163
|
+
.worktrees/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyhubblenetwork
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Hubble SDK host-side tools
|
|
5
5
|
Author-email: Paul Buckley <paul@hubble.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -139,8 +139,20 @@ If installed, the `hubblenetwork` command is available:
|
|
|
139
139
|
```bash
|
|
140
140
|
hubblenetwork --help
|
|
141
141
|
hubblenetwork ble scan
|
|
142
|
+
hubblenetwork ble scan --payload-format hex
|
|
143
|
+
hubblenetwork org get-packets --payload-format string
|
|
142
144
|
```
|
|
143
145
|
|
|
146
|
+
### Payload format option
|
|
147
|
+
|
|
148
|
+
Commands that output packet data (`ble scan`, `ble detect`, `org get-packets`) support the `--payload-format` flag to control how payloads are displayed:
|
|
149
|
+
|
|
150
|
+
* `base64` (default) — encode payloads as base64
|
|
151
|
+
* `hex` — display payloads as hexadecimal
|
|
152
|
+
* `string` — decode payloads as UTF-8 text (falls back to `<invalid UTF-8>` if bytes are not valid UTF-8)
|
|
153
|
+
|
|
154
|
+
This applies to all output formats (tabular, json, csv).
|
|
155
|
+
|
|
144
156
|
## Configuration
|
|
145
157
|
|
|
146
158
|
Some functions read defaults from environment variables if not provided explicitly. Suggested variables:
|
|
@@ -207,5 +219,5 @@ ruff check src
|
|
|
207
219
|
## Releases & versioning
|
|
208
220
|
|
|
209
221
|
* Follows **SemVer** (MAJOR.MINOR.PATCH).
|
|
210
|
-
* Tagged releases (e.g., `v0.
|
|
222
|
+
* Tagged releases (e.g., `v0.3.0`) publish wheels/sdists to PyPI.
|
|
211
223
|
* Release process: (add short steps for how to cut a release—tagging, CI release job, PyPI publish credentials).
|
|
@@ -116,8 +116,20 @@ If installed, the `hubblenetwork` command is available:
|
|
|
116
116
|
```bash
|
|
117
117
|
hubblenetwork --help
|
|
118
118
|
hubblenetwork ble scan
|
|
119
|
+
hubblenetwork ble scan --payload-format hex
|
|
120
|
+
hubblenetwork org get-packets --payload-format string
|
|
119
121
|
```
|
|
120
122
|
|
|
123
|
+
### Payload format option
|
|
124
|
+
|
|
125
|
+
Commands that output packet data (`ble scan`, `ble detect`, `org get-packets`) support the `--payload-format` flag to control how payloads are displayed:
|
|
126
|
+
|
|
127
|
+
* `base64` (default) — encode payloads as base64
|
|
128
|
+
* `hex` — display payloads as hexadecimal
|
|
129
|
+
* `string` — decode payloads as UTF-8 text (falls back to `<invalid UTF-8>` if bytes are not valid UTF-8)
|
|
130
|
+
|
|
131
|
+
This applies to all output formats (tabular, json, csv).
|
|
132
|
+
|
|
121
133
|
## Configuration
|
|
122
134
|
|
|
123
135
|
Some functions read defaults from environment variables if not provided explicitly. Suggested variables:
|
|
@@ -184,5 +196,5 @@ ruff check src
|
|
|
184
196
|
## Releases & versioning
|
|
185
197
|
|
|
186
198
|
* Follows **SemVer** (MAJOR.MINOR.PATCH).
|
|
187
|
-
* Tagged releases (e.g., `v0.
|
|
199
|
+
* Tagged releases (e.g., `v0.3.0`) publish wheels/sdists to PyPI.
|
|
188
200
|
* Release process: (add short steps for how to cut a release—tagging, CI release job, PyPI publish credentials).
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pyhubblenetwork"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
requires-python = ">=3.9"
|
|
9
9
|
authors = [
|
|
10
10
|
{ name="Paul Buckley", email="paul@hubble.com" },
|
|
@@ -45,6 +45,7 @@ include = [
|
|
|
45
45
|
# ---- Testing & dev tooling ----
|
|
46
46
|
[tool.pytest.ini_options]
|
|
47
47
|
testpaths = ["tests"]
|
|
48
|
+
pythonpath = ["src"]
|
|
48
49
|
markers = [
|
|
49
50
|
"ble: tests that require BLE hardware/permissions",
|
|
50
51
|
"integration: slow or environment-dependent tests",
|
|
@@ -13,7 +13,7 @@ from datetime import datetime
|
|
|
13
13
|
from typing import Optional, List
|
|
14
14
|
from tabulate import tabulate
|
|
15
15
|
from hubblenetwork import Organization
|
|
16
|
-
from hubblenetwork import Device, DecryptedPacket
|
|
16
|
+
from hubblenetwork import Device, DecryptedPacket
|
|
17
17
|
from hubblenetwork import ble as ble_mod
|
|
18
18
|
from hubblenetwork import ready as ready_mod
|
|
19
19
|
from hubblenetwork import decrypt
|
|
@@ -28,6 +28,22 @@ _handler.setFormatter(logging.Formatter("%(levelname)s - %(message)s"))
|
|
|
28
28
|
logger.addHandler(_handler)
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
def _format_payload(payload, fmt: str) -> str:
|
|
32
|
+
"""Format packet payload bytes for display."""
|
|
33
|
+
if not isinstance(payload, bytes):
|
|
34
|
+
return str(payload)
|
|
35
|
+
if fmt == "hex":
|
|
36
|
+
return payload.hex().upper()
|
|
37
|
+
elif fmt == "string":
|
|
38
|
+
try:
|
|
39
|
+
return payload.decode("utf-8")
|
|
40
|
+
except UnicodeDecodeError:
|
|
41
|
+
click.echo("Warning: payload contains non-UTF-8 bytes", err=True)
|
|
42
|
+
return "<invalid UTF-8>"
|
|
43
|
+
else: # base64 (default)
|
|
44
|
+
return base64.b64encode(payload).decode("ascii")
|
|
45
|
+
|
|
46
|
+
|
|
31
47
|
def _get_env_or_fail(name: str) -> str:
|
|
32
48
|
val = os.getenv(name)
|
|
33
49
|
if not val:
|
|
@@ -47,7 +63,7 @@ def _get_org_and_token(org_id, token) -> tuple[str, str]:
|
|
|
47
63
|
return org_id, token
|
|
48
64
|
|
|
49
65
|
|
|
50
|
-
def _packet_to_dict(pkt) -> dict:
|
|
66
|
+
def _packet_to_dict(pkt, payload_format: str = "base64") -> dict:
|
|
51
67
|
"""Convert a packet to a dictionary for JSON serialization."""
|
|
52
68
|
ts = datetime.fromtimestamp(pkt.timestamp).strftime("%c")
|
|
53
69
|
data = {
|
|
@@ -59,24 +75,8 @@ def _packet_to_dict(pkt) -> dict:
|
|
|
59
75
|
if isinstance(pkt, DecryptedPacket):
|
|
60
76
|
data["counter"] = pkt.counter
|
|
61
77
|
data["sequence"] = pkt.sequence
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
data["payload"] = (
|
|
65
|
-
pkt.payload.decode("utf-8")
|
|
66
|
-
if isinstance(pkt.payload, bytes)
|
|
67
|
-
else str(pkt.payload)
|
|
68
|
-
)
|
|
69
|
-
except UnicodeDecodeError:
|
|
70
|
-
data["payload_hex"] = (
|
|
71
|
-
pkt.payload.hex()
|
|
72
|
-
if isinstance(pkt.payload, bytes)
|
|
73
|
-
else str(pkt.payload)
|
|
74
|
-
)
|
|
75
|
-
else:
|
|
76
|
-
# EncryptedPacket - show payload as hex
|
|
77
|
-
data["payload_hex"] = (
|
|
78
|
-
pkt.payload.hex() if isinstance(pkt.payload, bytes) else str(pkt.payload)
|
|
79
|
-
)
|
|
78
|
+
|
|
79
|
+
data["payload"] = _format_payload(pkt.payload, payload_format)
|
|
80
80
|
|
|
81
81
|
if not pkt.location.fake:
|
|
82
82
|
data["location"] = {
|
|
@@ -208,11 +208,12 @@ class _StreamingTablePrinter(_StreamingPrinterBase):
|
|
|
208
208
|
"PAYLOAD": 20,
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
def __init__(self):
|
|
211
|
+
def __init__(self, payload_format: str = "base64"):
|
|
212
212
|
super().__init__()
|
|
213
213
|
self._header_printed = False
|
|
214
214
|
self._headers: List[str] = []
|
|
215
215
|
self._column_config: dict = {}
|
|
216
|
+
self._payload_format = payload_format
|
|
216
217
|
|
|
217
218
|
def _determine_columns(self, pkt) -> tuple[List[str], dict]:
|
|
218
219
|
"""Determine column headers and configuration based on packet type."""
|
|
@@ -271,7 +272,7 @@ class _StreamingTablePrinter(_StreamingPrinterBase):
|
|
|
271
272
|
row.append(f"{loc.lat:.6f},{loc.lon:.6f}")
|
|
272
273
|
|
|
273
274
|
if self._column_config["is_decrypted"]:
|
|
274
|
-
row.append(
|
|
275
|
+
row.append(_format_payload(pkt.payload, self._payload_format))
|
|
275
276
|
|
|
276
277
|
# Print the data row
|
|
277
278
|
click.echo(self._format_row(row))
|
|
@@ -282,9 +283,10 @@ class _StreamingTablePrinter(_StreamingPrinterBase):
|
|
|
282
283
|
class _StreamingJsonPrinter(_StreamingPrinterBase):
|
|
283
284
|
"""Print packets as a streaming JSON array."""
|
|
284
285
|
|
|
285
|
-
def __init__(self):
|
|
286
|
+
def __init__(self, payload_format: str = "base64"):
|
|
286
287
|
super().__init__()
|
|
287
288
|
self._array_started = False
|
|
289
|
+
self._payload_format = payload_format
|
|
288
290
|
|
|
289
291
|
@property
|
|
290
292
|
def suppress_info_messages(self) -> bool:
|
|
@@ -292,7 +294,7 @@ class _StreamingJsonPrinter(_StreamingPrinterBase):
|
|
|
292
294
|
|
|
293
295
|
def print_row(self, pkt) -> None:
|
|
294
296
|
"""Print a single packet as JSON."""
|
|
295
|
-
pkt_dict = _packet_to_dict(pkt)
|
|
297
|
+
pkt_dict = _packet_to_dict(pkt, self._payload_format)
|
|
296
298
|
if not self._array_started:
|
|
297
299
|
click.echo("[")
|
|
298
300
|
self._array_started = True
|
|
@@ -321,13 +323,12 @@ _STREAMING_PRINTERS = {
|
|
|
321
323
|
}
|
|
322
324
|
|
|
323
325
|
|
|
324
|
-
def _print_packets_tabular(pkts: List) -> None:
|
|
326
|
+
def _print_packets_tabular(pkts: List, payload_format: str = "base64") -> None:
|
|
325
327
|
"""Print packets in a formatted table using tabulate."""
|
|
326
328
|
if not pkts:
|
|
327
329
|
click.echo("No packets!")
|
|
328
330
|
return
|
|
329
331
|
|
|
330
|
-
# For batch printing, use the full table format
|
|
331
332
|
first_pkt = pkts[0]
|
|
332
333
|
is_decrypted = isinstance(first_pkt, DecryptedPacket)
|
|
333
334
|
has_real_location = not first_pkt.location.fake
|
|
@@ -353,50 +354,37 @@ def _print_packets_tabular(pkts: List) -> None:
|
|
|
353
354
|
row.append(f"{loc.lat:.6f},{loc.lon:.6f}")
|
|
354
355
|
|
|
355
356
|
if is_decrypted:
|
|
356
|
-
row.append(
|
|
357
|
+
row.append(_format_payload(pkt.payload, payload_format))
|
|
357
358
|
|
|
358
359
|
rows.append(row)
|
|
359
360
|
|
|
360
361
|
click.echo("\n" + tabulate(rows, headers=headers, tablefmt="grid"))
|
|
361
362
|
|
|
362
363
|
|
|
363
|
-
def _print_packets_csv(pkts) -> None:
|
|
364
|
+
def _print_packets_csv(pkts, payload_format: str = "base64") -> None:
|
|
364
365
|
click.echo("timestamp, datetime, latitude, longitude, payload")
|
|
365
366
|
for pkt in pkts:
|
|
366
367
|
ts = datetime.fromtimestamp(pkt.timestamp).strftime("%c")
|
|
367
|
-
|
|
368
|
-
payload = pkt.payload
|
|
369
|
-
elif isinstance(pkt, EncryptedPacket):
|
|
370
|
-
payload = pkt.payload.hex()
|
|
368
|
+
payload_str = _format_payload(pkt.payload, payload_format)
|
|
371
369
|
click.echo(
|
|
372
|
-
f'{pkt.timestamp}, {ts}, {pkt.location.lat:.6f}, {pkt.location.lon:.6f}, "{
|
|
370
|
+
f'{pkt.timestamp}, {ts}, {pkt.location.lat:.6f}, {pkt.location.lon:.6f}, "{payload_str}"'
|
|
373
371
|
)
|
|
374
372
|
|
|
375
373
|
|
|
376
|
-
def _print_packets_json(pkts) -> None:
|
|
374
|
+
def _print_packets_json(pkts, payload_format: str = "base64") -> None:
|
|
377
375
|
"""Print packets as a JSON array."""
|
|
378
|
-
json_packets = [_packet_to_dict(pkt) for pkt in pkts]
|
|
376
|
+
json_packets = [_packet_to_dict(pkt, payload_format) for pkt in pkts]
|
|
379
377
|
click.echo(json.dumps(json_packets, indent=2))
|
|
380
378
|
|
|
381
379
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
"
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
def _print_packets(pkts, output: str = "tabular") -> None:
|
|
390
|
-
if not output:
|
|
391
|
-
_print_packets_tabular(pkts)
|
|
392
|
-
return
|
|
393
|
-
|
|
394
|
-
format_key = output.lower().strip()
|
|
395
|
-
if format_key in _OUTPUT_FORMATS:
|
|
396
|
-
func = globals()[_OUTPUT_FORMATS[format_key]]
|
|
397
|
-
func(pkts)
|
|
380
|
+
def _print_packets(pkts, output: str = "tabular", payload_format: str = "base64") -> None:
|
|
381
|
+
format_key = (output or "tabular").lower().strip()
|
|
382
|
+
if format_key == "json":
|
|
383
|
+
_print_packets_json(pkts, payload_format)
|
|
384
|
+
elif format_key == "csv":
|
|
385
|
+
_print_packets_csv(pkts, payload_format)
|
|
398
386
|
else:
|
|
399
|
-
_print_packets_tabular(pkts)
|
|
387
|
+
_print_packets_tabular(pkts, payload_format)
|
|
400
388
|
|
|
401
389
|
|
|
402
390
|
def _print_device(dev: Device) -> None:
|
|
@@ -483,6 +471,20 @@ def ble() -> None:
|
|
|
483
471
|
show_default=False,
|
|
484
472
|
help="Key to decrypt packets (base64 encoded, required)",
|
|
485
473
|
)
|
|
474
|
+
@click.option(
|
|
475
|
+
"--days",
|
|
476
|
+
"-d",
|
|
477
|
+
type=int,
|
|
478
|
+
default=2,
|
|
479
|
+
show_default=True,
|
|
480
|
+
help="Number of days to check back when decrypting",
|
|
481
|
+
)
|
|
482
|
+
@click.option(
|
|
483
|
+
"--eid-pool-size",
|
|
484
|
+
type=int,
|
|
485
|
+
default=None,
|
|
486
|
+
help="Use counter-based EID with given pool size (0..N-1) instead of UTC-based",
|
|
487
|
+
)
|
|
486
488
|
@click.option(
|
|
487
489
|
"--format",
|
|
488
490
|
"-o",
|
|
@@ -492,16 +494,29 @@ def ble() -> None:
|
|
|
492
494
|
show_default=True,
|
|
493
495
|
help="Output format",
|
|
494
496
|
)
|
|
497
|
+
@click.option(
|
|
498
|
+
"--payload-format",
|
|
499
|
+
"payload_format",
|
|
500
|
+
type=click.Choice(["base64", "hex", "string"], case_sensitive=False),
|
|
501
|
+
default="base64",
|
|
502
|
+
show_default=True,
|
|
503
|
+
help="Encoding format for packet payload",
|
|
504
|
+
)
|
|
495
505
|
@click.option(
|
|
496
506
|
"--debug",
|
|
497
507
|
is_flag=True,
|
|
498
508
|
default=False,
|
|
499
509
|
help="Enable debug logging to stderr",
|
|
500
510
|
)
|
|
511
|
+
@click.pass_context
|
|
501
512
|
def ble_detect(
|
|
513
|
+
ctx,
|
|
502
514
|
timeout: Optional[int] = None,
|
|
503
515
|
key: str = None,
|
|
516
|
+
days: int = 2,
|
|
517
|
+
eid_pool_size: Optional[int] = None,
|
|
504
518
|
output_format: str = "tabular",
|
|
519
|
+
payload_format: str = "base64",
|
|
505
520
|
debug: bool = False,
|
|
506
521
|
) -> None:
|
|
507
522
|
"""
|
|
@@ -516,6 +531,14 @@ def ble_detect(
|
|
|
516
531
|
"""
|
|
517
532
|
use_json = output_format.lower() == "json"
|
|
518
533
|
|
|
534
|
+
# Validate --eid-pool-size and --days mutual exclusivity
|
|
535
|
+
if eid_pool_size is not None:
|
|
536
|
+
days_source = ctx.get_parameter_source("days")
|
|
537
|
+
if days_source == click.core.ParameterSource.COMMANDLINE:
|
|
538
|
+
raise click.UsageError(
|
|
539
|
+
"--eid-pool-size and --days are mutually exclusive"
|
|
540
|
+
)
|
|
541
|
+
|
|
519
542
|
# Set log level based on debug flag
|
|
520
543
|
logger.setLevel(logging.DEBUG if debug else logging.WARNING)
|
|
521
544
|
|
|
@@ -565,7 +588,9 @@ def ble_detect(
|
|
|
565
588
|
logger.debug("Packet received, attempting decryption...")
|
|
566
589
|
|
|
567
590
|
# Attempt to decrypt the packet
|
|
568
|
-
decrypted_pkt = decrypt(
|
|
591
|
+
decrypted_pkt = decrypt(
|
|
592
|
+
decoded_key, pkt, days=days, eid_pool_size=eid_pool_size
|
|
593
|
+
)
|
|
569
594
|
|
|
570
595
|
if decrypted_pkt:
|
|
571
596
|
# If we can decrypt it, output success
|
|
@@ -574,20 +599,22 @@ def ble_detect(
|
|
|
574
599
|
)
|
|
575
600
|
logger.info("Packet decrypted successfully!")
|
|
576
601
|
|
|
602
|
+
payload_str = _format_payload(decrypted_pkt.payload, payload_format)
|
|
577
603
|
if use_json:
|
|
578
604
|
result = {
|
|
579
605
|
"success": True,
|
|
580
606
|
"packet": {
|
|
581
607
|
"datetime": datetime_str,
|
|
582
608
|
"rssi": decrypted_pkt.rssi,
|
|
583
|
-
"
|
|
609
|
+
"payload": payload_str,
|
|
610
|
+
"counter": decrypted_pkt.counter,
|
|
584
611
|
},
|
|
585
612
|
}
|
|
586
613
|
click.echo(json.dumps(result))
|
|
587
614
|
else:
|
|
588
615
|
click.secho("[SUCCESS] ", fg="green", nl=False)
|
|
589
616
|
click.echo(
|
|
590
|
-
f"Packet decrypted: {datetime_str}, RSSI: {decrypted_pkt.rssi} dBm, {
|
|
617
|
+
f"Packet decrypted: {datetime_str}, RSSI: {decrypted_pkt.rssi} dBm, payload: {payload_str}, counter: {decrypted_pkt.counter}"
|
|
591
618
|
)
|
|
592
619
|
return
|
|
593
620
|
|
|
@@ -631,6 +658,12 @@ def ble_detect(
|
|
|
631
658
|
show_default=True,
|
|
632
659
|
help="Number of days to check back when decrypting",
|
|
633
660
|
)
|
|
661
|
+
@click.option(
|
|
662
|
+
"--eid-pool-size",
|
|
663
|
+
type=int,
|
|
664
|
+
default=None,
|
|
665
|
+
help="Use counter-based EID with given pool size (0..N-1) instead of UTC-based",
|
|
666
|
+
)
|
|
634
667
|
@click.option("--ingest", is_flag=True, help="Ingest packets to backend (requires key)")
|
|
635
668
|
@click.option(
|
|
636
669
|
"--format",
|
|
@@ -641,13 +674,25 @@ def ble_detect(
|
|
|
641
674
|
show_default=True,
|
|
642
675
|
help="Output format for packets",
|
|
643
676
|
)
|
|
677
|
+
@click.option(
|
|
678
|
+
"--payload-format",
|
|
679
|
+
"payload_format",
|
|
680
|
+
type=click.Choice(["base64", "hex", "string"], case_sensitive=False),
|
|
681
|
+
default="base64",
|
|
682
|
+
show_default=True,
|
|
683
|
+
help="Encoding format for packet payload",
|
|
684
|
+
)
|
|
685
|
+
@click.pass_context
|
|
644
686
|
def ble_scan(
|
|
687
|
+
ctx,
|
|
645
688
|
timeout: Optional[int] = None,
|
|
646
689
|
count: Optional[int] = None,
|
|
647
690
|
ingest: bool = False,
|
|
648
691
|
key: Optional[str] = None,
|
|
649
692
|
days: int = 2,
|
|
693
|
+
eid_pool_size: Optional[int] = None,
|
|
650
694
|
output_format: str = "tabular",
|
|
695
|
+
payload_format: str = "base64",
|
|
651
696
|
) -> None:
|
|
652
697
|
"""
|
|
653
698
|
Scan for UUID 0xFCA6 and print packets as they are found.
|
|
@@ -658,11 +703,21 @@ def ble_scan(
|
|
|
658
703
|
hubblenetwork ble scan -o json --timeout 10
|
|
659
704
|
hubblenetwork ble scan -n 5 # Stop after 5 packets
|
|
660
705
|
"""
|
|
706
|
+
# Validate --eid-pool-size constraints
|
|
707
|
+
if eid_pool_size is not None:
|
|
708
|
+
if not key:
|
|
709
|
+
raise click.UsageError("--eid-pool-size requires --key")
|
|
710
|
+
days_source = ctx.get_parameter_source("days")
|
|
711
|
+
if days_source == click.core.ParameterSource.COMMANDLINE:
|
|
712
|
+
raise click.UsageError(
|
|
713
|
+
"--eid-pool-size and --days are mutually exclusive"
|
|
714
|
+
)
|
|
715
|
+
|
|
661
716
|
# Get the appropriate streaming printer
|
|
662
717
|
printer_class = _STREAMING_PRINTERS.get(
|
|
663
718
|
output_format.lower(), _StreamingTablePrinter
|
|
664
719
|
)
|
|
665
|
-
printer = printer_class()
|
|
720
|
+
printer = printer_class(payload_format=payload_format)
|
|
666
721
|
|
|
667
722
|
if not printer.suppress_info_messages:
|
|
668
723
|
click.secho("[INFO] Scanning for Hubble devices... (Press Ctrl+C to stop)")
|
|
@@ -703,7 +758,9 @@ def ble_scan(
|
|
|
703
758
|
|
|
704
759
|
# If we have a key, attempt to decrypt
|
|
705
760
|
if decoded_key:
|
|
706
|
-
decrypted_pkt = decrypt(
|
|
761
|
+
decrypted_pkt = decrypt(
|
|
762
|
+
decoded_key, pkt, days=days, eid_pool_size=eid_pool_size
|
|
763
|
+
)
|
|
707
764
|
if decrypted_pkt:
|
|
708
765
|
printer.print_row(decrypted_pkt)
|
|
709
766
|
# We only allow ingestion of packets you know the key of
|
|
@@ -2227,9 +2284,17 @@ def set_device_name(org: Organization, device_id: str, name: str) -> None:
|
|
|
2227
2284
|
show_default=True,
|
|
2228
2285
|
help="Number of days to query back (from now)",
|
|
2229
2286
|
)
|
|
2287
|
+
@click.option(
|
|
2288
|
+
"--payload-format",
|
|
2289
|
+
"payload_format",
|
|
2290
|
+
type=click.Choice(["base64", "hex", "string"], case_sensitive=False),
|
|
2291
|
+
default="base64",
|
|
2292
|
+
show_default=True,
|
|
2293
|
+
help="Encoding format for packet payload",
|
|
2294
|
+
)
|
|
2230
2295
|
@pass_orgcfg
|
|
2231
2296
|
def get_packets(
|
|
2232
|
-
org: Organization, device_id: str, output_format: str = "tabular", days: int = 7
|
|
2297
|
+
org: Organization, device_id: str, output_format: str = "tabular", days: int = 7, payload_format: str = "base64"
|
|
2233
2298
|
) -> None:
|
|
2234
2299
|
"""
|
|
2235
2300
|
Retrieve and display packets for a device.
|
|
@@ -2241,7 +2306,7 @@ def get_packets(
|
|
|
2241
2306
|
"""
|
|
2242
2307
|
device = Device(id=device_id)
|
|
2243
2308
|
packets = org.retrieve_packets(device, days=days)
|
|
2244
|
-
_print_packets(packets, output_format)
|
|
2309
|
+
_print_packets(packets, output_format, payload_format)
|
|
2245
2310
|
|
|
2246
2311
|
|
|
2247
2312
|
def main(argv: Optional[list[str]] = None) -> int:
|
|
@@ -75,19 +75,29 @@ def _check_tag_matches(
|
|
|
75
75
|
|
|
76
76
|
|
|
77
77
|
def decrypt(
|
|
78
|
-
key: bytes,
|
|
78
|
+
key: bytes,
|
|
79
|
+
encrypted_pkt: EncryptedPacket,
|
|
80
|
+
days: int = 2,
|
|
81
|
+
eid_pool_size: Optional[int] = None,
|
|
79
82
|
) -> Optional[DecryptedPacket]:
|
|
83
|
+
if eid_pool_size is not None and days != 2:
|
|
84
|
+
raise ValueError("Cannot specify both eid_pool_size and days")
|
|
85
|
+
|
|
80
86
|
parsed = ParsedPacket(encrypted_pkt)
|
|
81
87
|
keylen = len(key)
|
|
82
88
|
|
|
83
|
-
|
|
89
|
+
if eid_pool_size is not None:
|
|
90
|
+
candidates = range(eid_pool_size)
|
|
91
|
+
else:
|
|
92
|
+
time_counter = int(datetime.now(timezone.utc).timestamp()) // 86400
|
|
93
|
+
candidates = (time_counter + t for t in range(-days, days + 1))
|
|
84
94
|
|
|
85
|
-
for
|
|
86
|
-
if _check_tag_matches(key,
|
|
95
|
+
for candidate in candidates:
|
|
96
|
+
if _check_tag_matches(key, candidate, parsed):
|
|
87
97
|
daily_key = _get_encryption_key(
|
|
88
|
-
key,
|
|
98
|
+
key, candidate, parsed.seq_no, keylen=keylen
|
|
89
99
|
)
|
|
90
|
-
nonce = _get_nonce(key,
|
|
100
|
+
nonce = _get_nonce(key, candidate, parsed.seq_no, keylen=keylen)
|
|
91
101
|
decrypted_payload = _aes_decrypt(daily_key, nonce, parsed.encrypted_payload)
|
|
92
102
|
return DecryptedPacket(
|
|
93
103
|
timestamp=encrypted_pkt.timestamp,
|
|
@@ -97,7 +107,7 @@ def decrypt(
|
|
|
97
107
|
tags={},
|
|
98
108
|
payload=decrypted_payload,
|
|
99
109
|
rssi=encrypted_pkt.rssi,
|
|
100
|
-
counter=
|
|
110
|
+
counter=candidate,
|
|
101
111
|
sequence=parsed.seq_no,
|
|
102
112
|
)
|
|
103
113
|
return None
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# hubble/device.py
|
|
2
2
|
from __future__ import annotations
|
|
3
|
+
import base64
|
|
3
4
|
from dataclasses import dataclass
|
|
4
5
|
from typing import Dict, Optional
|
|
5
6
|
|
|
@@ -18,6 +19,17 @@ class Device:
|
|
|
18
19
|
created_ts: Optional[int] = None
|
|
19
20
|
active: Optional[bool] = False
|
|
20
21
|
|
|
22
|
+
def __str__(self) -> str:
|
|
23
|
+
key_str = (
|
|
24
|
+
base64.b64encode(self.key).decode("ascii")
|
|
25
|
+
if isinstance(self.key, bytes)
|
|
26
|
+
else self.key
|
|
27
|
+
)
|
|
28
|
+
return (
|
|
29
|
+
f"Device(id={self.id!r}, key={key_str!r}, name={self.name!r}, "
|
|
30
|
+
f"tags={self.tags!r}, created_ts={self.created_ts!r}, active={self.active!r})"
|
|
31
|
+
)
|
|
32
|
+
|
|
21
33
|
@classmethod
|
|
22
34
|
def from_json(cls, json):
|
|
23
35
|
return cls(
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import pytest
|
|
3
|
+
from unittest.mock import MagicMock
|
|
4
|
+
from hubblenetwork.packets import EncryptedPacket, DecryptedPacket
|
|
5
|
+
from hubblenetwork.packets import Location
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def fake_location():
|
|
9
|
+
loc = MagicMock(spec=Location)
|
|
10
|
+
loc.fake = True
|
|
11
|
+
loc.lat = 0.0
|
|
12
|
+
loc.lon = 0.0
|
|
13
|
+
return loc
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def make_encrypted_packet(payload: bytes) -> EncryptedPacket:
|
|
17
|
+
return EncryptedPacket(
|
|
18
|
+
timestamp=1700000000,
|
|
19
|
+
location=fake_location(),
|
|
20
|
+
payload=payload,
|
|
21
|
+
rssi=-70,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def make_decrypted_packet(payload: bytes) -> DecryptedPacket:
|
|
26
|
+
return DecryptedPacket(
|
|
27
|
+
timestamp=1700000000,
|
|
28
|
+
device_id="dev123",
|
|
29
|
+
device_name="Test Device",
|
|
30
|
+
location=fake_location(),
|
|
31
|
+
tags={},
|
|
32
|
+
payload=payload,
|
|
33
|
+
rssi=-70,
|
|
34
|
+
counter=42,
|
|
35
|
+
sequence=1,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestFormatPayload:
|
|
40
|
+
"""Tests for the _format_payload helper."""
|
|
41
|
+
|
|
42
|
+
def test_base64_encoding(self):
|
|
43
|
+
from hubblenetwork.cli import _format_payload
|
|
44
|
+
assert _format_payload(b'\x01\x02\x03', "base64") == "AQID"
|
|
45
|
+
|
|
46
|
+
def test_hex_encoding_uppercase(self):
|
|
47
|
+
from hubblenetwork.cli import _format_payload
|
|
48
|
+
assert _format_payload(b'\xab\xc6\x79', "hex") == "ABC679"
|
|
49
|
+
|
|
50
|
+
def test_hex_encoding_all_zeros(self):
|
|
51
|
+
from hubblenetwork.cli import _format_payload
|
|
52
|
+
assert _format_payload(b'\x00\x00', "hex") == "0000"
|
|
53
|
+
|
|
54
|
+
def test_string_encoding_valid_utf8(self):
|
|
55
|
+
from hubblenetwork.cli import _format_payload
|
|
56
|
+
assert _format_payload(b'hello world', "string") == "hello world"
|
|
57
|
+
|
|
58
|
+
def test_string_encoding_invalid_utf8_returns_fallback(self, capsys):
|
|
59
|
+
from hubblenetwork.cli import _format_payload
|
|
60
|
+
result = _format_payload(b'\xff\xfe', "string")
|
|
61
|
+
assert result == "<invalid UTF-8>"
|
|
62
|
+
|
|
63
|
+
def test_string_encoding_invalid_utf8_warns_to_stderr(self, capsys):
|
|
64
|
+
from hubblenetwork.cli import _format_payload
|
|
65
|
+
_format_payload(b'\xff\xfe', "string")
|
|
66
|
+
captured = capsys.readouterr()
|
|
67
|
+
assert "Warning" in captured.err
|
|
68
|
+
|
|
69
|
+
def test_empty_bytes_base64(self):
|
|
70
|
+
from hubblenetwork.cli import _format_payload
|
|
71
|
+
assert _format_payload(b'', "base64") == ""
|
|
72
|
+
|
|
73
|
+
def test_empty_bytes_hex(self):
|
|
74
|
+
from hubblenetwork.cli import _format_payload
|
|
75
|
+
assert _format_payload(b'', "hex") == ""
|
|
76
|
+
|
|
77
|
+
def test_non_bytes_passthrough(self):
|
|
78
|
+
from hubblenetwork.cli import _format_payload
|
|
79
|
+
assert _format_payload("already a string", "base64") == "already a string"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TestPacketToDict:
|
|
83
|
+
"""Tests for _packet_to_dict payload encoding."""
|
|
84
|
+
|
|
85
|
+
def test_encrypted_packet_payload_is_base64(self):
|
|
86
|
+
from hubblenetwork.cli import _packet_to_dict
|
|
87
|
+
raw = b'\x01\x02\x03\xff'
|
|
88
|
+
pkt = make_encrypted_packet(raw)
|
|
89
|
+
result = _packet_to_dict(pkt)
|
|
90
|
+
assert "payload" in result
|
|
91
|
+
assert result["payload"] == base64.b64encode(raw).decode("ascii")
|
|
92
|
+
assert "payload_hex" not in result
|
|
93
|
+
|
|
94
|
+
def test_decrypted_packet_payload_is_base64(self):
|
|
95
|
+
from hubblenetwork.cli import _packet_to_dict
|
|
96
|
+
raw = b'\xde\xad\xbe\xef'
|
|
97
|
+
pkt = make_decrypted_packet(raw)
|
|
98
|
+
result = _packet_to_dict(pkt)
|
|
99
|
+
assert "payload" in result
|
|
100
|
+
assert result["payload"] == base64.b64encode(raw).decode("ascii")
|
|
101
|
+
assert "payload_hex" not in result
|
|
102
|
+
|
|
103
|
+
def test_decrypted_packet_utf8_payload_still_base64(self):
|
|
104
|
+
"""Even valid UTF-8 should be base64 encoded."""
|
|
105
|
+
from hubblenetwork.cli import _packet_to_dict
|
|
106
|
+
raw = b'hello world'
|
|
107
|
+
pkt = make_decrypted_packet(raw)
|
|
108
|
+
result = _packet_to_dict(pkt)
|
|
109
|
+
assert result["payload"] == base64.b64encode(raw).decode("ascii")
|
|
110
|
+
|
|
111
|
+
def test_empty_payload_is_base64(self):
|
|
112
|
+
from hubblenetwork.cli import _packet_to_dict
|
|
113
|
+
pkt = make_encrypted_packet(b'')
|
|
114
|
+
result = _packet_to_dict(pkt)
|
|
115
|
+
assert result["payload"] == ""
|
|
116
|
+
|
|
117
|
+
def test_encrypted_packet_payload_hex(self):
|
|
118
|
+
from hubblenetwork.cli import _packet_to_dict
|
|
119
|
+
raw = b'\xab\xc6\x79'
|
|
120
|
+
pkt = make_encrypted_packet(raw)
|
|
121
|
+
result = _packet_to_dict(pkt, payload_format="hex")
|
|
122
|
+
assert result["payload"] == "ABC679"
|
|
123
|
+
|
|
124
|
+
def test_decrypted_packet_payload_string(self):
|
|
125
|
+
from hubblenetwork.cli import _packet_to_dict
|
|
126
|
+
raw = b'sensor:42'
|
|
127
|
+
pkt = make_decrypted_packet(raw)
|
|
128
|
+
result = _packet_to_dict(pkt, payload_format="string")
|
|
129
|
+
assert result["payload"] == "sensor:42"
|
|
130
|
+
|
|
131
|
+
def test_decrypted_packet_payload_string_invalid_utf8(self, capsys):
|
|
132
|
+
from hubblenetwork.cli import _packet_to_dict
|
|
133
|
+
raw = b'\xff\xfe'
|
|
134
|
+
pkt = make_decrypted_packet(raw)
|
|
135
|
+
result = _packet_to_dict(pkt, payload_format="string")
|
|
136
|
+
assert result["payload"] == "<invalid UTF-8>"
|
|
137
|
+
captured = capsys.readouterr()
|
|
138
|
+
assert "Warning" in captured.err
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class TestStreamingTablePrinter:
|
|
142
|
+
"""Tests for tabular payload display."""
|
|
143
|
+
|
|
144
|
+
def test_table_row_payload_is_base64(self, capsys):
|
|
145
|
+
from hubblenetwork.cli import _StreamingTablePrinter
|
|
146
|
+
raw = b'\x01\x02\x03'
|
|
147
|
+
pkt = make_decrypted_packet(raw)
|
|
148
|
+
printer = _StreamingTablePrinter()
|
|
149
|
+
printer.print_row(pkt)
|
|
150
|
+
captured = capsys.readouterr()
|
|
151
|
+
expected_b64 = base64.b64encode(raw).decode("ascii")
|
|
152
|
+
assert expected_b64 in captured.out
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class TestStreamingTablePrinterPayloadFormat:
|
|
156
|
+
"""Tests for _StreamingTablePrinter with non-default payload formats."""
|
|
157
|
+
|
|
158
|
+
def test_hex_format(self, capsys):
|
|
159
|
+
from hubblenetwork.cli import _StreamingTablePrinter
|
|
160
|
+
raw = b'\xab\xc6\x79'
|
|
161
|
+
pkt = make_decrypted_packet(raw)
|
|
162
|
+
printer = _StreamingTablePrinter(payload_format="hex")
|
|
163
|
+
printer.print_row(pkt)
|
|
164
|
+
captured = capsys.readouterr()
|
|
165
|
+
assert "ABC679" in captured.out
|
|
166
|
+
|
|
167
|
+
def test_string_format_valid_utf8(self, capsys):
|
|
168
|
+
from hubblenetwork.cli import _StreamingTablePrinter
|
|
169
|
+
raw = b'sensor:42'
|
|
170
|
+
pkt = make_decrypted_packet(raw)
|
|
171
|
+
printer = _StreamingTablePrinter(payload_format="string")
|
|
172
|
+
printer.print_row(pkt)
|
|
173
|
+
captured = capsys.readouterr()
|
|
174
|
+
assert "sensor:42" in captured.out
|
|
175
|
+
|
|
176
|
+
def test_default_is_still_base64(self, capsys):
|
|
177
|
+
from hubblenetwork.cli import _StreamingTablePrinter
|
|
178
|
+
raw = b'\x01\x02\x03'
|
|
179
|
+
pkt = make_decrypted_packet(raw)
|
|
180
|
+
printer = _StreamingTablePrinter()
|
|
181
|
+
printer.print_row(pkt)
|
|
182
|
+
captured = capsys.readouterr()
|
|
183
|
+
assert base64.b64encode(raw).decode("ascii") in captured.out
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class TestStreamingJsonPrinterPayloadFormat:
|
|
187
|
+
"""Tests for _StreamingJsonPrinter with non-default payload formats."""
|
|
188
|
+
|
|
189
|
+
def test_hex_format(self, capsys):
|
|
190
|
+
from hubblenetwork.cli import _StreamingJsonPrinter
|
|
191
|
+
raw = b'\xab\xc6\x79'
|
|
192
|
+
pkt = make_decrypted_packet(raw)
|
|
193
|
+
printer = _StreamingJsonPrinter(payload_format="hex")
|
|
194
|
+
printer.print_row(pkt)
|
|
195
|
+
captured = capsys.readouterr()
|
|
196
|
+
assert "ABC679" in captured.out
|
|
197
|
+
|
|
198
|
+
def test_default_is_base64(self, capsys):
|
|
199
|
+
from hubblenetwork.cli import _StreamingJsonPrinter
|
|
200
|
+
raw = b'\x01\x02\x03'
|
|
201
|
+
pkt = make_decrypted_packet(raw)
|
|
202
|
+
printer = _StreamingJsonPrinter()
|
|
203
|
+
printer.print_row(pkt)
|
|
204
|
+
captured = capsys.readouterr()
|
|
205
|
+
assert base64.b64encode(raw).decode("ascii") in captured.out
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class TestBatchPrinters:
|
|
209
|
+
"""Tests for _print_packets_* functions with payload_format."""
|
|
210
|
+
|
|
211
|
+
def test_tabular_hex(self, capsys):
|
|
212
|
+
from hubblenetwork.cli import _print_packets_tabular
|
|
213
|
+
raw = b'\xab\xc6\x79'
|
|
214
|
+
pkt = make_decrypted_packet(raw)
|
|
215
|
+
_print_packets_tabular([pkt], payload_format="hex")
|
|
216
|
+
captured = capsys.readouterr()
|
|
217
|
+
assert "ABC679" in captured.out
|
|
218
|
+
|
|
219
|
+
def test_tabular_default_base64(self, capsys):
|
|
220
|
+
from hubblenetwork.cli import _print_packets_tabular
|
|
221
|
+
raw = b'\x01\x02\x03'
|
|
222
|
+
pkt = make_decrypted_packet(raw)
|
|
223
|
+
_print_packets_tabular([pkt])
|
|
224
|
+
captured = capsys.readouterr()
|
|
225
|
+
assert base64.b64encode(raw).decode("ascii") in captured.out
|
|
226
|
+
|
|
227
|
+
def test_json_hex(self, capsys):
|
|
228
|
+
from hubblenetwork.cli import _print_packets_json
|
|
229
|
+
raw = b'\xab\xc6\x79'
|
|
230
|
+
pkt = make_decrypted_packet(raw)
|
|
231
|
+
_print_packets_json([pkt], payload_format="hex")
|
|
232
|
+
captured = capsys.readouterr()
|
|
233
|
+
assert "ABC679" in captured.out
|
|
234
|
+
|
|
235
|
+
def test_csv_hex(self, capsys):
|
|
236
|
+
from hubblenetwork.cli import _print_packets_csv
|
|
237
|
+
raw = b'\xab\xc6\x79'
|
|
238
|
+
pkt = make_decrypted_packet(raw)
|
|
239
|
+
_print_packets_csv([pkt], payload_format="hex")
|
|
240
|
+
captured = capsys.readouterr()
|
|
241
|
+
assert "ABC679" in captured.out
|
|
242
|
+
|
|
243
|
+
def test_csv_string(self, capsys):
|
|
244
|
+
from hubblenetwork.cli import _print_packets_csv
|
|
245
|
+
raw = b'temp:25'
|
|
246
|
+
pkt = make_decrypted_packet(raw)
|
|
247
|
+
_print_packets_csv([pkt], payload_format="string")
|
|
248
|
+
captured = capsys.readouterr()
|
|
249
|
+
assert "temp:25" in captured.out
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Tests for counter-based EID decryption (eid_pool_size parameter)."""
|
|
2
|
+
|
|
3
|
+
import struct
|
|
4
|
+
import pytest
|
|
5
|
+
from click.testing import CliRunner
|
|
6
|
+
from Crypto.Cipher import AES
|
|
7
|
+
|
|
8
|
+
from hubblenetwork.crypto import (
|
|
9
|
+
_get_encryption_key,
|
|
10
|
+
_get_nonce,
|
|
11
|
+
_get_auth_tag,
|
|
12
|
+
decrypt,
|
|
13
|
+
)
|
|
14
|
+
from hubblenetwork.packets import EncryptedPacket, Location
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Helpers – build a valid BLE advertisement payload for a given counter value
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
def _encrypt_payload(key: bytes, counter_value: int, seq_no: int, plaintext: bytes) -> bytes:
|
|
22
|
+
"""Encrypt plaintext the same way firmware does and return a BLE adv payload."""
|
|
23
|
+
keylen = len(key)
|
|
24
|
+
daily_key = _get_encryption_key(key, counter_value, seq_no, keylen=keylen)
|
|
25
|
+
nonce = _get_nonce(key, counter_value, seq_no, keylen=keylen)
|
|
26
|
+
|
|
27
|
+
cipher = AES.new(daily_key, AES.MODE_CTR, nonce=nonce)
|
|
28
|
+
ciphertext = cipher.encrypt(plaintext)
|
|
29
|
+
|
|
30
|
+
auth_tag = _get_auth_tag(daily_key, ciphertext)
|
|
31
|
+
|
|
32
|
+
# BLE adv layout: seq_no (2 big-endian) + 4 padding bytes + auth_tag (4) + ciphertext
|
|
33
|
+
seq_bytes = struct.pack(">H", seq_no & 0x3FF)
|
|
34
|
+
padding = b"\x00" * 4
|
|
35
|
+
return seq_bytes + padding + auth_tag + ciphertext
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _make_encrypted_packet(key: bytes, counter_value: int, seq_no: int, plaintext: bytes) -> EncryptedPacket:
|
|
39
|
+
"""Build an EncryptedPacket whose auth_tag matches *counter_value*."""
|
|
40
|
+
payload = _encrypt_payload(key, counter_value, seq_no, plaintext)
|
|
41
|
+
return EncryptedPacket(
|
|
42
|
+
timestamp=1700000000,
|
|
43
|
+
location=Location(lat=0.0, lon=0.0, fake=True),
|
|
44
|
+
payload=payload,
|
|
45
|
+
rssi=-70,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Fixtures
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
# Deterministic 32-byte key (AES-256-CTR)
|
|
54
|
+
_KEY_256 = bytes(range(32))
|
|
55
|
+
# Deterministic 16-byte key (AES-128-CTR)
|
|
56
|
+
_KEY_128 = bytes(range(16))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# decrypt() with eid_pool_size
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
class TestCounterEidDecrypt:
|
|
64
|
+
"""Test decrypt() counter-based EID mode."""
|
|
65
|
+
|
|
66
|
+
def test_decrypt_finds_counter_value(self):
|
|
67
|
+
"""decrypt() with eid_pool_size finds the correct counter."""
|
|
68
|
+
counter_value = 7
|
|
69
|
+
pkt = _make_encrypted_packet(_KEY_256, counter_value, seq_no=1, plaintext=b"hello")
|
|
70
|
+
|
|
71
|
+
result = decrypt(_KEY_256, pkt, eid_pool_size=32)
|
|
72
|
+
|
|
73
|
+
assert result is not None
|
|
74
|
+
assert result.payload == b"hello"
|
|
75
|
+
assert result.counter == counter_value
|
|
76
|
+
|
|
77
|
+
def test_decrypt_counter_zero(self):
|
|
78
|
+
"""Counter value 0 is found."""
|
|
79
|
+
pkt = _make_encrypted_packet(_KEY_256, 0, seq_no=5, plaintext=b"zero")
|
|
80
|
+
|
|
81
|
+
result = decrypt(_KEY_256, pkt, eid_pool_size=10)
|
|
82
|
+
|
|
83
|
+
assert result is not None
|
|
84
|
+
assert result.counter == 0
|
|
85
|
+
assert result.payload == b"zero"
|
|
86
|
+
|
|
87
|
+
def test_decrypt_counter_at_pool_boundary(self):
|
|
88
|
+
"""Counter value N-1 (last in pool) is found."""
|
|
89
|
+
pool_size = 16
|
|
90
|
+
pkt = _make_encrypted_packet(_KEY_128, pool_size - 1, seq_no=2, plaintext=b"edge")
|
|
91
|
+
|
|
92
|
+
result = decrypt(_KEY_128, pkt, eid_pool_size=pool_size)
|
|
93
|
+
|
|
94
|
+
assert result is not None
|
|
95
|
+
assert result.counter == pool_size - 1
|
|
96
|
+
assert result.payload == b"edge"
|
|
97
|
+
|
|
98
|
+
def test_decrypt_counter_outside_pool_returns_none(self):
|
|
99
|
+
"""Counter value >= pool_size is not found."""
|
|
100
|
+
pkt = _make_encrypted_packet(_KEY_256, 50, seq_no=3, plaintext=b"miss")
|
|
101
|
+
|
|
102
|
+
result = decrypt(_KEY_256, pkt, eid_pool_size=32)
|
|
103
|
+
|
|
104
|
+
assert result is None
|
|
105
|
+
|
|
106
|
+
def test_decrypt_wrong_key_returns_none(self):
|
|
107
|
+
"""Wrong key doesn't match any counter."""
|
|
108
|
+
pkt = _make_encrypted_packet(_KEY_256, 5, seq_no=1, plaintext=b"data")
|
|
109
|
+
wrong_key = bytes(range(1, 33))
|
|
110
|
+
|
|
111
|
+
result = decrypt(wrong_key, pkt, eid_pool_size=32)
|
|
112
|
+
|
|
113
|
+
assert result is None
|
|
114
|
+
|
|
115
|
+
def test_decrypt_aes128_counter_mode(self):
|
|
116
|
+
"""Counter-based decryption works with AES-128 keys."""
|
|
117
|
+
pkt = _make_encrypted_packet(_KEY_128, 3, seq_no=10, plaintext=b"aes128")
|
|
118
|
+
|
|
119
|
+
result = decrypt(_KEY_128, pkt, eid_pool_size=8)
|
|
120
|
+
|
|
121
|
+
assert result is not None
|
|
122
|
+
assert result.payload == b"aes128"
|
|
123
|
+
assert result.counter == 3
|
|
124
|
+
|
|
125
|
+
def test_decrypt_preserves_packet_metadata(self):
|
|
126
|
+
"""Decrypted packet retains timestamp, rssi, location, sequence."""
|
|
127
|
+
pkt = _make_encrypted_packet(_KEY_256, 2, seq_no=42, plaintext=b"meta")
|
|
128
|
+
|
|
129
|
+
result = decrypt(_KEY_256, pkt, eid_pool_size=10)
|
|
130
|
+
|
|
131
|
+
assert result is not None
|
|
132
|
+
assert result.timestamp == 1700000000
|
|
133
|
+
assert result.rssi == -70
|
|
134
|
+
assert result.sequence == 42
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# Validation: eid_pool_size + days mutual exclusivity
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
class TestCounterEidValidation:
|
|
142
|
+
"""Test that eid_pool_size and days are mutually exclusive."""
|
|
143
|
+
|
|
144
|
+
def test_raises_when_both_eid_pool_size_and_days_set(self):
|
|
145
|
+
pkt = _make_encrypted_packet(_KEY_256, 0, seq_no=1, plaintext=b"x")
|
|
146
|
+
|
|
147
|
+
with pytest.raises(ValueError, match="Cannot specify both"):
|
|
148
|
+
decrypt(_KEY_256, pkt, days=5, eid_pool_size=10)
|
|
149
|
+
|
|
150
|
+
def test_eid_pool_size_with_default_days_is_ok(self):
|
|
151
|
+
"""eid_pool_size + days=2 (the default) should NOT raise."""
|
|
152
|
+
pkt = _make_encrypted_packet(_KEY_256, 0, seq_no=1, plaintext=b"ok")
|
|
153
|
+
|
|
154
|
+
result = decrypt(_KEY_256, pkt, days=2, eid_pool_size=10)
|
|
155
|
+
assert result is not None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
# CLI option validation (ble scan / ble detect)
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
class TestCliEidPoolSizeOptions:
|
|
163
|
+
"""Test --eid-pool-size CLI option validation."""
|
|
164
|
+
|
|
165
|
+
@pytest.fixture
|
|
166
|
+
def runner(self):
|
|
167
|
+
return CliRunner()
|
|
168
|
+
|
|
169
|
+
def test_scan_eid_pool_size_without_key_errors(self, runner):
|
|
170
|
+
from hubblenetwork.cli import cli
|
|
171
|
+
|
|
172
|
+
result = runner.invoke(cli, ["ble", "scan", "--eid-pool-size", "32"])
|
|
173
|
+
assert result.exit_code != 0
|
|
174
|
+
assert "requires --key" in result.output or "requires --key" in (result.exception and str(result.exception) or "")
|
|
175
|
+
|
|
176
|
+
def test_scan_eid_pool_size_with_days_errors(self, runner):
|
|
177
|
+
from hubblenetwork.cli import cli
|
|
178
|
+
|
|
179
|
+
result = runner.invoke(cli, [
|
|
180
|
+
"ble", "scan",
|
|
181
|
+
"--key", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
|
182
|
+
"--eid-pool-size", "32",
|
|
183
|
+
"--days", "3",
|
|
184
|
+
])
|
|
185
|
+
assert result.exit_code != 0
|
|
186
|
+
|
|
187
|
+
def test_detect_eid_pool_size_with_days_errors(self, runner):
|
|
188
|
+
from hubblenetwork.cli import cli
|
|
189
|
+
|
|
190
|
+
result = runner.invoke(cli, [
|
|
191
|
+
"ble", "detect",
|
|
192
|
+
"--key", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
|
193
|
+
"--eid-pool-size", "32",
|
|
194
|
+
"--days", "3",
|
|
195
|
+
])
|
|
196
|
+
assert result.exit_code != 0
|
|
197
|
+
|
|
198
|
+
def test_scan_help_shows_eid_pool_size(self, runner):
|
|
199
|
+
from hubblenetwork.cli import cli
|
|
200
|
+
|
|
201
|
+
result = runner.invoke(cli, ["ble", "scan", "--help"])
|
|
202
|
+
assert "--eid-pool-size" in result.output
|
|
203
|
+
|
|
204
|
+
def test_detect_help_shows_eid_pool_size(self, runner):
|
|
205
|
+
from hubblenetwork.cli import cli
|
|
206
|
+
|
|
207
|
+
result = runner.invoke(cli, ["ble", "detect", "--help"])
|
|
208
|
+
assert "--eid-pool-size" in result.output
|
|
209
|
+
|
|
210
|
+
def test_detect_help_shows_days(self, runner):
|
|
211
|
+
from hubblenetwork.cli import cli
|
|
212
|
+
|
|
213
|
+
result = runner.invoke(cli, ["ble", "detect", "--help"])
|
|
214
|
+
assert "--days" in result.output
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|