Initial public release
This commit is contained in:
+115
@@ -0,0 +1,115 @@
|
||||
"""Encrypted-frame envelope shared by the variable-sync and live-keystroke
|
||||
BLE channels.
|
||||
|
||||
Wire format (identical for both channels):
|
||||
byte tag_len 1 byte
|
||||
bytes tag ASCII "M5Stack|AA:BB:CC:DD:EE:FF"
|
||||
bytes nonce 12 bytes
|
||||
bytes ciphertext+gcm_tag N + 16 bytes (AES-256-GCM, AAD = tag bytes)
|
||||
|
||||
Each channel layers its own plaintext schema on top: the var-sync channel
|
||||
uses JSON, the live-keystroke channel uses a compact binary protocol
|
||||
defined in ble_live.py. Frames whose tag prefix isn't "M5Stack|" or whose
|
||||
AEAD authentication fails are dropped silently by the parser.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
DEVICE_TAG_PREFIX = "M5Stack|"
|
||||
MAC_RE = re.compile(r"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$")
|
||||
|
||||
|
||||
def build_frame(key: bytes, tag: str, plaintext: bytes) -> bytes:
|
||||
"""Build an encrypted, MAC-tagged frame.
|
||||
|
||||
`tag` is the device-tag string ("M5Stack|AA:BB:..."); it is bound to
|
||||
the ciphertext via GCM AAD and also written in plaintext at the head
|
||||
of the frame so the receiver can read it before decrypting.
|
||||
"""
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
tag_bytes = tag.encode("ascii")
|
||||
if len(tag_bytes) > 255:
|
||||
raise ValueError("device tag too long")
|
||||
nonce = os.urandom(12)
|
||||
ct = AESGCM(key).encrypt(nonce, plaintext, tag_bytes)
|
||||
return bytes([len(tag_bytes)]) + tag_bytes + nonce + ct
|
||||
|
||||
|
||||
def extract_tag(frame: bytes):
|
||||
"""Pull the device tag (e.g. "M5Stack|AA:BB:CC:DD:EE:FF") out of
|
||||
the frame header WITHOUT decrypting. Returns the tag string or
|
||||
None if the frame is malformed.
|
||||
|
||||
Used by the BLE layer to select the right per-MAC key before
|
||||
attempting AES-GCM authentication.
|
||||
"""
|
||||
if len(frame) < 1 + 12 + 16:
|
||||
return None
|
||||
tag_len = frame[0]
|
||||
if tag_len == 0 or len(frame) < 1 + tag_len + 12 + 16:
|
||||
return None
|
||||
tag_bytes = bytes(frame[1:1 + tag_len])
|
||||
try:
|
||||
tag_str = tag_bytes.decode("ascii")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
if not tag_str.startswith(DEVICE_TAG_PREFIX):
|
||||
return None
|
||||
mac = tag_str[len(DEVICE_TAG_PREFIX):]
|
||||
if not MAC_RE.match(mac):
|
||||
return None
|
||||
return tag_str
|
||||
|
||||
|
||||
def parse_frame(key: bytes, frame: bytes):
|
||||
"""Return (tag_str, plaintext_bytes) on success, or None on any failure.
|
||||
|
||||
Returns None silently for malformed / wrong-prefix / authentication-
|
||||
failed frames so callers can drop them without leaking timing info.
|
||||
"""
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.exceptions import InvalidTag
|
||||
|
||||
tag_str = extract_tag(frame)
|
||||
if tag_str is None:
|
||||
return None
|
||||
tag_len = frame[0]
|
||||
tag_bytes = bytes(frame[1:1 + tag_len])
|
||||
nonce = bytes(frame[1 + tag_len:1 + tag_len + 12])
|
||||
ct = bytes(frame[1 + tag_len + 12:])
|
||||
try:
|
||||
pt = AESGCM(key).decrypt(nonce, ct, tag_bytes)
|
||||
except InvalidTag:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
return tag_str, pt
|
||||
|
||||
|
||||
def parse_frame_auto(frame: bytes):
|
||||
"""Decode a frame using the right per-MAC key automatically.
|
||||
|
||||
Reads the tag from the frame header, asks ble_keystore for the
|
||||
matching key (falling back to the legacy single-key file), then
|
||||
AES-GCM-decrypts. Returns (tag_str, plaintext_bytes) on success,
|
||||
or (tag_str, None) if a tag was readable but no matching key was
|
||||
available (so callers can surface a clear "unknown device" error
|
||||
instead of a silent auth fail), or None for fully malformed input.
|
||||
"""
|
||||
import ble_keystore
|
||||
tag_str = extract_tag(frame)
|
||||
if tag_str is None:
|
||||
return None
|
||||
mac = tag_str[len(DEVICE_TAG_PREFIX):]
|
||||
key = ble_keystore.load_key_for_mac_or_default(mac)
|
||||
if key is None:
|
||||
return (tag_str, None)
|
||||
parsed = parse_frame(key, frame)
|
||||
if parsed is None:
|
||||
# Key existed but didn't authenticate — surface as a separate
|
||||
# signal (None for plaintext) so the live client can emit
|
||||
# KEY_MISMATCH for this specific device.
|
||||
return (tag_str, None)
|
||||
return parsed
|
||||
Reference in New Issue
Block a user