483 lines
18 KiB
Python
483 lines
18 KiB
Python
"""
|
|
Tests for ``bal.core.animated_qr`` (BC-UR v1, BC-UR v2, BBQR interop).
|
|
|
|
Validates the self-contained codecs against the published spec vectors
|
|
(BCR-2020-004/005 BC32, BCR-2020-012 bytewords) and against byte-exact
|
|
output captured from the reference C++ bc-ur encoder (fountain/xoshiro/
|
|
alias-sampler parity), plus round trips, out-of-order assembly, missing-part
|
|
fountain solving and malformed-input rejection for all four formats.
|
|
|
|
Run:
|
|
source electrum/env/bin/activate
|
|
python3 tests/test_core_animated_qr.py
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
|
|
|
import random
|
|
|
|
from bal.core import animated_qr as aq
|
|
|
|
|
|
def _payload(plen: int) -> bytes:
|
|
"""Deterministic payload matching the C++ reference driver (``(i*7)&0xff``)."""
|
|
return bytes((i * 7) & 0xFF for i in range(plen))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# BC32 (BCR-2020-004 / bcr-2020-005 rev1 reference implementation vectors)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_bc32_official_vectors():
|
|
cases = [
|
|
(b"Hello, world", "fpjkcmr09ss8wmmjd3jq6ax7w9"),
|
|
(b"Hello world", "fpjkcmr0ypmk7unvvsh4ra4j"),
|
|
(
|
|
bytes.fromhex("d934063e82001eec0585ee41ab5d8e4b703a4be1f73aec21e143912c56"),
|
|
"my6qv05zqq0wcpv9aeq6khvwfdcr5jlp7uawcg0pgwgjc4shjm6xu",
|
|
),
|
|
]
|
|
for payload, encoded in cases:
|
|
assert aq.bc32_encode(payload) == encoded
|
|
assert aq.bc32_decode(encoded) == payload
|
|
|
|
|
|
def test_bc32_checksum_rejected():
|
|
good = aq.bc32_encode(b"Hello, world")
|
|
corrupted = good[:-1] + ("a" if good[-1] != "a" else "b")
|
|
try:
|
|
aq.bc32_decode(corrupted)
|
|
except aq.AnimatedQrError:
|
|
pass
|
|
else:
|
|
raise AssertionError("expected AnimatedQrError for corrupted BC32")
|
|
|
|
|
|
def test_bc32_bad_char_rejected():
|
|
try:
|
|
aq.bc32_decode("1" * 26)
|
|
except aq.AnimatedQrError:
|
|
pass
|
|
else:
|
|
raise AssertionError("expected AnimatedQrError for '1' (not in alphabet)")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Bytewords (BCR-2020-012)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_bytewords_minimal_roundtrip():
|
|
samples = [bytes(range(256)), _payload(59), b"\x00"] + [
|
|
os.urandom(64) for _ in range(4)
|
|
]
|
|
for data in samples:
|
|
words = aq.bytewords_minimal_encode(data)
|
|
assert len(words) == (len(data) + 4) * 2 # 2 chars per byte incl. CRC
|
|
assert aq.bytewords_minimal_decode(words) == data
|
|
|
|
|
|
def test_bytewords_rejects_corrupted_crc():
|
|
data = _payload(40)
|
|
words = aq.bytewords_minimal_encode(data)
|
|
flip = "a" if words[-1] != "a" else "b"
|
|
try:
|
|
aq.bytewords_minimal_decode(words[:-1] + flip)
|
|
except aq.AnimatedQrError:
|
|
pass
|
|
else:
|
|
raise AssertionError("expected AnimatedQrError for corrupted CRC")
|
|
|
|
|
|
def test_bytewords_rejects_odd_length():
|
|
try:
|
|
aq.bytewords_minimal_decode("abc")
|
|
except aq.AnimatedQrError:
|
|
pass
|
|
else:
|
|
raise AssertionError("expected AnimatedQrError for odd-length bytewords")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# BC-UR v2: byte-exact parity with the reference C++ encoder
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
# Reference frames from the bc-ur C++ fountain encoder
|
|
# (payload x=(i*7)&0xFF, cbor wrapped, single-part and multipart).
|
|
REF_V2_SINGLE_12 = "ur:bytes/gsaeatbabzcecndrehetfhfggtoeemhpmo"
|
|
|
|
REF_V2_MULTI_59 = [
|
|
"ur:bytes/2-2/lpaoaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaeeccasket",
|
|
"ur:bytes/3-2/lpaxaocsfscyrpdpjzbyhdcthdfraeatbabzcecndrehetfhfggtghhpidinjoktkblplkmunyoypdperpryssimryrldt",
|
|
"ur:bytes/4-2/lpaaaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaefeimteue",
|
|
"ur:bytes/5-2/lpahaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnssgdaontls",
|
|
"ur:bytes/6-2/lpamaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnssisescmwt",
|
|
"ur:bytes/7-2/lpataocsfscyrpdpjzbyhdcthdfraeatbabzcecndrehetfhfggtghhpidinjoktkblplkmunyoypdperprysslsspplgm",
|
|
"ur:bytes/8-2/lpayaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaeonlrzebg",
|
|
"ur:bytes/9-2/lpasaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaeaaryknzt",
|
|
"ur:bytes/10-2/lpbkaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnsslotsfrfn",
|
|
"ur:bytes/11-2/lpbdaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnssdtwyrstd",
|
|
"ur:bytes/12-2/lpbnaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaegswnvdin",
|
|
"ur:bytes/13-2/lpbtaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnsshknlptee",
|
|
]
|
|
|
|
# Reference message for the 59-byte payload: byte-string head (0x58,0x3b) + data.
|
|
REF_V2_MULTI_59_MSG = bytes([0x58, 0x3B]) + _payload(59)
|
|
|
|
|
|
def test_v2_single_part_matches_reference():
|
|
frames = aq.ur2_frames(_payload(12), len(REF_V2_SINGLE_12))
|
|
assert frames == [REF_V2_SINGLE_12]
|
|
|
|
|
|
def test_v2_reference_frames_decode_and_reencode_exactly():
|
|
message = REF_V2_MULTI_59_MSG
|
|
fragment_len = -(-len(message) // 2)
|
|
for frame in REF_V2_MULTI_59:
|
|
seq, seq_len, message_len, checksum, data = aq.ur2_parse_part(frame)
|
|
assert seq_len == 2
|
|
assert message_len == len(message)
|
|
assert checksum == aq.crc32_int(message)
|
|
assert len(data) == fragment_len
|
|
# re-encoding the parsed values reproduces the reference line exactly
|
|
assert aq._ur2_part_string(seq, seq_len, message_len, checksum, data) == frame
|
|
# our choose_fragments + partition + xor reproduces the reference data
|
|
indexes = aq.choose_fragments(seq, seq_len, checksum)
|
|
assert seq_num_indexes_valid(seq, seq_len, indexes)
|
|
mixed = aq._mix_fragments(aq._partition_message(message, fragment_len), indexes, fragment_len)
|
|
assert mixed == data
|
|
|
|
|
|
def seq_num_indexes_valid(seq, seq_len, indexes):
|
|
# pure part for seq <= seq_len contains exactly fragment seq-1
|
|
if seq <= seq_len:
|
|
return indexes == {seq - 1}
|
|
return set(indexes) <= set(range(seq_len)) and bool(indexes)
|
|
|
|
|
|
def test_v2_multipart_encoder_matches_reference_from_seq2():
|
|
# Our frames start at seq 1 (spec-aligned); parts seq 2.. must equal the
|
|
# reference (which starts at seq 2 due to first_seq_num=1).
|
|
mine = aq.ur2_frames(_payload(59), 120)
|
|
assert mine[0].split("/", 1)[1].startswith("1-2") or "1-2" in mine[0].split("/")[1]
|
|
assert mine[1:4] == REF_V2_MULTI_59[:3]
|
|
|
|
|
|
def test_v2_reference_seq7_mix_parity():
|
|
# Higher-degree mixed parts (seq_len=7) also match: message uses the
|
|
# reference head 0x58|0x00 for the 256-byte driver payload.
|
|
message = bytes([0x58, 0x00]) + _payload(256)
|
|
seq_len = 7
|
|
fragment_len = -(-len(message) // seq_len)
|
|
frames = [
|
|
"ur:bytes/9-7/lpasatcfadaocyfysnjlsrhddaykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtntoxpyprrhrtsttotluovlwdwnsrfejzhd",
|
|
"ur:bytes/10-7/lpbkatcfadaocyfysnjlsrhddazeahbnbwcycldedlenfsfygrgmhkhniojtkpkelslememkneolpmqzrksasotitsuevwwpwfzswzpmdrvo",
|
|
"ur:bytes/11-7/lpbdatcfadaocyfysnjlsrhddawkwtbbbefnaefnbebbjojybebnaebndybbbewkwtceaecedyeebebbjobnaebnbeeedybbbeztwproyapd",
|
|
]
|
|
for frame in frames:
|
|
seq, sl, mlen, checksum, data = aq.ur2_parse_part(frame)
|
|
assert sl == seq_len and mlen == len(message)
|
|
assert checksum == aq.crc32_int(message)
|
|
mixed = aq._mix_fragments(
|
|
aq._partition_message(message, fragment_len),
|
|
aq.choose_fragments(seq, seq_len, checksum),
|
|
fragment_len,
|
|
)
|
|
assert mixed == data
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# BC-UR v2: sessions / fountain decoding
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_v2_roundtrip_in_order():
|
|
payload = ("BAL transfer " * 9).encode()
|
|
frames = aq.ur2_frames(payload, 120)
|
|
seq_len = int(frames[0].split("/")[1].split("-")[1])
|
|
assert len(frames) == 2 * seq_len # pure wave + redundant mixed wave
|
|
session = aq.AnimatedQrSession()
|
|
for frame in frames:
|
|
session.add_part(frame)
|
|
assert session.done
|
|
assert session.received == session.total
|
|
text, _ = session.resolve()
|
|
assert text == payload.decode()
|
|
|
|
|
|
def test_v2_out_of_order_and_duplicate():
|
|
payload = ("BAL transfer " * 9).encode()
|
|
frames = aq.ur2_frames(payload, 120)
|
|
order = list(range(len(frames)))
|
|
random.Random(11).shuffle(order)
|
|
session = aq.AnimatedQrSession()
|
|
for i in order:
|
|
status = session.add_part(frames[i])
|
|
assert status in ("ok", "dup")
|
|
session.add_part(frames[0]) # duplicate of an already-received part
|
|
assert session.done
|
|
assert session.resolve()[0] == payload.decode()
|
|
|
|
|
|
def test_v2_solves_without_a_pure_fragment():
|
|
payload = ("BAL transfer " * 9).encode()
|
|
frames = aq.ur2_frames(payload, 120)
|
|
session = aq.AnimatedQrSession()
|
|
for frame in frames[1:]: # drop the first pure fragment
|
|
session.add_part(frame)
|
|
assert session.done
|
|
assert session.resolve()[0] == payload.decode()
|
|
|
|
|
|
def test_v2_single_part_import():
|
|
session = aq.AnimatedQrSession()
|
|
session.add_part(REF_V2_SINGLE_12)
|
|
assert session.done and session.total == 1
|
|
assert session.resolve()[0] == _payload(12).decode("latin-1")
|
|
|
|
|
|
def test_v2_conflicting_transfer_rejected():
|
|
payload_a = b"AAAAAAAAAAAAAAAA"
|
|
payload_b = b"BBBBBBBBBBBBBBBB"
|
|
fa = aq.ur2_frames(payload_a, 500)[0]
|
|
fb = aq.ur2_frames(payload_b, 500)[0]
|
|
session = aq.AnimatedQrSession()
|
|
session.add_part(fa)
|
|
try:
|
|
session.add_part(fb)
|
|
except aq.TransferConflictError:
|
|
pass
|
|
else:
|
|
raise AssertionError("expected TransferConflictError for a different transfer")
|
|
|
|
|
|
def test_v2_corrupt_crc_rejected():
|
|
frame = list(REF_V2_MULTI_59[0])
|
|
idx = len(frame) - 1
|
|
frame[idx] = "a" if frame[idx] != "a" else "b"
|
|
try:
|
|
aq.ur2_parse_part("".join(frame))
|
|
except aq.AnimatedQrError:
|
|
pass
|
|
else:
|
|
raise AssertionError("expected AnimatedQrError for a corrupt v2 part")
|
|
|
|
|
|
def test_v2_session_cap_rejected():
|
|
part = aq._ur2_part_string(1, 30000, 100, 1234, b"\x00" * 100)
|
|
session = aq._Ur2Session()
|
|
try:
|
|
session.add(part)
|
|
except aq.SessionLimitError:
|
|
pass
|
|
else:
|
|
raise AssertionError("expected SessionLimitError for oversized seq_len")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# BC-UR v1
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_v1_multipart_roundtrip():
|
|
payload = ("v1 transfer payload " * 6).encode()
|
|
frames = aq.ur1_frames(payload, 120)
|
|
assert len(frames) > 1
|
|
session = aq.AnimatedQrSession()
|
|
for frame in reversed(frames):
|
|
session.add_part(frame)
|
|
assert session.done
|
|
assert session.resolve()[0] == payload.decode()
|
|
|
|
|
|
def test_v1_single_part_roundtrip():
|
|
payload = b"hello, bal"
|
|
frames = aq.ur1_frames(payload, 400)
|
|
assert len(frames) == 1
|
|
session = aq.AnimatedQrSession()
|
|
session.add_part(frames[0])
|
|
assert session.done and session.total == 1
|
|
assert session.resolve()[0] == payload.decode()
|
|
|
|
|
|
def test_v1_headerless_single_part_import():
|
|
# bcr-2020-005 rev1 allows omitting the sequence header + digest entirely.
|
|
payload = b"hello, bal"
|
|
message = aq.cbor_byte_string(payload)
|
|
single = "ur:bytes/" + aq.bc32_encode(message)
|
|
assert aq.detect_format(single) == "ur1"
|
|
session = aq.AnimatedQrSession()
|
|
session.add_part(single)
|
|
assert session.done
|
|
assert session.resolve()[0] == payload.decode()
|
|
|
|
|
|
def test_v1_digest_mismatch_rejected():
|
|
frame = aq.ur1_frames(b"hello, bal", 400)[0]
|
|
tampered = frame[:-4] + "abcd"
|
|
session = aq.AnimatedQrSession()
|
|
session.add_part(tampered)
|
|
try:
|
|
session.resolve()
|
|
except aq.ChecksumError:
|
|
pass
|
|
else:
|
|
raise AssertionError("expected ChecksumError for a tampered v1 digest")
|
|
|
|
|
|
def test_v1_part_numbers_validated():
|
|
for bad in (
|
|
"ur:bytes/0of1/{}full".format("x" * 51),
|
|
"ur:bytes/2of1/{}full".format("x" * 51),
|
|
"ur:bytes/1of0/{}full".format("x" * 51),
|
|
"ur:bytes/1aof1/{}full".format("x" * 51),
|
|
):
|
|
try:
|
|
aq.ur1_parse_part(bad)
|
|
except aq.AnimatedQrError:
|
|
pass
|
|
else:
|
|
raise AssertionError("expected AnimatedQrError for: {}".format(bad))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# BBQR
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_bbqr_all_encodings_roundtrip():
|
|
payload = ("BBQR payload " * 8).encode()
|
|
for encoding in ("Z", "2", "H"):
|
|
frames = aq.bbqr_frames(payload, 90, encoding=encoding)
|
|
assert len(frames) >= 1
|
|
order = list(range(len(frames)))
|
|
random.Random(3).shuffle(order)
|
|
session = aq.AnimatedQrSession()
|
|
for i in order:
|
|
session.add_part(frames[i])
|
|
assert session.done
|
|
assert session.resolve()[0] == payload.decode()
|
|
|
|
|
|
def test_bbqr_compression_default_and_fallback():
|
|
payload = ("repetitive data " * 40).encode() # compresses well
|
|
frames_z = aq.bbqr_frames(payload, 90, encoding="Z")
|
|
# Highly compressible: Z yields one frame and a 'Z' flag.
|
|
assert all(f[2] == "Z" for f in frames_z)
|
|
assert len(frames_z) == 1
|
|
raw = os.urandom(600) # incompressible
|
|
frames_2 = aq.bbqr_frames(raw, 90, encoding="Z")
|
|
assert all(f[2] == "2" for f in frames_2) # Z loses, '2' is used
|
|
|
|
|
|
def test_bbqr_hex_uppercase():
|
|
payload = b"\xde\xad\xbe\xef"
|
|
frame = aq.bbqr_frames(payload, 50, encoding="H")[0]
|
|
assert "DEADBEEF" in frame
|
|
encoding, _type, total, index, frag = aq.bbqr_parse_part(frame)
|
|
assert (encoding, total, index) == ("H", 1, 0)
|
|
|
|
|
|
def test_bbqr_runt_last_part():
|
|
payload = os.urandom(33)
|
|
frames = aq.bbqr_frames(payload, 60, encoding="2")
|
|
parts = [aq.bbqr_parse_part(f)[4] for f in frames]
|
|
joined = aq._bbqr_decode(parts, "2")
|
|
assert joined == payload
|
|
assert len(parts[-1]) < len(parts[0]) # last part is a runt
|
|
|
|
|
|
def test_bbqr_zlib_bomb_rejected():
|
|
compressed = aq._bbqr_encode(b"\x00" * 1000000, "Z")[1]
|
|
try:
|
|
aq._bbqr_decode(["0" * len(compressed)], "2") # not zlib data
|
|
except aq.AnimatedQrError:
|
|
pass
|
|
# direct inflate bomb guard:
|
|
inflated = aq._bbqr_encode(b"\x00" * 1000000, "Z")
|
|
assert inflated[0] == "Z" # 1MB zeros compresses
|
|
bomb = aq._bbqr_encode(b"\x00" * (aq._MAX_MESSAGE_BYTES + 100), "Z")[1]
|
|
parts = [bomb[i : i + 90] for i in range(0, len(bomb), 90)]
|
|
try:
|
|
aq._bbqr_decode(parts, "Z")
|
|
except aq.AnimatedQrError:
|
|
pass
|
|
else:
|
|
raise AssertionError("expected AnimatedQrError for an oversized decompression")
|
|
|
|
|
|
def test_bbqr_part_number_limits():
|
|
try:
|
|
aq.bbqr_frames(os.urandom(30000), 40, encoding="2")
|
|
except aq.AnimatedQrError:
|
|
pass
|
|
else:
|
|
raise AssertionError("expected AnimatedQrError for too many BBQR parts")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Detection / parse_for_detection
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_detect_format_recognises_all_formats():
|
|
assert aq.detect_format("BALQR1|1|1||payload") == "balqr"
|
|
assert aq.detect_format("BAL1" + "001" + "001" + "0" + "payload") == "balqr"
|
|
assert aq.detect_format(aq.ur1_frames(b"x", 400)[0]) == "ur1"
|
|
assert aq.detect_format(aq.ur2_frames(b"x", 400)[0]) == "ur2"
|
|
assert aq.detect_format(aq.bbqr_frames(b"x", 50)[0]) == "bbqr"
|
|
assert aq.detect_format(REF_V2_SINGLE_12) == "ur2"
|
|
assert aq.detect_format("ur:bytes/" + aq.bc32_encode(aq.cbor_byte_string(b"x"))) == "ur1"
|
|
|
|
|
|
def test_detect_format_rejects_garbage():
|
|
for text in ("", "hello world", "BALQ|1|1||a", "ur:", "ur:txn/xyz"):
|
|
assert aq.detect_format(text) is None, text
|
|
# Lenient prefix probe: a string that merely *starts* with "balqr" is
|
|
# reported as balqr (the strict parse then rejects it downstream).
|
|
assert aq.detect_format("BALQRX|1|1||a") == "balqr"
|
|
|
|
|
|
def test_parse_for_detection_keys():
|
|
bal = aq.parse_for_detection("BALQR1|3|2||payload")
|
|
assert bal == ("balqr", "balqr:3", 3, 2)
|
|
# Compact v2 frame (fixed 11-char header) is detected too.
|
|
bal_v2 = aq.parse_for_detection("BAL1" + "007" + "004" + "0" + "payload")
|
|
assert bal_v2 == ("balqr", "balqr:7", 7, 4)
|
|
v2 = aq.parse_for_detection(aq.ur2_frames(b"x"*50, 400)[0])
|
|
assert v2[0] == "ur2" and v2[2] == 1 and v2[3] == 1
|
|
v1 = aq.parse_for_detection(aq.ur1_frames(b"x"*50, 120)[0])
|
|
assert v1[0] == "ur1" and v1[2] > 1 and 1 <= v1[3] <= v1[2]
|
|
bb = aq.parse_for_detection(aq.bbqr_frames(b"x"*50, 40)[0])
|
|
assert bb[0] == "bbqr" and bb[2] >= 1 and 0 <= bb[3] < bb[2]
|
|
|
|
|
|
def test_format_names_exist():
|
|
for fmt in ("balqr", "ur1", "ur2", "bbqr"):
|
|
assert aq.format_name(fmt)
|
|
assert aq.format_name("nope") == "nope"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
if __name__ == "__main__":
|
|
import traceback
|
|
|
|
failures = 0
|
|
for _name, fn in sorted(globals().items()):
|
|
if _name.startswith("test_") and callable(fn):
|
|
try:
|
|
fn()
|
|
print("ok: {}".format(_name))
|
|
except Exception:
|
|
failures += 1
|
|
print("FAIL: {}".format(_name))
|
|
traceback.print_exc()
|
|
if failures:
|
|
print("{} test(s) failed".format(failures))
|
|
sys.exit(1)
|
|
print("all tests passed")
|