diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index c39cda5ed030..84150ee30c36 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -64,6 +64,11 @@ Item { } //scanner.destroy() // TODO }) + scanner.onFoundUR.connect(function(data) { + console.log("UR type:", data.getType) + console.log("UR data:", data.getBytes) + piResolver.recipient = data.getBytes + }) scanner.open() } diff --git a/electrum/gui/qml/java_classes/org/electrum/qr/ScanCallback.java b/electrum/gui/qml/java_classes/org/electrum/qr/ScanCallback.java new file mode 100644 index 000000000000..2286c0d6056a --- /dev/null +++ b/electrum/gui/qml/java_classes/org/electrum/qr/ScanCallback.java @@ -0,0 +1,5 @@ +package org.electrum.qr; + +public interface ScanCallback { + boolean onPart(String text, byte[] binary); +} diff --git a/electrum/gui/qml/java_classes/org/electrum/qr/SimpleScannerActivity.java b/electrum/gui/qml/java_classes/org/electrum/qr/SimpleScannerActivity.java index 476c375c42ed..0f36ff57a5bd 100644 --- a/electrum/gui/qml/java_classes/org/electrum/qr/SimpleScannerActivity.java +++ b/electrum/gui/qml/java_classes/org/electrum/qr/SimpleScannerActivity.java @@ -37,6 +37,12 @@ public class SimpleScannerActivity extends Activity { private boolean mAlreadyRequestedPermissions = false; + private static ScanCallback callback; + + public static void setCallback(ScanCallback cb) { + callback = cb; + } + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -64,7 +70,8 @@ public void onClick(View v) { Toast.makeText(SimpleScannerActivity.this, "Clipboard contents too large.", Toast.LENGTH_SHORT).show(); return; } - SimpleScannerActivity.this.setResultAndClose(null, clipboardText); + Log.v(TAG, "clipboard contentType TEXT"); + SimpleScannerActivity.this.setResultAndClose(clipboardText, null); } else { Toast.makeText(SimpleScannerActivity.this, "Clipboard is empty.", Toast.LENGTH_SHORT).show(); } @@ -104,32 +111,38 @@ private void startCamera() { contentFrame.addView(mScannerView); mScannerView.setOnBarcodeListener(result -> { // Handle the scan result - this.setResultAndClose(result, null); - // Return false to stop scanning after first result - return false; + String text = null; + byte[] binary = null; + if (result.getContentType() == ContentType.TEXT) { + Log.v(TAG, "scanResult contentType TEXT"); + text = result.getText(); + } else if (result.getContentType() == ContentType.BINARY) { + Log.v(TAG, "scanResult contentType BINARY"); + binary = result.getRawBytes(); + } else { + Log.v(TAG, "scanResult contentType unknown"); + return true; + } + return !this.setResultAndClose(text, binary); }); } mScannerView.openAsync(); // Start camera on resume } - private void setResultAndClose(Result scanResult, String textOnly) { - Intent resultIntent = new Intent(); - if (textOnly != null) { - Log.v(TAG, "clipboard contentType TEXT"); - resultIntent.putExtra("text", textOnly); - } else if (scanResult != null) { - if (scanResult.getContentType() == ContentType.TEXT) { - Log.v(TAG, "scanResult contentType TEXT"); - resultIntent.putExtra("text", scanResult.getText()); - } else if (scanResult.getContentType() == ContentType.BINARY) { - Log.v(TAG, "scanResult contentType BINARY"); - resultIntent.putExtra("binary", scanResult.getRawBytes()); - } else { - Log.v(TAG, "scanresult contenttype unknown"); + private boolean setResultAndClose(String text, byte[] binary) { + boolean done = false; + if (callback != null) { + try { + done = callback.onPart(text, binary); + } catch (Exception e) { + Log.e(TAG, "callback failed", e); } } - setResult(Activity.RESULT_OK, resultIntent); - this.finish(); + if (done) { + setResult(Activity.RESULT_OK, new Intent()); + this.finish(); + } + return done; } private boolean hasPermission() { diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index e3971561a087..4ea541e6a4e6 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -36,7 +36,7 @@ from .qeinvoice import QEInvoice, QEInvoiceParser from .qepiresolver import QEPIResolver from .qerequestdetails import QERequestDetails -from .qetypes import QEAmount, QEBytes +from .qetypes import QEAmount, QEBytes, QEUR from .qeaddressdetails import QEAddressDetails from .qetxdetails import QETxDetails from .qechannelopener import QEChannelOpener @@ -479,6 +479,7 @@ def __init__(self, args, *, config: 'SimpleConfig', daemon: 'Daemon', plugins: ' # TODO QT6 order of declaration is important now? qmlRegisterType(QEAmount, 'org.electrum', 1, 0, 'Amount') qmlRegisterType(QEBytes, 'org.electrum', 1, 0, 'Bytes') + qmlRegisterType(QEUR, 'org.electrum', 1, 0, 'UR') qmlRegisterType(QENewWalletWizard, 'org.electrum', 1, 0, 'QNewWalletWizard') qmlRegisterType(QETermsOfUseWizard, 'org.electrum', 1, 0, 'QTermsOfUseWizard') qmlRegisterType(QEServerConnectWizard, 'org.electrum', 1, 0, 'QServerConnectWizard') diff --git a/electrum/gui/qml/qeqrscanner.py b/electrum/gui/qml/qeqrscanner.py index be93bbf1f288..8357937a0e33 100644 --- a/electrum/gui/qml/qeqrscanner.py +++ b/electrum/gui/qml/qeqrscanner.py @@ -3,14 +3,15 @@ from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, Qt from PyQt6.QtGui import QGuiApplication -from electrum.gui.qml.qetypes import QEBytes +from electrum.gui.qml.qetypes import QEBytes, QEUR from electrum.util import send_exception_to_crash_reporter from electrum.logging import get_logger from electrum.i18n import _ +from electrum.ur.ur_decoder import URDecoder if 'ANDROID_DATA' in os.environ: - from jnius import autoclass + from jnius import autoclass, PythonJavaClass, java_method from android import activity jpythonActivity = autoclass('org.kivy.android.PythonActivity').mActivity @@ -18,6 +19,19 @@ jIntent = autoclass('android.content.Intent') + class QRPartCallback(PythonJavaClass): + __javainterfaces__ = ['org/electrum/qr/ScanCallback'] + __javacontext__ = 'app' + + def __init__(self, handler): + super().__init__() + self.handler = handler + + @java_method('(Ljava/lang/String;[B)Z') + def onPart(self, text, binary): + return self.handler(text, binary) + + class QEQRScanner(QObject): REQUEST_CODE_SIMPLE_SCANNER_ACTIVITY = 30368 # random 16 bit int @@ -25,6 +39,7 @@ class QEQRScanner(QObject): foundText = pyqtSignal(str) foundBinary = pyqtSignal(QEBytes) + foundUR = pyqtSignal(QEUR) finished = pyqtSignal() @@ -52,6 +67,12 @@ def open(self): self._scan_qr_non_android() return jSimpleScannerActivity = autoclass("org.electrum.qr.SimpleScannerActivity") + + self._result = None + self._decoder = URDecoder() + self._callback = QRPartCallback(self._on_part) + jSimpleScannerActivity.setCallback(self._callback) + intent = jIntent(jpythonActivity, jSimpleScannerActivity) intent.putExtra(jIntent.EXTRA_TEXT, jString(self._hint)) @@ -63,16 +84,30 @@ def close(self): # no-op to prevent qml type error pass + def _on_part(self, text, binary): + if text: + self._result = str(text) + if text.lower().startswith('ur:'): + self._decoder.receive_part(text) + if not self._decoder.is_complete(): + return False + elif binary: + self._result = bytes(binary) + return True + def on_qr_activity_result(self, requestCode, resultCode, intent): if requestCode != self.REQUEST_CODE_SIMPLE_SCANNER_ACTIVITY: self._logger.warning(f"got activity result with invalid {requestCode=}") return try: if resultCode == -1: # RESULT_OK: - if (contents := intent.getStringExtra(jString("text"))) is not None: - self.foundText.emit(contents) - if (contents := intent.getByteArrayExtra(jString("binary"))) is not None: - self._binary_content = QEBytes(bytes(contents.tolist())) + if self._decoder.is_complete(): + self._ur_content = QEUR(self._decoder.result_message()) + self.foundUR.emit(self._ur_content) + elif isinstance(self._result, str): + self.foundText.emit(self._result) + elif isinstance(self._result, bytes): + self._binary_content = QEBytes(self._result) self.foundBinary.emit(self._binary_content) except Exception as e: # exc would otherwise get lost send_exception_to_crash_reporter(e) diff --git a/electrum/gui/qml/qetypes.py b/electrum/gui/qml/qetypes.py index abcb445dfe81..6f1ba07e3aaf 100644 --- a/electrum/gui/qml/qetypes.py +++ b/electrum/gui/qml/qetypes.py @@ -1,7 +1,11 @@ +from base64 import b64encode + from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject from electrum.logging import get_logger from electrum.i18n import _ +from electrum.ur.cbor_lite import CBORDecoder +from electrum.ur.ur import UR class QEAmount(QObject): @@ -141,3 +145,38 @@ def __str__(self): def __repr__(self): return f"" + + +class QEUR(QObject): + def __init__(self, data: UR = None, *, parent=None): + super().__init__(parent) + self.data = data + + @property + def data(self): + return self._data + + @data.setter + def data(self, _data): + self._data = _data + + @pyqtProperty(str) + def getType(self): + if self._data is None: + return "" + return self._data.type + + @pyqtProperty(str) + def getBytes(self): + if self._data is None: + return "" + data, _ = CBORDecoder(self._data.cbor).decodeBytes() + return b64encode(data).decode() + + def __str__(self): + return f'{self.getBytes}' + + def __repr__(self): + if self._data is None: + return "" + return f"" diff --git a/electrum/gui/qt/qrreader/qtmultimedia/camera_dialog.py b/electrum/gui/qt/qrreader/qtmultimedia/camera_dialog.py index 003b05377456..e0cb2df70c4e 100644 --- a/electrum/gui/qt/qrreader/qtmultimedia/camera_dialog.py +++ b/electrum/gui/qt/qrreader/qtmultimedia/camera_dialog.py @@ -38,6 +38,7 @@ from electrum.i18n import _ from electrum.qrreader import get_qr_reader, QrCodeResult, MissingQrDetectionLib from electrum.logging import Logger +from electrum.ur.ur_decoder import URDecoder from electrum.gui.qt.util import MessageBoxMixin, FixedAspectRatioLayout, ImageGraphicsEffect @@ -91,6 +92,7 @@ def __init__(self, parent: Optional[QWidget], *, config: SimpleConfig): self._ok_done: bool = False self.camera_sc_conn = None self.resolution: QSize = None + self.ur_decoder = URDecoder() self.config = config @@ -245,6 +247,7 @@ def _on_finished(self, code): and self.validator_res and self.validator_res.accepted and self.validator_res.simple_result) or '' ) + res = res and (self.ur_decoder.result or res) self.validator = None @@ -291,8 +294,9 @@ def _on_frame_available(self, frame: QImage): # Close the dialog if the validator accepted the result if self.validator_res.accepted: - self.accept() - return + if not self.ur_decoder.receive_part(self.validator_res.simple_result): + self.accept() + return # Apply the crop blur effect if self.image_effect: diff --git a/electrum/ur/__init__.py b/electrum/ur/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/electrum/ur/bytewords.py b/electrum/ur/bytewords.py new file mode 100644 index 000000000000..8c917898cf28 --- /dev/null +++ b/electrum/ur/bytewords.py @@ -0,0 +1,140 @@ +# +# bytewords.py +# +# Copyright © 2020 Foundation Devices, Inc. +# Licensed under the "BSD-2-Clause Plus Patent License" +# + +from .utils import crc32_bytes, partition + +BYTEWORDS = 'ableacidalsoapexaquaarchatomauntawayaxisbackbaldbarnbeltbetabiasbluebodybragbrewbulbbuzzcalmcashcatschefcityclawcodecolacookcostcruxcurlcuspcyandarkdatadaysdelidicedietdoordowndrawdropdrumdulldutyeacheasyechoedgeepicevenexamexiteyesfactfairfernfigsfilmfishfizzflapflewfluxfoxyfreefrogfuelfundgalagamegeargemsgiftgirlglowgoodgraygrimgurugushgyrohalfhanghardhawkheathelphighhillholyhopehornhutsicedideaidleinchinkyintoirisironitemjadejazzjoinjoltjowljudojugsjumpjunkjurykeepkenokeptkeyskickkilnkingkitekiwiknoblamblavalazyleaflegsliarlimplionlistlogoloudloveluaulucklungmainmanymathmazememomenumeowmildmintmissmonknailnavyneednewsnextnoonnotenumbobeyoboeomitonyxopenovalowlspaidpartpeckplaypluspoempoolposepuffpumapurrquadquizraceramprealredorichroadrockroofrubyruinrunsrustsafesagascarsetssilkskewslotsoapsolosongstubsurfswantacotasktaxitenttiedtimetinytoiltombtoystriptunatwinuglyundouniturgeuservastveryvetovialvibeviewvisavoidvowswallwandwarmwaspwavewaxywebswhatwhenwhizwolfworkyankyawnyellyogayurtzapszerozestzinczonezoom' +WORD_ARRAY = None + +def decode_word(word, word_len): + global WORD_ARRAY + global BYTEWORDS + + if len(word) != word_len: + raise ValueError('Invalid Bytewords.') + + dim = 26 + + # Since the first and last letters of each Byteword are unique, + # we can use them as indexes into a two-dimensional lookup table. + # This table is generated lazily. + if WORD_ARRAY == None: + WORD_ARRAY = [-1] * (dim * dim) # create empty array + + for i in range(256): + byteword_offset = i * 4 + x = ord(BYTEWORDS[byteword_offset]) - ord('a') + y = ord(BYTEWORDS[byteword_offset + 3]) - ord('a') + array_offset = y * dim + x + WORD_ARRAY[array_offset] = i + + # If the coordinates generated by the first and last letters are out of bounds, + # or the lookup table contains -1 at the coordinates, then the word is not valid. + x = ord(word[0].lower()) - ord('a') + y = ord((word[3 if len(word) == 4 else 1]).lower()) - ord('a') + if not (0 <= x and x < dim and 0 <= y and y < dim): + raise ValueError('Invalid Bytewords.') + + offset = y * dim + x + value = WORD_ARRAY[offset] + if value == -1: + raise ValueError('Invalid Bytewords.') + + # If we're decoding a full four-letter word, verify that the two middle letters are correct. + if len(word) == 4: + byteword_offset = value * 4 + c1 = word[1].lower() + c2 = word[2].lower() + if c1 != BYTEWORDS[byteword_offset + 1] or c2 != BYTEWORDS[byteword_offset + 2]: + raise ValueError('Invalid Bytewords.') + + # Successful decode. + return value + +def get_word(index): + byteword_offset = index * 4 + return BYTEWORDS[byteword_offset:byteword_offset + 4] + +def get_minimal_word(index): + byteword_offset = index * 4 + return BYTEWORDS[byteword_offset] + BYTEWORDS[byteword_offset + 3] + +def encode(buf, separator): + words = [] + for i in range(len(buf)): + byte = buf[i] + words.append(get_word(byte)) + + return separator.join(words) + +def add_crc(buf): + crc_buf = crc32_bytes(buf) + return buf + crc_buf + +def encode_with_separator(buf, separator): + crc_buf = add_crc(buf) + return encode(crc_buf, separator) + +def encode_minimal(buf): + result = '' + + crc_buf = add_crc(buf) + for i in range(len(crc_buf)): + byte = crc_buf[i] + result += get_minimal_word(byte) + + return result + +def decode(s, separator, word_len): + buf = bytearray() + + if word_len == 4: + words = s.split(separator) + else: + words = partition(s, 2) + + for word in words: + buf.append(decode_word(word, word_len)) + + if len(buf) < 5: + raise ValueError('Invalid Bytewords.') + + # Validate checksum + body = buf[0:-4] + body_checksum = buf[-4:] + checksum = crc32_bytes(body) + if checksum != body_checksum: + raise ValueError('Invalid Bytewords.') + + return body + +Bytewords_Style_standard = 1 +Bytewords_Style_uri = 2 +Bytewords_Style_minimal = 3 + +class Bytewords: + @staticmethod + def encode(style, bytes): + if style == Bytewords_Style_standard: + return encode_with_separator(bytes, ' ') + elif style == Bytewords_Style_uri: + return encode_with_separator(bytes, '-') + elif style == Bytewords_Style_minimal: + return encode_minimal(bytes) + else: + assert(False) + + @staticmethod + def decode(style, str): + if style == Bytewords_Style_standard: + return decode(str, ' ', 4) + elif style == Bytewords_Style_uri: + return decode(str, '-', 4) + elif style == Bytewords_Style_minimal: + return decode(str, 0, 2) + else: + assert(False) diff --git a/electrum/ur/cbor_lite.py b/electrum/ur/cbor_lite.py new file mode 100644 index 000000000000..ca4c109c9e12 --- /dev/null +++ b/electrum/ur/cbor_lite.py @@ -0,0 +1,316 @@ +# +# crc32.py +# +# Copyright © 2020 Foundation Devices, Inc. +# Licensed under the "BSD-2-Clause Plus Patent License" +# + +# From: https://bitbucket.org/isode/cbor-lite/raw/6c770624a97e3229e3f200be092c1b9c70a60ef1/include/cbor-lite/codec.h + +# This file is part of CBOR-lite which is copyright Isode Limited +# and others and released under a MIT license. For details, see the +# COPYRIGHT.md file in the top-level folder of the CBOR-lite software +# distribution. + +def bit_length(n): + return len(bin(abs(n))) - 2 + + +Flag_None = 0 +Flag_Require_Minimal_Encoding = 1 + +Tag_Major_unsignedInteger = 0 +Tag_Major_negativeInteger = 1 << 5 +Tag_Major_byteString = 2 << 5 +Tag_Major_textString = 3 << 5 +Tag_Major_array = 4 << 5 +Tag_Major_map = 5 << 5 +Tag_Major_semantic = 6 << 5 +Tag_Major_floatingPoint = 7 << 5 +Tag_Major_simple = 7 << 5 +Tag_Major_mask = 0xe0 + +Tag_Minor_length1 = 24 +Tag_Minor_length2 = 25 +Tag_Minor_length4 = 26 +Tag_Minor_length8 = 27 + +Tag_Minor_false = 20 +Tag_Minor_true = 21 +Tag_Minor_null = 22 +Tag_Minor_undefined = 23 +Tag_Minor_half_float = 25 +Tag_Minor_singleFloat = 26 +Tag_Minor_doubleFloat = 27 + +Tag_Minor_dateTime = 0 +Tag_Minor_epochDateTime = 1 +Tag_Minor_positiveBignum = 2 +Tag_Minor_negativeBignum = 3 +Tag_Minor_decimalFraction = 4 +Tag_Minor_bigFloat = 5 +Tag_Minor_convertBase64Url = 21 +Tag_Minor_convertBase64 = 22 +Tag_Minor_convertBase16 = 23 +Tag_Minor_cborEncodedData = 24 +Tag_Minor_uri = 32 +Tag_Minor_base64Url = 33 +Tag_Minor_base64 = 34 +Tag_Minor_regex = 35 +Tag_Minor_mimeMessage = 36 +Tag_Minor_selfDescribeCbor = 55799 +Tag_Minor_mask = 0x1f +Tag_Undefined = Tag_Major_semantic + Tag_Minor_undefined + + +def get_byte_length(value): + if value < 24: + return 0 + + return (bit_length(value) + 7) // 8 + +class CBOREncoder: + def __init__(self): + self.buf = bytearray() + + def get_bytes(self): + return self.buf + + def encodeTagAndAdditional(self, tag, additional): + self.buf.append(tag + additional) + return 1 + + def encodeTagAndValue(self, tag, value): + length = get_byte_length(value) + + # 5-8 bytes required, use 8 bytes + if length >= 5 and length <= 8: + self.encodeTagAndAdditional(tag, Tag_Minor_length8) + self.buf.append((value >> 56) & 0xff) + self.buf.append((value >> 48) & 0xff) + self.buf.append((value >> 40) & 0xff) + self.buf.append((value >> 32) & 0xff) + self.buf.append((value >> 24) & 0xff) + self.buf.append((value >> 16) & 0xff) + self.buf.append((value >> 8) & 0xff) + self.buf.append(value & 0xff) + + # 3-4 bytes required, use 4 bytes + elif length == 3 or length == 4: + self.encodeTagAndAdditional(tag, Tag_Minor_length4) + self.buf.append((value >> 24) & 0xff) + self.buf.append((value >> 16) & 0xff) + self.buf.append((value >> 8) & 0xff) + self.buf.append(value & 0xff) + + elif length == 2: + self.encodeTagAndAdditional(tag, Tag_Minor_length2) + self.buf.append((value >> 8) & 0xff) + self.buf.append(value & 0xff) + + elif length == 1: + self.encodeTagAndAdditional(tag, Tag_Minor_length1) + self.buf.append(value & 0xff) + + elif length == 0: + self.encodeTagAndAdditional(tag, value) + + else: + raise Exception("Unsupported byte length of {} for value in encodeTagAndValue()".format(length)) + + encoded_size = 1 + length + return encoded_size + + def encodeUnsigned(self, value): + return self.encodeTagAndValue(Tag_Major_unsignedInteger, value) + + def encodeNegative(self, value): + return self.encodeTagAndValue(Tag_Major_negativeInteger, value) + + def encodeInteger(self, value): + if value >= 0: + return self.encodeUnsigned(value) + else: + return self.encodeNegative(value) + + def encodeBool(self, value): + return self.encodeTagAndValue(Tag_Major_simple, Tag_Minor_true if value else Tag_Minor_false) + + def encodeBytes(self, value): + length = self.encodeTagAndValue(Tag_Major_byteString, len(value)) + self.buf += value + return length + len(value) + + def encodeEncodedBytesPrefix(self, value): + length = self.encodeTagAndValue(Tag_Major_semantic, Tag_Minor_cborEncodedData) + return length + self.encodeTagAndAdditional + + def encodeEncodedBytes(self, value): + length = self.encodeTagAndValue(Tag_Major_semantic, Tag_Minor_cborEncodedData) + return length + self.encodeBytes(value) + + def encodeText(self, value): + str_len = len(value) + length = self.encodeTagAndValue(Tag_Major_textString, str_len) + self.buf.append(bytes(value, 'utf8')) + return length + str_len + + def encodeArraySize(self, value): + return self.encodeTagAndValue(Tag_Major_array, value) + + def encodeMapSize(self, value): + return self.encodeTagAndValue(Tag_Major_map, value) + + +class CBORDecoder: + def __init__(self, buf): + self.buf = buf + self.pos = 0 + + def decodeTagAndAdditional(self, flags=Flag_None): + if self.pos == len(self.buf): + raise Exception("Not enough input") + octet = self.buf[self.pos] + self.pos += 1 + tag = octet & Tag_Major_mask + additional = octet & Tag_Minor_mask + return (tag, additional, 1) + + def decodeTagAndValue(self, flags): + end = len(self.buf) + + if self.pos == end: + raise Exception("Not enough input") + + (tag, additional, length) = self.decodeTagAndAdditional(flags) + if additional < Tag_Minor_length1: + value = additional + return (tag, value, length) + + value = 0 + if additional == Tag_Minor_length8: + if end - self.pos < 8: + raise Exception("Not enough input") + for shift in [56, 48, 40, 32, 24, 16, 8, 0]: + value |= self.buf[self.pos] << shift + self.pos += 1 + if ((flags & Flag_Require_Minimal_Encoding) and value == 0): + raise Exception("Encoding not minimal") + return (tag, value, self.pos) + elif additional == Tag_Minor_length4: + if end - self.pos < 4: + raise Exception("Not enough input") + for shift in [24, 16, 8, 0]: + value |= self.buf[self.pos] << shift + self.pos += 1 + if ((flags & Flag_Require_Minimal_Encoding) and value == 0): + raise Exception("Encoding not minimal") + return (tag, value, self.pos) + elif additional == Tag_Minor_length2: + if end - self.pos < 2: + raise Exception("Not enough input") + for shift in [8, 0]: + value |= self.buf[self.pos] << shift + self.pos += 1 + if ((flags & Flag_Require_Minimal_Encoding) and value == 0): + raise Exception("Encoding not minimal") + return (tag, value, self.pos) + elif additional == Tag_Minor_length1: + if end - self.pos < 1: + raise Exception("Not enough input") + value |= self.buf[self.pos] + self.pos += 1 + if ((flags & Flag_Require_Minimal_Encoding) and value == 0): + raise Exception("Encoding not minimal") + return (tag, value, self.pos) + + raise Exception("Bad additional value") + + def decodeUnsigned(self, flags=Flag_None): + (tag, value, length) = self.decodeTagAndValue(flags) + if tag != Tag_Major_unsignedInteger: + raise Exception("Expected Tag_Major_unsignedInteger ({}), but found {}".format(Tag_Major_unsignedInteger, tag)) + return (value, length) + + def decodeNegative(self, flags=Flag_None): + (tag, value, length) = self.decodeTagAndValue(flags) + if tag != Tag_Major_negativeInteger: + raise Exception("Expected Tag_Major_negativeInteger, but found {}".format(tag)) + return (value, length) + + def decodeInteger(self, flags=Flag_None): + (tag, value, length) = self.decodeTagAndValue(flags) + if tag == Tag_Major_unsignedInteger: + return (value, length) + elif tag == Tag_Major_negativeInteger: + return (-1 - value, length) # TODO: Check that this is the right way -- do we need to use struct.unpack()? + + def decodeBool(self, flags=Flag_None): + (tag, value, length) = self.decodeTagAndValue(flags) + if tag == Tag_Major_simple: + if value == Tag_Minor_true: + return (True, length) + elif value == Tag_Minor_false: + return (False, length) + raise Exception("Not a Boolean") + raise Exception("Not Simple/Boolean") + + def decodeBytes(self, flags=Flag_None): + # First value is the length of the bytes that follow + (tag, byte_length, size_length) = self.decodeTagAndValue(flags) + if tag != Tag_Major_byteString: + raise Exception("Not a byteString") + + end = len(self.buf) + if end - self.pos < byte_length: + raise Exception("Not enough input") + + value = bytes(self.buf[self.pos : self.pos + byte_length]) + self.pos += byte_length + return (value, size_length + byte_length) + + def decodeEncodedBytesPrefix(self, flags=Flag_None): + (tag, value, length1) = self.decodeTagAndValue(flags) + if tag != Tag_Major_semantic or value != Tag_Minor_cborEncodedData: + raise Exception("Not CBOR Encoded Data") + + (tag, value, length2) = self.decodeTagAndValue(flags) + if tag != Tag_Major_byteString: + raise Exception("Not byteString") + + return (tag, value, length1 + length2) + + def decodeEncodedBytes(self, flags=Flag_None): + (tag, minor_tag, tag_length) = self.decodeTagAndValue(flags) + if tag != Tag_Major_semantic or minor_tag != Tag_Minor_cborEncodedData: + raise Exception("Not CBOR Encoded Data") + + (value, length) = self.decodeBytes(flags) + return (value, tag_length + length) + + def decodeText(self, flags=Flag_None): + # First value is the length of the bytes that follow + (tag, byte_length, size_length) = self.decodeTagAndValue(flags) + if tag != Tag_Major_textString: + raise Exception("Not a textString") + + end = len(self.buf) + if end - self.pos < byte_length: + raise Exception("Not enough input") + + value = bytes(self.buf[self.pos : self.pos + byte_length]) + self.pos += byte_length + return (value, size_length + byte_length) + + def decodeArraySize(self, flags=Flag_None): + (tag, value, length) = self.decodeTagAndValue(flags) + + if tag != Tag_Major_array: + raise Exception("Expected Tag_Major_array, but found {}".format(tag)) + return (value, length) + + def decodeMapSize(self, flags=Flag_None): + (tag, value, length) = self.decodeTagAndValue(flags) + if tag != Tag_Major_mask: + raise Exception("Expected Tag_Major_map, but found {}".format(tag)) + return (value, length) diff --git a/electrum/ur/constants.py b/electrum/ur/constants.py new file mode 100644 index 000000000000..00a2265db77a --- /dev/null +++ b/electrum/ur/constants.py @@ -0,0 +1,9 @@ +# +# constants.py +# +# Copyright © 2020 Foundation Devices, Inc. +# Licensed under the "BSD-2-Clause Plus Patent License" +# + +MAX_UINT32 = 0xffffffff +MAX_UINT64 = 0xffffffffffffffff diff --git a/electrum/ur/crc32.py b/electrum/ur/crc32.py new file mode 100644 index 000000000000..7335eb7c1f67 --- /dev/null +++ b/electrum/ur/crc32.py @@ -0,0 +1,36 @@ +# +# crc32.py +# +# Copyright © 2020 Foundation Devices, Inc. +# Licensed under the "BSD-2-Clause Plus Patent License" +# + +from .constants import MAX_UINT32 + +def bit_length(n): + return len(bin(abs(n))) - 2 + +TABLE = None + +def crc32(buf): + # Lazily instantiate CRC table + global TABLE + if TABLE == None: + TABLE = [None] * (256 * 4) + + for i in range(256): + c = i + for j in range(8): + c = (c >> 1) if (c % 2 == 0) else (0xEDB88320 ^ (c >> 1)) + + TABLE[i] = c + + crc = MAX_UINT32 & ~0 + for byte in buf: + crc = (crc >> 8) ^ TABLE[(crc ^ byte) & 0xFF] + + return MAX_UINT32 & ~crc + +def crc32n(buf): + n = crc32(buf) + return n.to_bytes(4, 'big') diff --git a/electrum/ur/fountain_decoder.py b/electrum/ur/fountain_decoder.py new file mode 100644 index 000000000000..d9d5460832c7 --- /dev/null +++ b/electrum/ur/fountain_decoder.py @@ -0,0 +1,278 @@ +# +# fountain_decoder.py +# +# Copyright © 2020 Foundation Devices, Inc. +# Licensed under the "BSD-2-Clause Plus Patent License" +# + +from .fountain_utils import choose_fragments, contains, is_strict_subset, set_difference +from .utils import join_lists, join_bytes, crc32_int, xor_with, take_first + +class InvalidPart(Exception): + pass + +class InvalidChecksum(Exception): + pass + +class FountainDecoder: + class Part: + def __init__(self, indexes, data): + self.indexes = frozenset(indexes) + self.data = data + + @classmethod + def from_encoder_part(cls, p): + return cls(choose_fragments(p.seq_num, p.seq_len, p.checksum), p.data[:]) + + def indexes(self): + return self.indexes + + def data(self): + return self.data + + def is_simple(self): + return len(self.indexes) == 1 + + def index(self): + # TODO: Not efficient + return list(self.indexes)[0] + + # FountainDecoder + def __init__(self): + self.received_part_indexes = set() + self.last_part_indexes = None + self.processed_parts_count = 0 + self.result = None + self.expected_part_indexes = None + self.expected_fragment_len = None + self.expected_message_len = None + self.expected_checksum = None + self.simple_parts = {} + self.mixed_parts = {} + self.queued_parts = [] + + def expected_part_count(self): + return len(self.expected_part_indexes) # TODO: Handle None? + + def is_success(self): + result = self.result + return result if not isinstance(result, Exception) else False + + def is_failure(self): + result = self.result + return result if isinstance(result, Exception) else False + + def is_complete(self): + return self.result != None + + def result_message(self): + return self.result + + def result_error(self): + return self.result + + def estimated_percent_complete(self): + if self.is_complete(): + return 1 + if self.expected_part_indexes == None: + return 0 + estimated_input_parts = self.expected_part_count() * .7 + return min(0.99, len(self.received_part_indexes) / estimated_input_parts) + + def receive_part(self, encoder_part): + # Don't process the part if we're already done + if self.is_complete(): + return False + + # Don't continue if this part doesn't validate + if not self.validate_part(encoder_part): + return False + + # Add this part to the queue + p = FountainDecoder.Part.from_encoder_part(encoder_part) + self.last_part_indexes = p.indexes + self.enqueue(p) + + # Process the queue until we're done or the queue is empty + while not self.is_complete() and len(self.queued_parts) != 0: + self.process_queue_item() + + # Keep track of how many parts we've processed + self.processed_parts_count += 1 + + # self.print_part_end() + + return True + + # Join all the fragments of a message together, throwing away any padding + @staticmethod + def join_fragments(fragments, message_len): + message = join_bytes(fragments) + return take_first(message, message_len) + + def enqueue(self, p): + self.queued_parts.append(p) + + def process_queue_item(self): + part = self.queued_parts.pop(0) + # self.print_part(part) + + if part.is_simple(): + self.process_simple_part(part) + else: + self.process_mixed_part(part) + # self.print_state() + + def reduce_mixed_by(self, p): + # Reduce all the current mixed parts by the given part + reduced_parts = [] + for value in self.mixed_parts.values(): + reduced_parts.append(self.reduce_part_by_part(value, p)) + + # Collect all the remaining mixed parts + new_mixed = {} + for reduced_part in reduced_parts: + # If this reduced part is now simple + if reduced_part.is_simple(): + # Add it to the queue + self.enqueue(reduced_part) + else: + # Otherwise, add it to the dict of current mixed parts + new_mixed[reduced_part.indexes] = reduced_part + + self.mixed_parts = new_mixed + + def reduce_part_by_part(self, a, b): + # If the fragments mixed into `b` are a strict (proper) subset of those in `a`... + if is_strict_subset(b.indexes, a.indexes): + # The new fragments in the revised part are `a` - `b`. + new_indexes = set_difference(a.indexes, b.indexes) + # The new data in the revised part are `a` XOR `b` + new_data = xor_with(bytearray(a.data), b.data) + return self.Part(new_indexes, new_data) + else: + # `a` is not reducable by `b`, so return a + return a + + def process_simple_part(self, p): + # Don't process duplicate parts + fragment_index = p.index() + if contains(self.received_part_indexes, fragment_index): + return + + # Record this part + self.simple_parts[p.indexes] = p + self.received_part_indexes.add(fragment_index) + + # If we've received all the parts + if self.received_part_indexes == self.expected_part_indexes: + # Reassemble the message from its fragments + sorted_parts = [] + for value in self.simple_parts.values(): + sorted_parts.append(value) + + sorted_parts.sort(key=lambda a: a.index()) + + fragments = [] + for part in sorted_parts: + fragments.append(part.data) + + message = self.join_fragments(fragments, self.expected_message_len) + + # Verify the message checksum and note success or failure + checksum = crc32_int(message) + if(checksum == self.expected_checksum): + self.result = bytes(message) + else: + self.result = InvalidChecksum() + + else: + # Reduce all the mixed parts by this part + self.reduce_mixed_by(p) + + def process_mixed_part(self, p): + # Don't process duplicate parts + for r in self.mixed_parts.values(): + if r == p.indexes: + return + + # Reduce this part by all the others + p2 = p # TODO: Does this need to make a copy of p? + for r in self.simple_parts.values(): + p2 = self.reduce_part_by_part(p2, r) + + for r in self.mixed_parts.values(): + p2 = self.reduce_part_by_part(p2, r) + + # If the part is now simple + if p2.is_simple(): + # Add it to the queue + self.enqueue(p2) + else: + # Reduce all the mixed parts by this one + self.reduce_mixed_by(p2) + # Record this new mixed part + self.mixed_parts[p2.indexes] = p2 + + def validate_part(self, p): + # If this is the first part we've seen + if self.expected_part_indexes == None: + # Record the things that all the other parts we see will have to match to be valid. + self.expected_part_indexes = set() + for i in range(p.seq_len): + self.expected_part_indexes.add(i) + + self.expected_message_len = p.message_len + self.expected_checksum = p.checksum + self.expected_fragment_len = len(p.data) + else: + # If this part's values don't match the first part's values, throw away the part + if self.expected_part_count() != p.seq_len: + return False + if self.expected_message_len != p.message_len: + return False + if self.expected_checksum != p.checksum: + return False + if self.expected_fragment_len != len(p.data): + return False + + # This part should be processed + return True + + # debugging + def indexes_to_string(self, indexes): + i = list(indexes) + i.sort() + s = [str(j) for j in i] + return '[{}]'.format(', '.join(s)) + + def result_description(self): + if self.result == None: + return 'None' + + if self.is_success(): + return '{} bytes'.format(len(self.result)) + elif self.is_failure(): + return 'Exception: {}'.format(self.result) + else: + assert(False) + + def print_part(self, p): + print('part indexes: {}'.format(self.indexes_to_string(p.indexes))) + + def print_part_end(self): + expected = self.expected_part_count() if self.expected_part_indexes != None else 'None' + percent = int(round(self.estimated_percent_complete() * 100)) + print("processed: {}, expected: {}, received: {}, percent: {}%".format(self.processed_parts_count, expected, len(self.received_part_indexes), percent)) + + def print_state(self): + parts = self.expected_part_count() if self.expected_part_indexes != None else 'None' + received = self.indexes_to_string(self.received_part_indexes) + mixed = [] + for indexes, p in self.mixed_parts.items(): + mixed.append(self.indexes_to_string(indexes)) + + mixed_s = "[{}]".format(', '.join(mixed)) + queued = len(self.queued_parts) + res = self.result_description() + print('parts: {}, received: {}, mixed: {}, queued: {}, result: {}'.format(parts, received, mixed_s, queued, res)) diff --git a/electrum/ur/fountain_encoder.py b/electrum/ur/fountain_encoder.py new file mode 100644 index 000000000000..d539e460e17c --- /dev/null +++ b/electrum/ur/fountain_encoder.py @@ -0,0 +1,152 @@ +# +# fountain_encoder.py +# +# Copyright © 2020 Foundation Devices, Inc. +# Licensed under the "BSD-2-Clause Plus Patent License" +# + +import math +from .cbor_lite import CBORDecoder, CBOREncoder +from .fountain_utils import choose_fragments +from .utils import split, crc32_int, xor_into, data_to_hex +from .constants import MAX_UINT32, MAX_UINT64 + +class InvalidHeader(Exception): + pass + +class Part: + + def __init__(self, seq_num, seq_len, message_len, checksum, data): + self.seq_num = seq_num + self.seq_len = seq_len + self.message_len = message_len + self.checksum = checksum + self.data = data + + @staticmethod + def from_cbor(cbor_buf): + try: + decoder = CBORDecoder(cbor_buf) + (array_size, _) = decoder.decodeArraySize() + if array_size != 5: + raise InvalidHeader() + + (seq_num, _) = decoder.decodeUnsigned() + if seq_num > MAX_UINT64: # TODO: Do something better with this check + raise InvalidHeader() + + (seq_len, _) = decoder.decodeUnsigned() + if seq_len > MAX_UINT64: + raise InvalidHeader() + + (message_len, _) = decoder.decodeUnsigned() + if message_len > MAX_UINT64: + raise InvalidHeader() + + (checksum, _) = decoder.decodeUnsigned() + if checksum > MAX_UINT64: + raise InvalidHeader() + + (data, _) = decoder.decodeBytes() + + return Part(seq_num, seq_len, message_len, checksum, data) + except Exception as err: + raise InvalidHeader() + + def cbor(self): + encoder = CBOREncoder() + encoder.encodeArraySize(5) + encoder.encodeInteger(self.seq_num) + encoder.encodeInteger(self.seq_len) + encoder.encodeInteger(self.message_len) + encoder.encodeInteger(self.checksum) + encoder.encodeBytes(self.data) + return encoder.get_bytes() + + def seq_num(self): + return self.seq_num + + def seq_len(self): + return self.seq_len + + def message_len(self): + return self.message_len + + def checksum(self): + return self.checksum + + def data(self): + return self.data + + def description(self): + return "seqNum:{}, seqLen:{}, messageLen:{}, checksum:{}, data:{}".format( + self.seq_num, self.seq_len, self.message_len, self.checksum, data_to_hex(self.data)) + +class FountainEncoder: + def __init__(self, message, max_fragment_len, first_seq_num = 0, min_fragment_len = 10): + assert(len(message) <= MAX_UINT32) + self.message_len = len(message) + self.checksum = crc32_int(message) + self.fragment_len = FountainEncoder.find_nominal_fragment_length(self.message_len, min_fragment_len, max_fragment_len) + self.fragments = FountainEncoder.partition_message(message, self.fragment_len) + self.seq_num = first_seq_num + + @staticmethod + def find_nominal_fragment_length(message_len, min_fragment_len, max_fragment_len): + assert(message_len > 0) + assert(min_fragment_len > 0) + assert(max_fragment_len >= min_fragment_len) + max_fragment_count = message_len // min_fragment_len + fragment_len = None + + for fragment_count in range(1, max_fragment_count + 1): + fragment_len = math.ceil(message_len / fragment_count) + if fragment_len <= max_fragment_len: + break + + assert(fragment_len != None) + return fragment_len + + + @staticmethod + def partition_message(message, fragment_len): + remaining = message + fragments = [] + while len(remaining) != 0: + (fragment, remaining) = split(remaining, fragment_len) + padding = fragment_len - len(fragment) + while padding > 0: + fragment.append(0) + padding -= 1 + fragments.append(fragment) + + return fragments + + def last_part_indexes(self): + return self.last_part_indexes + + def seq_len(self): + return len(self.fragments) + + # This becomes `true` when the minimum number of parts + # to relay the complete message have been generated + def is_complete(self): + return self.seq_num >= self.seq_len() + + # True if only a single part will be generated. + def is_single_part(self): + return self.seq_len() == 1 + + def next_part(self): + self.seq_num += 1 + self.seq_num = self.seq_num % MAX_UINT32 # wrap at period 2^32 + indexes = choose_fragments(self.seq_num, self.seq_len(), self.checksum) + mixed = self.mix(indexes) + data = bytes(mixed) + return Part(self.seq_num, self.seq_len(), self.message_len, self.checksum, data) + + def mix(self, indexes): + result = [0] * self.fragment_len + for index in indexes: + xor_into(result, self.fragments[index]) + return result diff --git a/electrum/ur/fountain_utils.py b/electrum/ur/fountain_utils.py new file mode 100644 index 000000000000..6c7074e548ba --- /dev/null +++ b/electrum/ur/fountain_utils.py @@ -0,0 +1,55 @@ +# +# fountain_utils.py +# +# Copyright © 2020 Foundation Devices, Inc. +# Licensed under the "BSD-2-Clause Plus Patent License" +# + +from .random_sampler import RandomSampler +from .utils import int_to_bytes +from .xoshiro256 import Xoshiro256 + +# Fisher-Yates shuffle +def shuffled(items, rng): + remaining = items + result = [] + while len(remaining) > 0: + index = rng.next_int(0, len(remaining) - 1) + item = remaining.pop(index) + result.append(item) + + return result + +def choose_degree(seq_len, rng): + degree_probabilities = [] + for i in range(1, seq_len + 1): + degree_probabilities.append(1.0 / i) + + degree_chooser = RandomSampler(degree_probabilities) + return degree_chooser.next(lambda: rng.next_double()) + 1 + +def choose_fragments(seq_num, seq_len, checksum): + # The first `seq_len` parts are the "pure" fragments, not mixed with any + # others. This means that if you only generate the first `seq_len` parts, + # then you have all the parts you need to decode the message. + if seq_num <= seq_len: + return set([seq_num - 1]) + else: + seed = int_to_bytes(seq_num) + int_to_bytes(checksum) + rng = Xoshiro256.from_bytes(seed) + degree = choose_degree(seq_len, rng) + indexes = [] + + for i in range(seq_len): + indexes.append(i) + shuffled_indexes = shuffled(indexes, rng) + return set(shuffled_indexes[0:degree]) + +def contains(set_or_list, el): + return el in set_or_list + +def is_strict_subset(a, b): + return a.issubset(b) + +def set_difference(a, b): + return a.difference(b) \ No newline at end of file diff --git a/electrum/ur/random_sampler.py b/electrum/ur/random_sampler.py new file mode 100644 index 000000000000..087789e8e4d6 --- /dev/null +++ b/electrum/ur/random_sampler.py @@ -0,0 +1,65 @@ +# +# random_sampler.py +# +# Copyright © 2020 Foundation Devices, Inc. +# Licensed under the "BSD-2-Clause Plus Patent License" +# + +class RandomSampler: + + def __init__(self, probs): + for p in probs: + assert(p > 0) + + # Normalize given probabilities + total = sum(probs) + assert(total > 0) + + n = len(probs) + + P = [] + for p in probs: + P.append((p * float(n)) / total) + + S = [] + L = [] + + # Set separate index lists for small and large probabilities: + for i in reversed(range(0, n)): + # at variance from Schwarz, we reverse the index order + if P[i] < 1: + S.append(i) + else: + L.append(i) + + # Work through index lists + _probs = [0] * n + _aliases = [0] * n + + while len(S) > 0 and len(L) > 0: + a = S.pop() # Schwarz's l + g = L.pop() # Schwarz's g + _probs[a] = P[a] + _aliases[a] = g + P[g] += P[a] - 1 + if P[g] < 1: + S.append(g) + else: + L.append(g) + + while len(L) > 0: + _probs[L.pop()] = 1 + + while len(S) > 0: + # can only happen through numeric instability + _probs[S.pop()] = 1 + + self.probs = _probs + self.aliases = _aliases + + def next(self, rng_func): + r1 = rng_func() + r2 = rng_func() + n = len(self.probs) + i = int(float(n) * r1) + return i if r2 < self.probs[i] else self.aliases[i] diff --git a/electrum/ur/ur.py b/electrum/ur/ur.py new file mode 100644 index 000000000000..f48d3d698be7 --- /dev/null +++ b/electrum/ur/ur.py @@ -0,0 +1,26 @@ +# +# ur.py +# +# Copyright © 2020 Foundation Devices, Inc. +# Licensed under the "BSD-2-Clause Plus Patent License" +# + +from .utils import is_ur_type + +class InvalidType(Exception): + pass + +class UR: + + def __init__(self, type, cbor): + if not is_ur_type(type): + raise InvalidType() + + self.type = type + self.cbor = cbor + + def __eq__(self, obj): + if obj == None: + return False + return self.type == obj.type and self.cbor == obj.cbor + \ No newline at end of file diff --git a/electrum/ur/ur_decoder.py b/electrum/ur/ur_decoder.py new file mode 100644 index 000000000000..c0baf21f8954 --- /dev/null +++ b/electrum/ur/ur_decoder.py @@ -0,0 +1,176 @@ +# +# ur_decoder.py +# +# Copyright © 2020 Foundation Devices, Inc. +# Licensed under the "BSD-2-Clause Plus Patent License" +# + +from .ur import UR +from .fountain_encoder import FountainEncoder, Part as FountainEncoderPart +from .fountain_decoder import FountainDecoder +from .bytewords import * +from .utils import drop_first, is_ur_type + +class InvalidScheme(Exception): + pass + +class InvalidType(Exception): + pass + +class InvalidPathLength(Exception): + pass + +class InvalidSequenceComponent(Exception): + pass + +class InvalidFragment(Exception): + pass + +class URDecoder: + def __init__(self): + self.fountain_decoder = FountainDecoder() + self.expected_type = None + self.result = None + + @staticmethod + def decode(str): + (type, components) = URDecoder.parse(str) + if len(components) == 0: + raise InvalidPathLength() + + body = components[0] + return URDecoder.decode_by_type(type, body) + + @staticmethod + def decode_by_type(type, body): + cbor = Bytewords.decode(Bytewords_Style_minimal, body) + return UR(type, cbor) + + @staticmethod + def parse(str): + # Don't consider case + lowered = str.lower() + + # Validate URI scheme + if not lowered.startswith('ur:'): + raise InvalidScheme() + + path = drop_first(lowered, 3) + + # Split the remainder into path components + components = path.split('/') + + # Make sure there are at least two path components + if len(components) < 2: + raise InvalidPathLength() + + # Validate the type + type = components[0] + if not is_ur_type(type): + raise InvalidType() + + comps = components[1:] # Don't include the ur type + return (type, comps) + + @staticmethod + def parse_sequence_component(str): + try: + comps = str.split('-') + if len(comps) != 2: + raise InvalidSequenceComponent() + seq_num = int(comps[0]) + seq_len = int(comps[1]) + if seq_num < 1 or seq_len < 1: + raise InvalidSequenceComponent() + return (seq_num, seq_len) + except: + raise InvalidSequenceComponent() + + def validate_part(self, type): + if self.expected_type == None: + if not is_ur_type(type): + return False + self.expected_type = type + return True + else: + return type == self.expected_type + + def receive_part(self, str): + try: + # Don't process the part if we're already done + if self.result != None: + return False + + # Don't continue if this part doesn't validate + (type, components) = URDecoder.parse(str) + if not self.validate_part(type): + return False + + # If this is a single-part UR then we're done + if len(components) == 1: + body = components[0] + self.result = self.decode_by_type(type, body) + return True + + # Multi-part URs must have two path components: seq/fragment + if len(components) != 2: + raise InvalidPathLength() + seq = components[0] + fragment = components[1] + + # Parse the sequence component and the fragment, and make sure they agree. + (seq_num, seq_len) = URDecoder.parse_sequence_component(seq) + cbor = Bytewords.decode(Bytewords_Style_minimal, fragment) + part = FountainEncoderPart.from_cbor(cbor) + if seq_num != part.seq_num or seq_len != part.seq_len: + return False + + # Process the part + if not self.fountain_decoder.receive_part(part): + return False + + if self.fountain_decoder.is_success(): + self.result = UR(type, self.fountain_decoder.result_message()) + elif self.fountain_decoder.is_failure(): + self.result = self.fountain_decoder.result_error() + + return True + except Exception as err: + return False + + def expected_type(self): + return self.expected_type + + def expected_part_count(self): + return self.fountain_decoder.expected_part_count() + + def received_part_indexes(self): + return self.fountain_decoder.received_part_indexes + + def last_part_indexes(self): + return self.fountain_decoder.last_part_indexes + + def processed_parts_count(self): + return self.fountain_decoder.processed_parts_count + + def estimated_percent_complete(self): + return self.fountain_decoder.estimated_percent_complete() + + def is_success(self): + result = self.result + return result if not isinstance(result, Exception) else False + + def is_failure(self): + result = self.result + return result if isinstance(result, Exception) else False + + def is_complete(self): + return self.result != None + + def result_message(self): + return self.result + + def result_error(self): + return self.result + + \ No newline at end of file diff --git a/electrum/ur/ur_encoder.py b/electrum/ur/ur_encoder.py new file mode 100644 index 000000000000..ac1cbc7d0fa3 --- /dev/null +++ b/electrum/ur/ur_encoder.py @@ -0,0 +1,58 @@ +# +# ur_encoder.py +# +# Copyright © 2020 Foundation Devices, Inc. +# Licensed under the "BSD-2-Clause Plus Patent License" +# + +from .fountain_encoder import FountainEncoder +from .bytewords import * + +class UREncoder: + # Start encoding a (possibly) multi-part UR. + def __init__(self, ur, max_fragment_len, first_seq_num = 0, min_fragment_len = 10): + self.ur = ur + self.fountain_encoder = FountainEncoder(ur.cbor, max_fragment_len, first_seq_num, min_fragment_len) + + # Encode a single-part UR. + @staticmethod + def encode(ur): + body = Bytewords.encode(Bytewords_Style_minimal, ur.cbor) + return UREncoder.encode_ur([ur.type, body]) + + def last_part_indexes(self): + return self.fountain_encoder.last_part_indexes() + + # `True` if the minimal number of parts to transmit the message have been + # generated. Parts generated when this is `true` will be fountain codes + # containing various mixes of the part data. + def is_complete(self): + return self.fountain_encoder.is_complete() + + # `True` if this UR can be contained in a single part. If `True`, repeated + # calls to `next_part()` will all return the same single-part UR. + def is_single_part(self): + return self.fountain_encoder.is_single_part() + + def next_part(self): + part = self.fountain_encoder.next_part() + if self.is_single_part(): + return UREncoder.encode(self.ur) + else: + return UREncoder.encode_part(self.ur.type, part) + + @staticmethod + def encode_part(type, part): + seq = '{}-{}'.format(part.seq_num, part.seq_len) + body = Bytewords.encode(Bytewords_Style_minimal, part.cbor()) + result = UREncoder.encode_ur([type, seq, body]) + return result + + @staticmethod + def encode_uri(scheme, path_components): + path = '/'.join(path_components) + return ':'.join([scheme, path]) + + @staticmethod + def encode_ur(path_components): + return UREncoder.encode_uri('ur', path_components) diff --git a/electrum/ur/utils.py b/electrum/ur/utils.py new file mode 100644 index 000000000000..4e88c363d9ce --- /dev/null +++ b/electrum/ur/utils.py @@ -0,0 +1,73 @@ +# +# utils.py +# +# Copyright © 2020 Foundation Devices, Inc. +# Licensed under the "BSD-2-Clause Plus Patent License" +# + +from .crc32 import crc32, crc32n + +def crc32_bytes(buf): + checksum = crc32n(buf) + return checksum + +def crc32_int(buf): + return crc32(buf) + +def data_to_hex(buf): + return ''.join('{:02x}'.format(x) for x in buf) + +def int_to_bytes(n): + # return n.to_bytes((n.bit_length() + 7) // 8, 'big') + return n.to_bytes(4, 'big') + +def bytes_to_int(buf): + return int.from_bytes(buf, 'big') + +def string_to_bytes(s): + return bytes(s, 'utf8') + +def is_ur_type(ch): + if 'a' <= ch and ch <= 'z': + return True + if '0' <= ch and ch <= '9': + return True + if ch == '-': + return True + return False + +def partition(s, n): + return [s[i:i+n] for i in range(0, len(s), n)] + +# Split the given sequence into two parts returned in a tuple +# The first entry in the tuple has the first `count` values. +# The second entry in the tuple has the remaining values. +def split(buf, count): + return (buf[0:count], buf[count:]) + +def join_lists(lists): + # return [y for x in lists for y in x] + return sum(lists, []) + +def join_bytes(list_of_ba): + out = bytearray() + for ba in list_of_ba: + out.extend(ba) + return out + +def xor_into(target, source): + count = len(target) + assert(count == len(source)) # Must be the same length + for i in range(count): + target[i] ^= source[i] + +def xor_with(a, b): + target = a + xor_into(target, b) + return target + +def take_first(s, count): + return s[0:count] + +def drop_first(s, count): + return s[count:] diff --git a/electrum/ur/xoshiro256.py b/electrum/ur/xoshiro256.py new file mode 100644 index 000000000000..5ddb481c4e3f --- /dev/null +++ b/electrum/ur/xoshiro256.py @@ -0,0 +1,169 @@ +# +# xoshiro256.py +# +# Copyright © 2020 Foundation Devices, Inc. +# Licensed under the "BSD-2-Clause Plus Patent License" +# + +import sys +try: + import uhashlib as hashlib +except: + try: + import hashlib + except: + sys.exit("ERROR: No hashlib or uhashlib implementation found (required for sha256)") + +from .utils import string_to_bytes, int_to_bytes +from .constants import MAX_UINT64 + +# Original Info: +# Written in 2018 by David Blackman and Sebastiano Vigna (vigna@acm.org) + +# To the extent possible under law, the author has dedicated all copyright +# and related and neighboring rights to this software to the public domain +# worldwide. This software is distributed without any warranty. + +# See . + +# This is xoshiro256** 1.0, one of our all-purpose, rock-solid +# generators. It has excellent (sub-ns) speed, a state (256 bits) that is +# large enough for any parallel application, and it passes all tests we +# are aware of. + +# For generating just floating-point numbers, xoshiro256+ is even faster. + +# The state must be seeded so that it is not everywhere zero. If you have +# a 64-bit seed, we suggest to seed a splitmix64 generator and use its +# output to fill s. + +def rotl(x, k): + return ((x << k) | (x >> (64 - k))) & MAX_UINT64 + +JUMP = [ 0x180ec6d33cfd0aba, 0xd5a61266f0c9392c, 0xa9582618e03fc9aa, 0x39abdc4529b1661c ] +LONG_JUMP = [ 0x76e15d3efefdcbbf, 0xc5004e441c522fb3, 0x77710069854ee241, 0x39109bb02acbe635 ] + +class Xoshiro256: + def __init__(self, arr = None): + self.s = [0] * 4 + if arr != None: + self.s[0] = arr[0] + self.s[1] = arr[1] + self.s[2] = arr[2] + self.s[3] = arr[3] + + + def _set_s(self, arr): + for i in range(4): + o = i * 8 + v = 0 + for n in range(8): + v <<= 8 + v |= (arr[o + n]) + self.s[i] = v + + def _hash_then_set_s(self, buf): + m = hashlib.sha256() + m.update(buf) + digest = m.digest() + self._set_s(digest) + + @classmethod + def from_int8_array(cls, arr): + x = Xoshiro256() + x._set_s(arr) + return x + + @classmethod + def from_bytes(cls, buf): + x = Xoshiro256() + x._hash_then_set_s(buf) + return x + + @classmethod + def from_crc32(cls, crc32): + x = Xoshiro256() + buf = int_to_bytes(crc32) + x._hash_then_set_s(buf) + return x + + @classmethod + def from_string(cls, s): + x = Xoshiro256() + buf = string_to_bytes(s) + x._hash_then_set_s(buf) + return x + + def next(self): + result = (rotl((self.s[1] * 5) & MAX_UINT64, 7) * 9) & MAX_UINT64 + t = (self.s[1] << 17) & MAX_UINT64 + + self.s[2] ^= self.s[0] + self.s[3] ^= self.s[1] + self.s[1] ^= self.s[2] + self.s[0] ^= self.s[3] + + self.s[2] ^= t + + self.s[3] = rotl(self.s[3], 45) & MAX_UINT64 + + return result + + def next_double(self): + m = float(MAX_UINT64) + 1 + nxt = self.next() + return nxt / m + + def next_int(self, low, high): + return int(self.next_double() * (high - low + 1) + low) & MAX_UINT64 + + def next_byte(self): + return self.next_int(0, 255) + + def next_data(self, count): + result = bytearray() + for i in range(count): + result.append(self.next_byte()) + return result + + def jump(self): + global JUMP + + s0 = 0 + s1 = 0 + s2 = 0 + s3 = 0 + for i in range(len(JUMP)): + for b in range(64): + if JUMP[i] & (1 << b): + s0 ^= self.s[0] + s1 ^= self.s[1] + s2 ^= self.s[2] + s3 ^= self.s[3] + self.next() + + self.s[0] = s0 + self.s[1] = s1 + self.s[2] = s2 + self.s[3] = s3 + + def long_jump(self): + global LONG_JUMP + + s0 = 0 + s1 = 0 + s2 = 0 + s3 = 0 + for i in range(len(LONG_JUMP)): + for b in range(64): + if LONG_JUMP[i] & (1 << b): + s0 ^= self.s[0] + s1 ^= self.s[1] + s2 ^= self.s[2] + s3 ^= self.s[3] + self.next() + + self.s[0] = s0 + self.s[1] = s1 + self.s[2] = s2 + self.s[3] = s3