core+gui: BBQR-only QR transfer, disable Audio when modem plugin unavailable
- Drop BALQR / BC-UR v1 / v2 wire formats, keep BBQR only (animated_qr, qrtransfer, dialogs); remove the settings "QR Code Size" selector and persist the export-dialog size choice instead. - Disable the Audio transport radio button in the import/export dialogs when the audio_modem plugin (or its amodem dependency) is unavailable. - Refresh translation catalogs (bal.pot, es_ES, it_IT).
This commit is contained in:
@@ -2,33 +2,18 @@
|
||||
bal.core.animated_qr
|
||||
====================
|
||||
|
||||
GUI-free implementation of the interoperable animated-QR transfer formats
|
||||
used to move BAL will data between devices.
|
||||
GUI-free implementation of the BBQR animated-QR transfer format used to
|
||||
move BAL will data between devices.
|
||||
|
||||
Supported wire formats (each self-describing and order-independent on
|
||||
receive):
|
||||
Supported wire format (self-describing and order-independent on receive):
|
||||
|
||||
* **BALQR** (native): ``BAL1<TTT><iii><flag><payload>`` compact fixed-width
|
||||
header (11 chars, 3-digit base36 count fields, no separators); legacy
|
||||
``BALQR1|total|index|flags|payload`` still imported.
|
||||
* **BC-UR v1** (BCR-2020-005 rev1 draft, May 2020)::
|
||||
ur:bytes/1of7/<bc32-digest>/<bc32-fragment>
|
||||
Fragments partition the BC32 rendering of the CBOR byte string; the
|
||||
SHA-256 digest of the wrapped payload ties the parts together.
|
||||
* **BC-UR v2** (BCR-2020-005 rev 2 / BCR-2020-012)::
|
||||
ur:bytes/2-9/<bytewords-minimal fragment>
|
||||
Fountain-coded parts; each part is a CBOR array
|
||||
``[seq_num, seq_len, message_len, checksum, data]`` whose CBOR bytes are
|
||||
bytewords-minimal encoded with a trailing per-part CRC-32. The
|
||||
``checksum`` field holds the CRC-32 of the whole wrapped message, so the
|
||||
parts are mixable and order-independent.
|
||||
* **BBQR** (Coinkite)::
|
||||
B$<encoding><type><2 base36 total><2 base36 index><payload>
|
||||
Equal-length text frames; the payload is uppercase hex, RFC-4648
|
||||
base32, or raw-deflate (``wbits=-10``) zlib plus base32.
|
||||
|
||||
Everything is implemented from scratch on top of the Python standard library
|
||||
only (``zlib``, ``hashlib``, ``base64``), so the shipped plugin zip stays a
|
||||
only (``zlib``, ``base64``), so the shipped plugin zip stays a
|
||||
self-contained bundle with no third-party dependencies (house rule).
|
||||
|
||||
This module never imports Qt or any Electrum GUI code (house rule).
|
||||
@@ -37,9 +22,8 @@ This module never imports Qt or any Electrum GUI code (house rule).
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import zlib
|
||||
from typing import Dict, FrozenSet, List, Optional, Sequence, Set, Tuple
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Errors & safety caps
|
||||
@@ -62,604 +46,10 @@ class SessionLimitError(AnimatedQrError):
|
||||
"""A receive session exceeded its safety caps."""
|
||||
|
||||
|
||||
class ChecksumError(AnimatedQrError):
|
||||
"""A part failed its checksum / digest validation."""
|
||||
|
||||
|
||||
# Safety caps for untrusted scanner input.
|
||||
_MAX_SESSION_PARTS = 20000
|
||||
_MAX_MESSAGE_BYTES = 32 * 1024 * 1024
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CBOR minimals (byte-string envelope + the fountain part header)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_BYTE_STR_RES = 0x40 # byte string, length < 24
|
||||
_BYTE_STR_1 = 0x58 # byte string, 1-byte length
|
||||
_BYTE_STR_2 = 0x59 # byte string, 2-byte length
|
||||
_BYTE_STR_4 = 0x60 # byte string, 4-byte length
|
||||
_ARRAY_RES = 0x80
|
||||
_UNSIGNED_RES = 0x00
|
||||
|
||||
|
||||
def cbor_byte_string(data: bytes) -> bytes:
|
||||
"""Wrap ``data`` in the minimal CBOR byte-string envelope (0x40..0x60)."""
|
||||
n = len(data)
|
||||
if n < 24:
|
||||
head = bytes([_BYTE_STR_RES + n])
|
||||
elif n <= 0xFF:
|
||||
head = bytes([_BYTE_STR_1, n])
|
||||
elif n <= 0xFFFF:
|
||||
head = bytes([_BYTE_STR_2]) + n.to_bytes(2, "big")
|
||||
elif n <= 0xFFFFFFFF:
|
||||
head = bytes([_BYTE_STR_4]) + n.to_bytes(4, "big")
|
||||
else:
|
||||
raise AnimatedQrError("payload too large for the UR byte-string envelope")
|
||||
return head + data
|
||||
|
||||
|
||||
def unwrap_ur_cbor(message: bytes) -> bytes:
|
||||
"""Strip the CBOR byte-string envelope, falling back to the raw bytes.
|
||||
|
||||
Receivers keep working even when the emitter embedded the payload without
|
||||
any CBOR wrapping (some third-party ``ur:bytes`` emitters do).
|
||||
"""
|
||||
if not message:
|
||||
raise AnimatedQrError("empty decoded message")
|
||||
b0 = message[0]
|
||||
if _BYTE_STR_RES <= b0 <= 0x57:
|
||||
header_len, n = 1, b0 - _BYTE_STR_RES
|
||||
elif b0 == _BYTE_STR_1 and len(message) >= 2:
|
||||
header_len, n = 2, message[1]
|
||||
elif b0 == _BYTE_STR_2 and len(message) >= 3:
|
||||
header_len, n = 3, int.from_bytes(message[1:3], "big")
|
||||
elif b0 == _BYTE_STR_4 and len(message) >= 5:
|
||||
header_len, n = 5, int.from_bytes(message[1:5], "big")
|
||||
else:
|
||||
return message
|
||||
if header_len + n != len(message):
|
||||
raise AnimatedQrError("decoded message has an inconsistent CBOR length")
|
||||
return message[header_len:]
|
||||
|
||||
|
||||
def _cbor_unsigned(value: int) -> bytes:
|
||||
if value < 24:
|
||||
return bytes([_UNSIGNED_RES + value])
|
||||
if value <= 0xFF:
|
||||
return bytes([0x18, value])
|
||||
if value <= 0xFFFF:
|
||||
return bytes([0x19]) + value.to_bytes(2, "big")
|
||||
if value <= 0xFFFFFFFF:
|
||||
return bytes([0x1A]) + value.to_bytes(4, "big")
|
||||
return bytes([0x1B]) + value.to_bytes(8, "big")
|
||||
|
||||
|
||||
def cbor_part(seq_num: int, seq_len: int, message_len: int, checksum: int, data: bytes) -> bytes:
|
||||
"""The CBOR body of a BC-UR v2 fountain part (``[seq, seq_len, message_len, checksum, data]``)."""
|
||||
out = bytearray([_ARRAY_RES + 5])
|
||||
out += _cbor_unsigned(seq_num)
|
||||
out += _cbor_unsigned(seq_len)
|
||||
out += _cbor_unsigned(message_len)
|
||||
out += _cbor_unsigned(checksum)
|
||||
out += cbor_byte_string(data)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _need(buf: bytes, pos: int, count: int) -> None:
|
||||
if pos + count > len(buf):
|
||||
raise AnimatedQrError("truncated CBOR part header")
|
||||
|
||||
|
||||
def _cbor_read_unsigned(buf: bytes, pos: int) -> Tuple[int, int]:
|
||||
if pos >= len(buf):
|
||||
raise AnimatedQrError("truncated CBOR part header")
|
||||
octet = buf[pos]
|
||||
if octet & 0xE0 != _UNSIGNED_RES:
|
||||
raise AnimatedQrError("unexpected CBOR type in part header")
|
||||
pos += 1
|
||||
additional = octet & 0x1F
|
||||
if additional < 24:
|
||||
return additional, pos
|
||||
if additional == 24:
|
||||
_need(buf, pos, 1)
|
||||
return buf[pos], pos + 1
|
||||
if additional == 25:
|
||||
_need(buf, pos, 2)
|
||||
return int.from_bytes(buf[pos : pos + 2], "big"), pos + 2
|
||||
if additional == 26:
|
||||
_need(buf, pos, 4)
|
||||
return int.from_bytes(buf[pos : pos + 4], "big"), pos + 4
|
||||
if additional == 27:
|
||||
_need(buf, pos, 8)
|
||||
return int.from_bytes(buf[pos : pos + 8], "big"), pos + 8
|
||||
raise AnimatedQrError("unsupported CBOR integer width in part header")
|
||||
|
||||
|
||||
def _cbor_read_bytes(buf: bytes, pos: int) -> Tuple[bytes, int]:
|
||||
if pos >= len(buf):
|
||||
raise AnimatedQrError("truncated CBOR part header")
|
||||
octet = buf[pos]
|
||||
pos += 1
|
||||
if octet & 0xE0 != _BYTE_STR_RES:
|
||||
raise AnimatedQrError("expected a CBOR byte string in part header")
|
||||
additional = octet & 0x1F
|
||||
if additional < 24:
|
||||
n = additional
|
||||
elif additional == 24:
|
||||
_need(buf, pos, 1)
|
||||
n, pos = buf[pos], pos + 1
|
||||
elif additional == 25:
|
||||
_need(buf, pos, 2)
|
||||
n, pos = int.from_bytes(buf[pos : pos + 2], "big"), pos + 2
|
||||
elif additional == 26:
|
||||
_need(buf, pos, 4)
|
||||
n, pos = int.from_bytes(buf[pos : pos + 4], "big"), pos + 4
|
||||
else:
|
||||
raise AnimatedQrError("unsupported CBOR byte-string width in part header")
|
||||
_need(buf, pos, n)
|
||||
return buf[pos : pos + n], pos + n
|
||||
|
||||
|
||||
def _cbor_read_array(buf: bytes, pos: int) -> Tuple[int, int]:
|
||||
if pos >= len(buf):
|
||||
raise AnimatedQrError("truncated CBOR part header")
|
||||
octet = buf[pos]
|
||||
pos += 1
|
||||
if octet & 0xE0 != _ARRAY_RES:
|
||||
raise AnimatedQrError("expected a CBOR array in part header")
|
||||
additional = octet & 0x1F
|
||||
if additional < 24:
|
||||
return additional, pos
|
||||
if additional == 24:
|
||||
_need(buf, pos, 1)
|
||||
return buf[pos], pos + 1
|
||||
if additional == 25:
|
||||
_need(buf, pos, 2)
|
||||
return int.from_bytes(buf[pos : pos + 2], "big"), pos + 2
|
||||
raise AnimatedQrError("unsupported CBOR array header in part")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CRC-32 (same polynomial as ``zlib.crc32``, network byte order)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def crc32_int(data: bytes) -> int:
|
||||
"""CRC-32 over ``data`` as an unsigned 32-bit integer."""
|
||||
return zlib.crc32(data) & 0xFFFFFFFF
|
||||
|
||||
|
||||
def crc32_bytes(data: bytes) -> bytes:
|
||||
"""CRC-32 over ``data`` as 4 network-order (big-endian) bytes."""
|
||||
return crc32_int(data).to_bytes(4, "big")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Bytewords (BCR-2020-012)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_BYTEWORDS = (
|
||||
"ableacidalsoapexaquaarchatomauntawayaxisbackbaldbarnbeltbetabiasbluebodybragbr"
|
||||
"ewbulbbuzzcalmcashcatschefcityclawcodecolacookcostcruxcurlcuspcyandarkdatadays"
|
||||
"delidicedietdoordowndrawdropdrumdulldutyeacheasyechoedgeepicevenexamexiteyesfa"
|
||||
"ctfairfernfigsfilmfishfizzflapflewfluxfoxyfreefrogfuelfundgalagamegeargemsgift"
|
||||
"girlglowgoodgraygrimgurugushgyrohalfhanghardhawkheathelphighhillholyhopehornhu"
|
||||
"tsicedideaidleinchinkyintoirisironitemjadejazzjoinjoltjowljudojugsjumpjunkjury"
|
||||
"keepkenokeptkeyskickkilnkingkitekiwiknoblamblavalazyleaflegsliarlimplionlistlo"
|
||||
"goloudloveluaulucklungmainmanymathmazememomenumeowmildmintmissmonknailnavyneed"
|
||||
"newsnextnoonnotenumbobeyoboeomitonyxopenovalowlspaidpartpeckplaypluspoempoolpo"
|
||||
"sepuffpumapurrquadquizraceramprealredorichroadrockroofrubyruinrunsrustsafesaga"
|
||||
"scarsetssilkskewslotsoapsolosongstubsurfswantacotasktaxitenttiedtimetinytoilto"
|
||||
"mbtoystriptunatwinuglyundouniturgeuservastveryvetovialvibeviewvisavoidvowswall"
|
||||
"wandwarmwaspwavewaxywebswhatwhenwhizwolfworkyankyawnyellyogayurtzapszerozestzi"
|
||||
"nczonezoom"
|
||||
)
|
||||
|
||||
_WORDS = [_BYTEWORDS[i : i + 4] for i in range(0, 1024, 4)]
|
||||
_DIM = 26
|
||||
_WORD_LOOKUP: Optional[List[int]] = None
|
||||
|
||||
|
||||
def _word_lookup() -> List[int]:
|
||||
"""First/last-letter lookup table (built lazily, mirrors Bytewords)."""
|
||||
global _WORD_LOOKUP
|
||||
if _WORD_LOOKUP is None:
|
||||
table = [-1] * (_DIM * _DIM)
|
||||
for i, word in enumerate(_WORDS):
|
||||
x = ord(word[0]) - ord("a")
|
||||
y = ord(word[3]) - ord("a")
|
||||
table[y * _DIM + x] = i
|
||||
_WORD_LOOKUP = table
|
||||
return _WORD_LOOKUP
|
||||
|
||||
|
||||
def _decode_word(word: str, word_len: int) -> int:
|
||||
if len(word) != word_len:
|
||||
raise AnimatedQrError("invalid bytewords word length")
|
||||
x = ord(word[0]) - ord("a")
|
||||
y = ord(word[3] if word_len == 4 else word[1]) - ord("a")
|
||||
if not (0 <= x < _DIM and 0 <= y < _DIM):
|
||||
raise AnimatedQrError("invalid bytewords characters")
|
||||
value = _word_lookup()[y * _DIM + x]
|
||||
if value == -1:
|
||||
raise AnimatedQrError("invalid bytewords first/last pair")
|
||||
if word_len == 4:
|
||||
full = _WORDS[value]
|
||||
if word[1] != full[1] or word[2] != full[2]:
|
||||
raise AnimatedQrError("invalid bytewords middle letters")
|
||||
return value
|
||||
|
||||
|
||||
def bytewords_minimal_encode(data: bytes) -> str:
|
||||
"""BCR-2020-012 bytewords-minimal: one two-letter word per byte, then CRC."""
|
||||
crc = data + crc32_bytes(data)
|
||||
return "".join(_WORDS[b][0] + _WORDS[b][3] for b in crc)
|
||||
|
||||
|
||||
def bytewords_minimal_decode(text: str) -> bytes:
|
||||
"""Inverse of :func:`bytewords_minimal_encode` (validates the CRC-32)."""
|
||||
if len(text) % 2:
|
||||
raise AnimatedQrError("invalid bytewords length (odd)")
|
||||
values = [_decode_word(text[i : i + 2], 2) for i in range(0, len(text), 2)]
|
||||
payload = bytes(values)
|
||||
if len(payload) < 5:
|
||||
raise AnimatedQrError("bytewords payload too short")
|
||||
body, checksum = payload[:-4], payload[-4:]
|
||||
if crc32_bytes(body) != checksum:
|
||||
raise AnimatedQrError("bytewords CRC-32 mismatch")
|
||||
return body
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BC32 (the deprecated bech32-derived codec used by BC-UR v1)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_BC32_ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
_BC32_REV = {ch: i for i, ch in enumerate(_BC32_ALPHABET)}
|
||||
_BECH32_GENERATOR = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
|
||||
|
||||
|
||||
def bech32_polymod(values: Sequence[int]) -> int:
|
||||
chk = 1
|
||||
for value in values:
|
||||
top = chk >> 25
|
||||
chk = (chk & 0x1FFFFFF) << 5 ^ value
|
||||
for i in range(5):
|
||||
if (top >> i) & 1:
|
||||
chk ^= _BECH32_GENERATOR[i]
|
||||
return chk
|
||||
|
||||
|
||||
def _bc32_checksum(values: List[int]) -> List[int]:
|
||||
polymod = bech32_polymod([0] + values + [0] * 6) ^ 0x3FFFFFFF
|
||||
return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
|
||||
|
||||
|
||||
def _bc32_verify(values: List[int]) -> bool:
|
||||
return bech32_polymod([0] + values) == 0x3FFFFFFF
|
||||
|
||||
|
||||
def bc32_encode(data: bytes) -> str:
|
||||
"""BCR-2020-005 BC32: bech32 without the human-readable part and divider."""
|
||||
acc = 0
|
||||
bits = 0
|
||||
values: List[int] = []
|
||||
for byte in data:
|
||||
acc = (acc << 8) | byte
|
||||
bits += 8
|
||||
while bits >= 5:
|
||||
bits -= 5
|
||||
values.append((acc >> bits) & 31)
|
||||
if bits:
|
||||
values.append((acc << (5 - bits)) & 31)
|
||||
values += _bc32_checksum(values)
|
||||
return "".join(_BC32_ALPHABET[v] for v in values)
|
||||
|
||||
|
||||
def bc32_decode(text: str) -> bytes:
|
||||
"""Inverse of :func:`bc32_encode` (validates the 6-char checksum)."""
|
||||
lowered = text.lower()
|
||||
try:
|
||||
values = [_BC32_REV[ch] for ch in lowered]
|
||||
except KeyError:
|
||||
raise AnimatedQrError("invalid BC-UR v1 character") from None
|
||||
if len(values) < 6 or not _bc32_verify(values):
|
||||
raise AnimatedQrError("invalid BC-UR v1 checksum")
|
||||
data = values[:-6]
|
||||
acc = 0
|
||||
bits = 0
|
||||
out = bytearray()
|
||||
for value in data:
|
||||
acc = (acc << 5) | value
|
||||
bits += 5
|
||||
if bits >= 8:
|
||||
bits -= 8
|
||||
out.append((acc >> bits) & 0xFF)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# xoshiro256** + alias sampler (exact ports of the reference RNG chain)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_MASK64 = (1 << 64) - 1
|
||||
|
||||
|
||||
class _Xoshiro256:
|
||||
"""xoshiro256** 1.0, seeded via SHA-256 of a byte sequence."""
|
||||
|
||||
def __init__(self, seed: bytes):
|
||||
digest = hashlib.sha256(seed).digest()
|
||||
self._s = [
|
||||
int.from_bytes(digest[offset : offset + 8], "big")
|
||||
for offset in range(0, 32, 8)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _rotl(x: int, k: int) -> int:
|
||||
return ((x << k) | (x >> (64 - k))) & _MASK64
|
||||
|
||||
def next(self) -> int:
|
||||
result = (self._rotl((self._s[1] * 5) & _MASK64, 7) * 9) & _MASK64
|
||||
t = (self._s[1] << 17) & _MASK64
|
||||
s = self._s
|
||||
s[2] ^= s[0]
|
||||
s[3] ^= s[1]
|
||||
s[1] ^= s[2]
|
||||
s[0] ^= s[3]
|
||||
s[2] ^= t
|
||||
s[3] = self._rotl(s[3], 45)
|
||||
return result
|
||||
|
||||
def next_double(self) -> float:
|
||||
return self.next() / float(1 << 64)
|
||||
|
||||
def next_int(self, low: int, high: int) -> int:
|
||||
return int(self.next_double() * (high - low + 1)) + low
|
||||
|
||||
|
||||
class _RandomAliasSampler:
|
||||
"""Vose's alias method, built in the exact order of the reference code."""
|
||||
|
||||
def __init__(self, probs: Sequence[float]):
|
||||
total = sum(probs)
|
||||
assert total > 0
|
||||
n = len(probs)
|
||||
normalized = [p * float(n) / total for p in probs]
|
||||
|
||||
small: List[int] = []
|
||||
large: List[int] = []
|
||||
for i in range(n - 1, -1, -1):
|
||||
(small if normalized[i] < 1 else large).append(i)
|
||||
|
||||
self._probs = [0] * n
|
||||
self._aliases = [0] * n
|
||||
while small and large:
|
||||
a = small.pop()
|
||||
g = large.pop()
|
||||
self._probs[a] = normalized[a]
|
||||
self._aliases[a] = g
|
||||
normalized[g] += normalized[a] - 1
|
||||
(small if normalized[g] < 1 else large).append(g)
|
||||
|
||||
while large:
|
||||
self._probs[large.pop()] = 1
|
||||
while small:
|
||||
self._probs[small.pop()] = 1
|
||||
|
||||
def next(self, rng: _Xoshiro256) -> int:
|
||||
r1 = rng.next_double()
|
||||
r2 = rng.next_double()
|
||||
n = len(self._probs)
|
||||
i = int(float(n) * r1)
|
||||
return i if r2 < self._probs[i] else self._aliases[i]
|
||||
|
||||
|
||||
def choose_fragments(seq_num: int, seq_len: int, checksum: int) -> Set[int]:
|
||||
"""The fragments mixed into a BC-UR v2 fountain part (reference seed math).
|
||||
|
||||
Sequence numbers ``1..seq_len`` emit the pure fragment ``{seq_num - 1}``;
|
||||
every larger sequence number deterministically mixes a pseudo-random
|
||||
subset of fragments seeded by ``SHA256(seq ‖ checksum)``.
|
||||
"""
|
||||
if seq_num <= seq_len:
|
||||
return {seq_num - 1}
|
||||
seed = seq_num.to_bytes(4, "big") + checksum.to_bytes(4, "big")
|
||||
rng = _Xoshiro256(seed)
|
||||
probs: List[float] = [1.0 / i for i in range(1, seq_len + 1)]
|
||||
degree = _RandomAliasSampler(probs).next(rng) + 1
|
||||
remaining = list(range(seq_len))
|
||||
shuffled: List[int] = []
|
||||
while remaining:
|
||||
index = rng.next_int(0, len(remaining) - 1)
|
||||
shuffled.append(remaining.pop(index))
|
||||
return set(shuffled[:degree])
|
||||
|
||||
|
||||
def _partition_message(message: bytes, fragment_len: int) -> List[bytes]:
|
||||
fragments: List[bytes] = []
|
||||
for offset in range(0, len(message), fragment_len):
|
||||
fragment = message[offset : offset + fragment_len]
|
||||
if len(fragment) < fragment_len:
|
||||
fragment += b"\x00" * (fragment_len - len(fragment))
|
||||
fragments.append(fragment)
|
||||
return fragments
|
||||
|
||||
|
||||
def _mix_fragments(fragments: Sequence[bytes], indexes: Set[int], fragment_len: int) -> bytes:
|
||||
result = bytearray(fragment_len)
|
||||
for index in indexes:
|
||||
for i, byte in enumerate(fragments[index]):
|
||||
result[i] ^= byte
|
||||
return bytes(result)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BC-UR v2 (bytewords-minimal + fountain)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _ur2_header(seq_num: int, seq_len: int) -> str:
|
||||
return "ur:bytes/{}-{}/".format(seq_num, seq_len)
|
||||
|
||||
|
||||
def _ur2_part_string(seq_num: int, seq_len: int, message_len: int, checksum: int, data: bytes) -> str:
|
||||
body = cbor_part(seq_num, seq_len, message_len, checksum, data)
|
||||
return _ur2_header(seq_num, seq_len) + bytewords_minimal_encode(body)
|
||||
|
||||
|
||||
def _ur2_part_cost(seq_num: int, seq_len: int, message_len: int, checksum: int, data_len: int) -> int:
|
||||
body_len = len(cbor_part(seq_num, seq_len, message_len, checksum, b"\x00" * data_len))
|
||||
# bytewords_minimal_encode appends a 4-byte CRC over the body.
|
||||
return len(_ur2_header(seq_num, seq_len)) + 2 * (body_len + 4)
|
||||
|
||||
|
||||
def ur2_frames(payload: bytes, budget_chars: int) -> List[str]:
|
||||
"""Encode ``payload`` into BC-UR v2 fountain frames.
|
||||
|
||||
``budget_chars`` is the largest frame string the carrying QR code may
|
||||
hold. The first ``seq_len`` frames are pure (one fragment each); a second
|
||||
wave of ``seq_len`` mixed (fountain) frames follows so the receiver can
|
||||
recover with a few parts still missing.
|
||||
"""
|
||||
message = cbor_byte_string(payload)
|
||||
message_len = len(message)
|
||||
checksum = crc32_int(message)
|
||||
single_cost = len("ur:bytes/") + len(bytewords_minimal_encode(message))
|
||||
if single_cost <= budget_chars:
|
||||
return ["ur:bytes/" + bytewords_minimal_encode(message)]
|
||||
|
||||
fragment_len = message_len
|
||||
fragment_count = 1
|
||||
while True:
|
||||
seq_len = fragment_count
|
||||
worst_seq = 2 * seq_len # the export loop emits up to 2*seq_len parts
|
||||
cost = _ur2_part_cost(worst_seq, seq_len, message_len, checksum, fragment_len)
|
||||
if cost <= budget_chars:
|
||||
break
|
||||
fragment_count += 1
|
||||
fragment_len = -(-message_len // fragment_count)
|
||||
if fragment_count > message_len:
|
||||
raise AnimatedQrError("QR budget too small for a BC-UR v2 part")
|
||||
|
||||
fragments = _partition_message(message, fragment_len)
|
||||
frames: List[str] = []
|
||||
for seq_num in range(1, seq_len + 1):
|
||||
frames.append(_ur2_part_string(seq_num, seq_len, message_len, checksum, fragments[seq_num - 1]))
|
||||
for seq_num in range(seq_len + 1, 2 * seq_len + 1):
|
||||
data = _mix_fragments(fragments, choose_fragments(seq_num, seq_len, checksum), fragment_len)
|
||||
frames.append(_ur2_part_string(seq_num, seq_len, message_len, checksum, data))
|
||||
return frames
|
||||
|
||||
|
||||
def ur2_parse_part(frame_text: str) -> Tuple[int, int, int, int, bytes]:
|
||||
"""Parse a BC-UR v2 part into ``(seq, seq_len, message_len, checksum, data)``."""
|
||||
frame_text = frame_text.strip().lower()
|
||||
prefix = "ur:bytes/"
|
||||
if not frame_text.startswith(prefix):
|
||||
raise AnimatedQrError("not a BC-UR v2 part")
|
||||
tail = frame_text[len(prefix) :]
|
||||
if "/" not in tail:
|
||||
body = bytewords_minimal_decode(tail)
|
||||
return 1, 1, len(body), crc32_int(body), body
|
||||
seq_head, words = tail.split("/", 1)
|
||||
try:
|
||||
seq_num_s, seq_len_s = seq_head.split("-", 1)
|
||||
seq_num, seq_len = int(seq_num_s), int(seq_len_s)
|
||||
except ValueError:
|
||||
raise AnimatedQrError("bad BC-UR v2 sequence header") from None
|
||||
if seq_len < 1 or not 1 <= seq_num <= 2**32 - 1:
|
||||
raise AnimatedQrError("bad BC-UR v2 sequence numbers")
|
||||
body = bytewords_minimal_decode(words)
|
||||
arr, pos = _cbor_read_array(body, 0)
|
||||
if arr != 5:
|
||||
raise AnimatedQrError("bad BC-UR v2 part header arity")
|
||||
seq_again, pos = _cbor_read_unsigned(body, pos)
|
||||
seq_len_again, pos = _cbor_read_unsigned(body, pos)
|
||||
message_len, pos = _cbor_read_unsigned(body, pos)
|
||||
checksum, pos = _cbor_read_unsigned(body, pos)
|
||||
data, pos = _cbor_read_bytes(body, pos)
|
||||
if pos != len(body):
|
||||
raise AnimatedQrError("trailing garbage in BC-UR v2 part header")
|
||||
if seq_again != seq_num or seq_len_again != seq_len:
|
||||
raise AnimatedQrError("BC-UR v2 part header mismatch")
|
||||
return seq_num, seq_len, message_len, checksum, bytes(data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BC-UR v1 (BCR-2020-005 rev1: BC32 fragments + SHA-256 digest)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _ur1_digest(message: bytes) -> str:
|
||||
return bc32_encode(hashlib.sha256(message).digest())
|
||||
|
||||
|
||||
def _ur1_prefix(index: int, total: int, digest: str) -> str:
|
||||
return "ur:bytes/{}{}/{}/".format(
|
||||
index, "of{}".format(total), digest
|
||||
)
|
||||
|
||||
|
||||
def ur1_frames(payload: bytes, budget_chars: int) -> List[str]:
|
||||
"""Encode ``payload`` into BC-UR v1 fragments (``NofM`` + BC32 + digest)."""
|
||||
message = cbor_byte_string(payload)
|
||||
digest = _ur1_digest(message)
|
||||
full = bc32_encode(message)
|
||||
|
||||
total = 1
|
||||
while True:
|
||||
longest = _ur1_prefix(total, total, digest)
|
||||
capacity = budget_chars - len(longest)
|
||||
if capacity < 1:
|
||||
raise AnimatedQrError("QR budget too small for BC-UR v1")
|
||||
if len(full) <= capacity * total:
|
||||
break
|
||||
total += 1
|
||||
if total > _MAX_SESSION_PARTS:
|
||||
raise AnimatedQrError("BC-UR v1 transfer demands too many parts")
|
||||
|
||||
frames: List[str] = []
|
||||
pos = 0
|
||||
for index in range(1, total + 1):
|
||||
prefix = _ur1_prefix(index, total, digest)
|
||||
capacity = budget_chars - len(prefix)
|
||||
frames.append(prefix + full[pos : pos + capacity])
|
||||
pos += capacity
|
||||
return frames
|
||||
|
||||
|
||||
def ur1_parse_part(frame_text: str) -> Tuple[int, int, str, str]:
|
||||
"""Parse a BC-UR v1 part into ``(index, total, digest, fragment)``.
|
||||
|
||||
Accepts both the multipart form (``ur:bytes/NofM/<digest>/<frag>``) and
|
||||
the single-part form (``ur:bytes/<frag>``, no sequence header or digest).
|
||||
"""
|
||||
frame_text = frame_text.strip().lower()
|
||||
prefix = "ur:bytes/"
|
||||
if not frame_text.startswith(prefix):
|
||||
raise AnimatedQrError("not a BC-UR v1 part")
|
||||
tail = frame_text[len(prefix) :]
|
||||
parts = tail.split("/")
|
||||
if len(parts) == 1:
|
||||
return 1, 1, "", parts[0]
|
||||
if len(parts) != 3:
|
||||
raise AnimatedQrError("bad BC-UR v1 part structure")
|
||||
seq_head, digest, fragment = parts
|
||||
if "of" not in seq_head:
|
||||
raise AnimatedQrError("BC-UR v1 part misses the sequence header")
|
||||
try:
|
||||
index_s, total_s = seq_head.split("of", 1)
|
||||
index, total = int(index_s), int(total_s)
|
||||
except ValueError:
|
||||
raise AnimatedQrError("bad BC-UR v1 sequence header") from None
|
||||
if total < 1 or not 1 <= index <= total:
|
||||
raise AnimatedQrError("bad BC-UR v1 sequence numbers")
|
||||
if len(digest) != 58:
|
||||
raise AnimatedQrError("bad BC-UR v1 digest")
|
||||
return index, total, digest, fragment
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BBQR (Coinkite)
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -774,9 +164,6 @@ def _bbqr_decode(encoded_parts: Sequence[str], encoding: str) -> bytes:
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
FORMAT_LABELS = {
|
||||
"balqr": "BAL QR",
|
||||
"ur1": "BC-UR v1",
|
||||
"ur2": "BC-UR v2",
|
||||
"bbqr": "BBQR",
|
||||
}
|
||||
|
||||
@@ -791,34 +178,8 @@ def detect_format(text: str) -> Optional[str]:
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return None
|
||||
lowered = text.lower()
|
||||
if lowered.startswith(("balqr", "bal1")):
|
||||
return "balqr"
|
||||
if text.startswith(_BBQR_PREFIX):
|
||||
return "bbqr"
|
||||
if not lowered.startswith("ur:"):
|
||||
return None
|
||||
if lowered.startswith("ur:bytes/"):
|
||||
remainder = lowered[len("ur:bytes/") :]
|
||||
first = remainder.split("/", 1)[0]
|
||||
if "of" in first:
|
||||
return "ur1"
|
||||
if "-" in first:
|
||||
return "ur2"
|
||||
# Single-part: the whole remainder is the body. Prefer a bytewords v2
|
||||
# body (CBOR byte-string head 0x40..0x60), then BC32 v1.
|
||||
try:
|
||||
body = bytewords_minimal_decode(remainder)
|
||||
except AnimatedQrError:
|
||||
pass
|
||||
else:
|
||||
if body and 0x40 <= body[0] <= 0x60:
|
||||
return "ur2"
|
||||
try:
|
||||
bc32_decode(remainder)
|
||||
except AnimatedQrError:
|
||||
return None
|
||||
return "ur1"
|
||||
return None
|
||||
|
||||
|
||||
@@ -830,253 +191,16 @@ def parse_for_detection(text: str) -> Tuple[str, str, int, int]:
|
||||
:class:`AnimatedQrError` when the text cannot be parsed.
|
||||
"""
|
||||
fmt = detect_format(text)
|
||||
if fmt == "balqr":
|
||||
total, index, _compressed, _payload = _parse_balqr(text)
|
||||
return "balqr", "balqr:{}".format(total), total, index
|
||||
if fmt == "ur1":
|
||||
index, total, digest, _frag = ur1_parse_part(text)
|
||||
return "ur1", "ur1:{}".format(digest), total, index
|
||||
if fmt == "ur2":
|
||||
seq, seq_len, message_len, checksum, _data = ur2_parse_part(text)
|
||||
return "ur2", "ur2:{}-{}-{}".format(seq_len, message_len, checksum), seq_len, seq
|
||||
if fmt == "bbqr":
|
||||
encoding, type_code, total, index, _payload = bbqr_parse_part(text)
|
||||
return "bbqr", "bbqr:{}{}:{}".format(encoding, type_code, total), total, index
|
||||
raise FormatNotDetectedError("Not a supported QR transfer format")
|
||||
|
||||
|
||||
def _parse_balqr(text: str) -> Tuple[int, int, bool, str]:
|
||||
from bal.core.qrtransfer import parse_frame
|
||||
|
||||
return parse_frame(text)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Receive sessions (order-independent assembly per format)
|
||||
# Receive session (order-independent assembly)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class _BalQrSession:
|
||||
def __init__(self):
|
||||
self._frames: Dict[int, str] = {}
|
||||
self._total = 0
|
||||
self._compressed = False
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return self._total
|
||||
|
||||
@property
|
||||
def received(self) -> int:
|
||||
return len(self._frames)
|
||||
|
||||
@property
|
||||
def done(self) -> bool:
|
||||
return bool(self._total) and len(self._frames) >= self._total
|
||||
|
||||
def add(self, text: str) -> str:
|
||||
total, index, compressed, payload = _parse_balqr(text)
|
||||
if self._total and total != self._total:
|
||||
raise TransferConflictError("BAL QR transfer total changed")
|
||||
if len(self._frames) >= _MAX_SESSION_PARTS:
|
||||
raise SessionLimitError("too many BAL QR frames")
|
||||
if not self._total:
|
||||
self._total = total
|
||||
self._compressed = compressed
|
||||
if index in self._frames:
|
||||
return "dup"
|
||||
self._frames[index] = payload
|
||||
return "ok"
|
||||
|
||||
def resolve(self) -> Tuple[str, bool]:
|
||||
from bal.core.qrtransfer import assemble
|
||||
|
||||
text = assemble(self._frames, self._total)
|
||||
return text, self._compressed
|
||||
|
||||
|
||||
class _Ur1Session:
|
||||
def __init__(self):
|
||||
self._total = 0
|
||||
self._digest = ""
|
||||
self._fragments: Dict[int, str] = {}
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return self._total
|
||||
|
||||
@property
|
||||
def received(self) -> int:
|
||||
return len(self._fragments)
|
||||
|
||||
@property
|
||||
def done(self) -> bool:
|
||||
return bool(self._total) and len(self._fragments) >= self._total
|
||||
|
||||
def add(self, text: str) -> str:
|
||||
index, total, digest, fragment = ur1_parse_part(text)
|
||||
if self._total:
|
||||
if total != self._total or digest != self._digest:
|
||||
raise TransferConflictError("BC-UR v1 transfer digest changed")
|
||||
else:
|
||||
self._total = total
|
||||
self._digest = digest
|
||||
if total > _MAX_SESSION_PARTS:
|
||||
raise SessionLimitError("BC-UR v1 demands too many parts")
|
||||
if index in self._fragments:
|
||||
return "dup"
|
||||
self._fragments[index] = fragment
|
||||
return "ok"
|
||||
|
||||
def resolve(self) -> Tuple[str, bool]:
|
||||
full = "".join(self._fragments[i] for i in range(1, self._total + 1))
|
||||
try:
|
||||
message = bc32_decode(full)
|
||||
except AnimatedQrError:
|
||||
raise ChecksumError("BC-UR v1 checksum mismatch") from None
|
||||
if self._digest and _ur1_digest(message) != self._digest:
|
||||
raise ChecksumError("BC-UR v1 digest mismatch")
|
||||
return _transfer_text(unwrap_ur_cbor(message)), False
|
||||
|
||||
|
||||
class _Ur2Session:
|
||||
"""Fountain decoder mirroring the reference (C++/python) semantics."""
|
||||
|
||||
def __init__(self):
|
||||
self._seq_len = 0
|
||||
self._message_len = 0
|
||||
self._checksum = 0
|
||||
self._fragment_len = 0
|
||||
self._received: Set[int] = set()
|
||||
self._simple: Dict[FrozenSet[int], bytes] = {}
|
||||
self._mixed: Dict[FrozenSet[int], bytes] = {}
|
||||
self._queue: List[Tuple[FrozenSet[int], bytes]] = []
|
||||
self._processed = 0
|
||||
self._result: Optional[bytes] = None
|
||||
self._bad = False
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return self._seq_len
|
||||
|
||||
@property
|
||||
def received(self) -> int:
|
||||
return self._processed
|
||||
|
||||
@property
|
||||
def done(self) -> bool:
|
||||
return self._result is not None
|
||||
|
||||
def add(self, text: str) -> str:
|
||||
seq, seq_len, message_len, checksum, data = ur2_parse_part(text)
|
||||
if self._seq_len:
|
||||
if not self._validate(seq_len, message_len, checksum, len(data)):
|
||||
raise TransferConflictError("BC-UR v2 transfer header changed")
|
||||
else:
|
||||
self._seq_len = seq_len
|
||||
self._message_len = message_len
|
||||
self._checksum = checksum
|
||||
self._fragment_len = len(data)
|
||||
if seq_len > _MAX_SESSION_PARTS or message_len > _MAX_MESSAGE_BYTES:
|
||||
raise SessionLimitError("BC-UR v2 session exceeds safety caps")
|
||||
indexes = frozenset(choose_fragments(seq, self._seq_len, self._checksum))
|
||||
self._receive(indexes, bytes(data))
|
||||
return "ok"
|
||||
|
||||
def _validate(self, seq_len: int, message_len: int, checksum: int, data_len: int) -> bool:
|
||||
return (
|
||||
seq_len == self._seq_len
|
||||
and message_len == self._message_len
|
||||
and checksum == self._checksum
|
||||
and data_len == self._fragment_len
|
||||
)
|
||||
|
||||
def _receive(self, indexes: FrozenSet[int], data: bytes) -> None:
|
||||
if self._result is not None or self._bad:
|
||||
return
|
||||
self._queue.append((indexes, data))
|
||||
while self._result is None and not self._bad and self._queue:
|
||||
self._process(self._queue.pop(0))
|
||||
self._processed += 1
|
||||
|
||||
def _process(self, item: Tuple[FrozenSet[int], bytes]) -> None:
|
||||
indexes, data = item
|
||||
if len(indexes) == 1:
|
||||
self._process_simple(indexes, data)
|
||||
else:
|
||||
self._process_mixed(indexes, data)
|
||||
|
||||
def _process_simple(self, indexes: FrozenSet[int], data: bytes) -> None:
|
||||
fragment_index = next(iter(indexes))
|
||||
if fragment_index in self._received:
|
||||
return
|
||||
self._simple[indexes] = data
|
||||
self._received.add(fragment_index)
|
||||
if self._received == set(range(self._seq_len)):
|
||||
self._finish()
|
||||
return
|
||||
self._reduce_mixed_by(indexes, data)
|
||||
|
||||
def _reduce_mixed_by(self, indexes: FrozenSet[int], data: bytes) -> None:
|
||||
new_mixed: Dict[FrozenSet[int], bytes] = {}
|
||||
for other_indexes, other_data in self._mixed.items():
|
||||
reduced = self._reduce_part(other_indexes, other_data, indexes, data)
|
||||
if len(reduced[0]) == 1:
|
||||
self._queue.append(reduced)
|
||||
else:
|
||||
new_mixed[reduced[0]] = reduced[1]
|
||||
self._mixed = new_mixed
|
||||
|
||||
def _process_mixed(self, indexes: FrozenSet[int], data: bytes) -> None:
|
||||
if indexes in self._mixed:
|
||||
return
|
||||
reduced_indexes, reduced_data = indexes, data
|
||||
for simple_indexes, simple_data in self._simple.items():
|
||||
reduced_indexes, reduced_data = self._reduce_part(
|
||||
reduced_indexes, reduced_data, simple_indexes, simple_data
|
||||
)
|
||||
for other_indexes, other_data in list(self._mixed.items()):
|
||||
reduced_indexes, reduced_data = self._reduce_part(
|
||||
reduced_indexes, reduced_data, other_indexes, other_data
|
||||
)
|
||||
if len(reduced_indexes) == 1:
|
||||
self._queue.append((reduced_indexes, reduced_data))
|
||||
else:
|
||||
self._reduce_mixed_by(reduced_indexes, reduced_data)
|
||||
if reduced_indexes not in self._mixed:
|
||||
self._mixed[reduced_indexes] = reduced_data
|
||||
|
||||
@staticmethod
|
||||
def _reduce_part(
|
||||
a_indexes: FrozenSet[int], a_data: bytes, b_indexes: FrozenSet[int], b_data: bytes
|
||||
) -> Tuple[FrozenSet[int], bytes]:
|
||||
if b_indexes == a_indexes or not b_indexes.issubset(a_indexes):
|
||||
return a_indexes, a_data
|
||||
new_indexes = a_indexes - b_indexes
|
||||
new_data = bytes(x ^ y for x, y in zip(a_data, b_data, strict=True))
|
||||
return new_indexes, new_data
|
||||
|
||||
def _finish(self) -> None:
|
||||
fragments = []
|
||||
for index in range(self._seq_len):
|
||||
key = frozenset([index])
|
||||
if key not in self._simple:
|
||||
self._bad = True
|
||||
return
|
||||
fragments.append(self._simple[key])
|
||||
message = b"".join(fragments)[: self._message_len]
|
||||
if crc32_int(message) != self._checksum:
|
||||
self._bad = True
|
||||
return
|
||||
self._result = message
|
||||
|
||||
def resolve(self) -> Tuple[str, bool]:
|
||||
if self._bad:
|
||||
raise ChecksumError("BC-UR v2 message checksum mismatch")
|
||||
if self._result is None:
|
||||
raise AnimatedQrError("BC-UR v2 session is not complete")
|
||||
return _transfer_text(unwrap_ur_cbor(self._result)), False
|
||||
|
||||
|
||||
class _BbqrSession:
|
||||
def __init__(self):
|
||||
self._total = 0
|
||||
@@ -1126,8 +250,6 @@ def _transfer_text(raw: bytes) -> str:
|
||||
class AnimatedQrSession:
|
||||
"""Facade over the per-format receive sessions used by the QR import page."""
|
||||
|
||||
_DIALECTS = (("balqr", "_BalQrSession"), ("ur1", "_Ur1Session"), ("ur2", "_Ur2Session"), ("bbqr", "_BbqrSession"))
|
||||
|
||||
def __init__(self):
|
||||
self._inner: Optional[object] = None
|
||||
self._fmt: Optional[str] = None
|
||||
@@ -1152,12 +274,6 @@ class AnimatedQrSession:
|
||||
|
||||
@staticmethod
|
||||
def _make(fmt: str) -> object:
|
||||
if fmt == "balqr":
|
||||
return _BalQrSession()
|
||||
if fmt == "ur1":
|
||||
return _Ur1Session()
|
||||
if fmt == "ur2":
|
||||
return _Ur2Session()
|
||||
if fmt == "bbqr":
|
||||
return _BbqrSession()
|
||||
raise AssertionError("unknown animated-QR format {}".format(fmt))
|
||||
|
||||
@@ -305,11 +305,10 @@ class BalPlugin(BasePlugin):
|
||||
# stay display-only outside the wizard unless the user opts in.
|
||||
self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False)
|
||||
|
||||
# QR_CHUNK_SIZE (will transfer via QR): payload budget, in bytes, used
|
||||
# per QR frame when exporting/importing a will through the QR channel.
|
||||
# The settings dialog offers the 4 standard presets of
|
||||
# bal.core.qrtransfer.CHUNK_PRESETS; this stores the selected budget.
|
||||
# Default 150 (small QR, low-resolution cameras).
|
||||
# QR_CHUNK_SIZE (will transfer via QR): payload budget, in characters,
|
||||
# used per QR frame when exporting/importing a will through the QR
|
||||
# channel. The export dialog seeds its own size selector from this
|
||||
# value (default 150, small QR, low-resolution cameras).
|
||||
self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150)
|
||||
|
||||
# NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the
|
||||
|
||||
@@ -9,31 +9,11 @@ Scope
|
||||
-----
|
||||
* converts will transactions into a compact ``transfer_string``
|
||||
(newline-joined serialized transactions, optionally zlib + base64
|
||||
compressed);
|
||||
* splits that string into fixed-size ``BAL1<TTT><iii><flag>`` frames for
|
||||
multi-QR export, and reassembles/validates them on import.
|
||||
compressed). The transfer string is the payload carried by BBQR QR frames
|
||||
(``bal.core.animated_qr``).
|
||||
|
||||
Wire format (v2, compact)
|
||||
-------------------------
|
||||
A frame is::
|
||||
|
||||
BAL1<TTT><iii><flag><payload>
|
||||
|
||||
* ``BAL1`` - magic + format era (4 chars).
|
||||
* ``TTT`` - frame total as exactly 3 base36 digits (1-based, cap 46655).
|
||||
* ``iii`` - frame index as exactly 3 base36 digits (1-based).
|
||||
* ``flag`` - one char: ``Z`` (zlib + base64) or ``0`` (plain ASCII).
|
||||
* ``payload`` - every other character of the frame; the payloads of all
|
||||
frames, concatenated in index order, rebuild the transfer string.
|
||||
|
||||
The fixed 11-char header replaces the legacy ``BALQR1|N|i|flags|`` form
|
||||
(same 5 pieces of information) without any pipe separator, so the whole
|
||||
frame is scan-friendly and the overhead no longer grows with the frame
|
||||
count. Legacy ``BALQR1|…`` frames are still accepted on import.
|
||||
|
||||
The audio-modem channel deliberately bypasses the framing helpers here
|
||||
(PLAN_QR_TRANSFER.md section 4.4): its transport compresses internally and
|
||||
carries the whole transfer string in a single blob, so callers only use
|
||||
The audio-modem channel carries the whole transfer string in a single blob
|
||||
(PLAN_QR_TRANSFER.md section 4.4), so callers only use
|
||||
:func:`encode_transfer` / :func:`decode_transfer`.
|
||||
|
||||
This module never imports Qt or any Electrum GUI code (house rule).
|
||||
@@ -44,12 +24,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
import zlib
|
||||
|
||||
MAGIC = "BALQR"
|
||||
VERSION = 1
|
||||
FLAG_COMPRESSED = "Z"
|
||||
FLAG_PLAIN = "0"
|
||||
|
||||
# 4 standard presets (label, payload budget in bytes per QR). Ordered from
|
||||
# 4 standard presets (label, payload budget per QR). Ordered from
|
||||
# low-resolution cameras to high-resolution cameras (owner decision D5).
|
||||
CHUNK_PRESETS = (
|
||||
("Small - ~150 bytes/QR (low-res cameras)", 150),
|
||||
@@ -58,61 +33,16 @@ CHUNK_PRESETS = (
|
||||
("XL - ~1800 bytes/QR (high-res cameras)", 1800),
|
||||
)
|
||||
|
||||
# Smallest allowed payload budget per frame, below which the frame header
|
||||
# could consume the whole budget.
|
||||
MIN_CHUNK_SIZE = 40
|
||||
|
||||
# Legacy wire format (still imported); the exporter emits the v2 form below.
|
||||
_FRAME_MAGIC_V1 = MAGIC + str(VERSION)
|
||||
|
||||
# Compact v2 wire format: fixed-width base36 count fields, no separators.
|
||||
_FRAME_MAGIC_V2 = "BAL1"
|
||||
_BASE36_DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
_BASE36_WIDTH = 3
|
||||
_HEADER_V2_LEN = len(_FRAME_MAGIC_V2) + 2 * _BASE36_WIDTH + 1
|
||||
_MAX_TOTAL = 36 ** _BASE36_WIDTH - 1
|
||||
|
||||
|
||||
class QrTransferError(ValueError):
|
||||
"""Base error for will QR / audio transfer processing."""
|
||||
|
||||
|
||||
class MissingFramesError(QrTransferError):
|
||||
"""Some frame indices of a multi-QR transfer are missing."""
|
||||
|
||||
def __init__(self, missing):
|
||||
self.missing = list(missing)
|
||||
super().__init__("Missing QR frames: {}".format(self.missing))
|
||||
|
||||
|
||||
class InconsistentTotalError(QrTransferError):
|
||||
"""Frames disagree about the advertised frame total."""
|
||||
|
||||
|
||||
def encode_transfer(tx_strings, compress=False):
|
||||
"""Join serialized transaction strings into a transfer string.
|
||||
|
||||
``compress=True`` wraps the joined text in zlib + base64 (ASCII-safe) so
|
||||
the whole bundle shrinks before being printed/scanned. The optional flag
|
||||
of the frame header lets the importer reverse this automatically.
|
||||
the whole bundle shrinks before being printed/scanned.
|
||||
"""
|
||||
return __compress("\n".join(tx_strings), enabled=compress)
|
||||
|
||||
|
||||
def encode_transfer_best(tx_strings):
|
||||
"""Encode ``tx_strings`` with the smaller of plain vs compressed form.
|
||||
|
||||
Returns ``(transfer_string, compressed: bool)``. Compressed wins only
|
||||
when zlib + base64 really is shorter (best-of, never larger).
|
||||
"""
|
||||
joined = "\n".join(tx_strings)
|
||||
plain = joined
|
||||
compressed = __compress(joined, enabled=True)
|
||||
if len(compressed) < len(plain):
|
||||
return compressed, True
|
||||
return plain, False
|
||||
|
||||
|
||||
def decode_transfer(transfer_string, compressed):
|
||||
"""Inverse of :func:`encode_transfer`.
|
||||
|
||||
@@ -124,73 +54,6 @@ def decode_transfer(transfer_string, compressed):
|
||||
return [part for part in text.split("\n") if part]
|
||||
|
||||
|
||||
def split_frames(transfer_string, chunk_size, compressed=False):
|
||||
"""Split ``transfer_string`` into full compact ``BAL1`` frames.
|
||||
|
||||
Every returned frame has the fixed 11-char v2 header followed by its
|
||||
share of the payload, so each frame is at most ``chunk_size`` characters
|
||||
long. ``compressed`` stamps the ``Z`` flag into every frame so the
|
||||
importer knows how to reverse the encoding.
|
||||
|
||||
Raises :class:`QrTransferError` when ``chunk_size`` is too small to hold
|
||||
the header plus any payload, or when the transfer needs more than
|
||||
:data:`_MAX_TOTAL` frames.
|
||||
"""
|
||||
flag = FLAG_COMPRESSED if compressed else FLAG_PLAIN
|
||||
total = __compute_total(len(transfer_string), chunk_size)
|
||||
budget = chunk_size - _HEADER_V2_LEN
|
||||
frames = []
|
||||
pos = 0
|
||||
length = len(transfer_string)
|
||||
for index in range(1, total + 1):
|
||||
end = min(pos + budget, length)
|
||||
frames.append(
|
||||
_FRAME_MAGIC_V2
|
||||
+ _base36(total)
|
||||
+ _base36(index)
|
||||
+ flag
|
||||
+ transfer_string[pos:end]
|
||||
)
|
||||
pos = end
|
||||
if pos < length:
|
||||
# __compute_total guarantees this cannot happen; keep a safety net.
|
||||
raise QrTransferError("internal error: frames did not cover the transfer string")
|
||||
return frames
|
||||
|
||||
|
||||
def parse_frame(frame):
|
||||
"""Parse a single frame.
|
||||
|
||||
Accepts both the legacy ``BALQR1|total|index|flags|payload`` form and
|
||||
the compact ``BAL1<total><index><flag><payload>`` v2 form.
|
||||
|
||||
Returns ``(total, index, compressed: bool, payload: str)``. Raises
|
||||
:class:`QrTransferError` on malformed input (bad magic/version, wrong
|
||||
arity, non-integer or out-of-range frame numbers, unknown flags).
|
||||
"""
|
||||
if frame.startswith(_FRAME_MAGIC_V2):
|
||||
return _parse_v2(frame)
|
||||
return _parse_v1(frame)
|
||||
|
||||
|
||||
def assemble(frames, total):
|
||||
"""Concatenate frame payloads back into a transfer string.
|
||||
|
||||
``frames`` maps 1-based index -> payload. Every index ``1..total`` must
|
||||
be present (else :class:`MissingFramesError`) and no index may exceed
|
||||
``total`` (else :class:`InconsistentTotalError`).
|
||||
"""
|
||||
if total < 1:
|
||||
raise QrTransferError("invalid frame total")
|
||||
missing = [index for index in range(1, total + 1) if index not in frames]
|
||||
if missing:
|
||||
raise MissingFramesError(missing)
|
||||
extra = [index for index in frames if index > total]
|
||||
if extra:
|
||||
raise InconsistentTotalError()
|
||||
return "".join(frames[index] for index in range(1, total + 1))
|
||||
|
||||
|
||||
def preset_index_for_chunk_size(chunk_size):
|
||||
"""Return the :data:`CHUNK_PRESETS` index whose budget best matches a size."""
|
||||
best, best_diff = 0, abs(chunk_size - CHUNK_PRESETS[0][1])
|
||||
@@ -215,90 +78,3 @@ def __decompress(text, *, enabled):
|
||||
if not enabled:
|
||||
return text
|
||||
return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8")
|
||||
|
||||
|
||||
def _base36(n):
|
||||
"""Zero-padded :data:`_BASE36_WIDTH` base36 render of ``n``."""
|
||||
if not 0 <= n <= _MAX_TOTAL:
|
||||
raise QrTransferError("BAL QR part number out of range: {}".format(n))
|
||||
chars = []
|
||||
for _ in range(_BASE36_WIDTH):
|
||||
chars.append(_BASE36_DIGITS[n % 36])
|
||||
n //= 36
|
||||
return "".join(reversed(chars))
|
||||
|
||||
|
||||
def _base36_decode(text):
|
||||
"""Inverse of :func:`_base36`; raises ``ValueError`` on bad input."""
|
||||
if len(text) != _BASE36_WIDTH or any(c not in _BASE36_DIGITS for c in text):
|
||||
raise ValueError(text)
|
||||
n = 0
|
||||
for c in text:
|
||||
n = n * 36 + _BASE36_DIGITS.index(c)
|
||||
return n
|
||||
|
||||
|
||||
def _parse_v1(frame):
|
||||
parts = frame.split("|", maxsplit=4)
|
||||
if len(parts) != 5:
|
||||
raise QrTransferError("Not a BAL will QR (bad frame structure)")
|
||||
magic_seen, total_s, index_s, flags, payload = parts
|
||||
if magic_seen != _FRAME_MAGIC_V1:
|
||||
raise QrTransferError("Not a BAL will QR (unknown magic/version)")
|
||||
try:
|
||||
total = int(total_s)
|
||||
index = int(index_s)
|
||||
except ValueError as e:
|
||||
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from e
|
||||
if total < 1 or not 1 <= index <= total:
|
||||
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
|
||||
if flags not in ("", FLAG_COMPRESSED):
|
||||
raise QrTransferError("Not a BAL will QR (unknown flags)")
|
||||
return total, index, flags == FLAG_COMPRESSED, payload
|
||||
|
||||
|
||||
def _parse_v2(frame):
|
||||
if len(frame) < _HEADER_V2_LEN:
|
||||
raise QrTransferError("Not a BAL will QR (bad frame structure)")
|
||||
# Magic is length _FRAME_MAGIC_V2; the two base36 fields and the flag
|
||||
# make up the rest of the fixed header.
|
||||
offset = len(_FRAME_MAGIC_V2)
|
||||
total_s = frame[offset : offset + _BASE36_WIDTH]
|
||||
index_s = frame[offset + _BASE36_WIDTH : offset + 2 * _BASE36_WIDTH]
|
||||
flag = frame[offset + 2 * _BASE36_WIDTH]
|
||||
try:
|
||||
total = _base36_decode(total_s)
|
||||
index = _base36_decode(index_s)
|
||||
except ValueError:
|
||||
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from None
|
||||
if total < 1 or not 1 <= index <= total:
|
||||
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
|
||||
if flag not in (FLAG_PLAIN, FLAG_COMPRESSED):
|
||||
raise QrTransferError("Not a BAL will QR (unknown flags)")
|
||||
payload = frame[_HEADER_V2_LEN:]
|
||||
return total, index, flag == FLAG_COMPRESSED, payload
|
||||
|
||||
|
||||
def __compute_total(transfer_len, chunk_size):
|
||||
"""Smallest frame count whose budget covers the whole transfer string.
|
||||
|
||||
The v2 header is fixed-width, so the budget is constant and the count is
|
||||
a plain ceiling division, capped at :data:`_MAX_TOTAL`.
|
||||
"""
|
||||
if chunk_size < MIN_CHUNK_SIZE:
|
||||
raise QrTransferError(
|
||||
"chunk size too small to hold a BAL QR frame: {}".format(chunk_size)
|
||||
)
|
||||
budget = chunk_size - _HEADER_V2_LEN
|
||||
if budget <= 0:
|
||||
raise QrTransferError(
|
||||
"chunk size too small for the BAL QR frame header: {}".format(chunk_size)
|
||||
)
|
||||
total = -(-transfer_len // budget)
|
||||
if total < 1:
|
||||
total = 1
|
||||
if total > _MAX_TOTAL:
|
||||
raise QrTransferError(
|
||||
"BAL QR transfer demands too many frames: {}".format(total)
|
||||
)
|
||||
return total
|
||||
|
||||
Reference in New Issue
Block a user