From 24bfca106a683537f9d9398a12f4e74bd4e4b153 Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Mon, 3 Jun 2024 14:07:40 -0400 Subject: [PATCH] first pass NFC pushtx --- docs/nfc-pushtx.md | 76 +++++++++++++++++++++++++++++++++++ releases/Next-ChangeLog.md | 2 + shared/actions.py | 14 +++++-- shared/auth.py | 13 +++++- shared/lcd_display.py | 2 +- shared/nfc.py | 39 +++++++++++++++++- shared/utils.py | 2 +- stm32/COLDCARD_Q1/file_time.c | 6 +-- 8 files changed, 142 insertions(+), 12 deletions(-) create mode 100644 docs/nfc-pushtx.md diff --git a/docs/nfc-pushtx.md b/docs/nfc-pushtx.md new file mode 100644 index 00000000..d77a2287 --- /dev/null +++ b/docs/nfc-pushtx.md @@ -0,0 +1,76 @@ +# NFC Push Tx + +This feature allows single-tap broadcast of the freshly-signed transaction. + +Once enabled with a URL, the COLDCARD will show the NFC animation +after signing the transaction. When the user taps their phone, the +phone will see an NFC tag with URL inside. That URL contains the +signed transaction ready to go, and once opening in the mobile +browser, that URL will load. The landing page will connect to a +Bitcoin node (or similar) and send the transaction on the public +Bitcoin network. + +This feature is available on Q and Mk4 and requires NFC to be enabled. +See `Advanced/Tools > NFC Push Tx` + +## Protocol Spec + +The COLDCARD needs a URL prefix. To that it appends some values: + +- `t=...` + - this is the transaction, in binary encoded with + [base64url](https://datatracker.ietf.org/doc/html/rfc4648#section-5) + +- `&c=...` + - the rightmost 8 bytes of SHA256 over the transaction. Also `base64url` encoded. + +- `&n=XTN` + - if, and only if, the COLDCARD is set for Testnet, this value is appended to + indicate that the transaction is for Testnet3 and not MainNet. + - when RegTest is enabled, the value will be `XRT` + +We provide a few default URL values to our customers, including one backend we +will operate. The URL can also be directly entered by the customer. On the Q, +it can be scanned from a QR code. + +For COLDCARD backend, the url used is: + + https://coldcard.com/pushtx# + +The complete URL with a typical transaction might look like this (but longer): + + https://coldcard.com/pushtx#t=AgAAAAMNCxXtp2GVYVhkRXHLMmdZFs4p3kbFK ⋯ ABf&c=uiSVRda-1tw + +We are using hash symbol here so that our server logs do not get +contaminated with the arguments. The landing page uses javascript +to read the hash part of the URL and decodes from there. If you +prefer, your URL can end with `?` and then the arguments will be +sent by the phone's browser to your server. Your processing can be +entirely done in the backend in this case. + +## Expectations for the Backend + +Your code should decode the transaction and check the SHA-256 hash +matches. If it does not match, or if `c` value is missing, assume +the URL has been truncated and report that to the user. + +Once decoded, your code should immediately broadcast the transaction. +A confirmation step is not required in our opinion. Once it is +submitted to Bitcoin Core (or other API), any status response should +be decoded and shown to the user so they know it is on it's way. +If it was not accepted, please report the error to the user as +clearly as possible. + +Next, it would make sense to either link to the TXID on a block +explorer to provide further proof that it has been sent and that +it is now waiting in the mempool. + +### Notes + +- Complete URL might be as large as 8,000 bytes. Some web servers will not support beyond + 4k bytes and the NFC implementation of the phone may also have limits. +- The service URL provided must end in `?` or `#` or `&`. +- `base64url` values from COLDCARD will not have padding (`=` bytes) at end. +- Honest backends will not log the IP address of incoming transactions, but there is + no way to enforce that, and CloudFlare sees all. + diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md index c76ed518..86468dab 100644 --- a/releases/Next-ChangeLog.md +++ b/releases/Next-ChangeLog.md @@ -4,6 +4,8 @@ This lists the new changes that have not yet been published in a normal release. # Shared Improvements - Both Mk4 and Q +- New Feature: PushTX: when enabled with a service provider's URL, you can tap the COLDCARD + after signing with your phone and the transaction will be broadcast directly. - Enhancement: Stricter p2sh-p2wpkh validation - Enhancement: mention the need to remove old duress wallets before locking down temporary seed - Bugfix: Fix PSBTv2 `PSBT_GLOBAL_TX_MODIFIABLE` parsing diff --git a/shared/actions.py b/shared/actions.py index 2edc40fa..246dd8f0 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -2155,8 +2155,8 @@ async def _scan_any_qr(expect_secret=False, tmp=False): PUSHTX_SUPPLIERS = [ # (label, URL) - ('coldcard.com', 'https://coldcard.com/pushtx?' ), - ('mempool.space', 'https://mempool.space/tx/push?tx=' ), + ('coldcard.com', 'https://coldcard.com/pushtx#' ), + ('mempool.space', 'https://mempool.space/tx/push?' ), ] async def pushtx_setup_menu(*a): @@ -2203,8 +2203,14 @@ async def pushtx_setup_menu(*a): while 1: nv = await ux_input_text(val, confirm_exit=True, scan_ok=True, prompt="Enter URL") # cleanup? URL validation? - if nv and ('http://' not in nv and 'https://' not in nv) or len(nv) < 12: - await ux_show_story("Must start with http:// or https://. Try again") + if nv: + if ('http://' not in nv and 'https://' not in nv): + prob = "Must start with http:// or https://." + elif len(nv) < 12: + prob = "Too short." + if nv[-1] not in '#?&': + prob = "Final char must be # or ? or &." + await ux_show_story(prob + " Try again.") val = nv continue break diff --git a/shared/auth.py b/shared/auth.py index 73cfa018..3b0f30e6 100644 --- a/shared/auth.py +++ b/shared/auth.py @@ -806,9 +806,20 @@ class ApproveTransaction(UserAuthorizedAction): except BaseException as exc: return await self.failure("PSBT output failed", exc) - from glob import NFC + from glob import NFC, settings if self.do_finalize and txid and not hsm_active: + + # if NFC PushTx is enabled, do that w/o questions. + url = settings.get('ptxurl', False) + if NFC and url: + try: + await NFC.share_push_tx(url, txid, TXN_OUTPUT_OFFSET, self.result[0], self.result[1]) + return + except: + # continue normally if it fails, perhaps too big? + pass + kq, kn = "(1)", "(3)" if version.has_qwerty: kq, kn = KEY_QR, KEY_NFC diff --git a/shared/lcd_display.py b/shared/lcd_display.py index df5ac11a..0e3722a1 100644 --- a/shared/lcd_display.py +++ b/shared/lcd_display.py @@ -235,7 +235,7 @@ class Display: ch = (h+CELL_H) // CELL_H #print('pixel %dx%d @ (%d,%d) => %dx%d @ (%d,%d)' % (w, h, px,py, cw, ch, cx,cy)) - for y in range(cy, cy+ch+1): + for y in range(cy, cy+ch): for x in range(cx, cx+cw+1): try: self.last_buf[y][x] = self.next_buf[y][x] = 0xfffe diff --git a/shared/nfc.py b/shared/nfc.py index 85572873..e2abb386 100644 --- a/shared/nfc.py +++ b/shared/nfc.py @@ -239,6 +239,39 @@ class NFCHandler: return await self.share_start(n) + async def share_push_tx(self, url, txid, file_offset, txn_len, txn_sha): + # Given a signed TXN, we convert to URL which a web backend can broadcast directly + # - using base64url encoding + # - just appends to provided URL + # - keeps showing it until they press CANCEL + # + if (txn_len * 1.4) >= MAX_NFC_SIZE: + raise ValueError('too big') + + from glob import PSRAM + from utils import b2a_base64url + from chains import current_chain + + is_https = url.startswith('https://') + if is_https: + url = url[8:] + + url += 't=' + b2a_base64url(PSRAM.read_at(file_offset, txn_len)) \ + + '&c=' + b2a_base64url(txn_sha[-8:]) + + ch = current_chain() + if ch.ctype != 'BTC': + url += '&n=' + ch.ctype # XTN or XRT + + n = ndef.ndefMaker() + n.add_url(url, https=is_https) + + while 1: + done = await self.share_start(n, prompt="Tap to broadcast, CANCEL when done", + line2="Signed TXID: %s⋯%s" % (txid[0:8], txid[-8:])) + + if done: break + async def share_psbt(self, file_offset, psbt_len, psbt_sha, label=None): # we just signed something, share it over NFC if psbt_len >= MAX_NFC_SIZE: @@ -297,7 +330,7 @@ class NFCHandler: self.write_dyn(GPO_CTRL_Dyn, 0x01) # GPO_EN self.read_dyn(IT_STS_Dyn) # clear interrupt - async def ux_animation(self, write_mode, allow_enter=True): + async def ux_animation(self, write_mode, allow_enter=True, prompt=None, line2=None): # Run the pretty animation, and detect both when we are written, and/or key to exit/abort. # - similar when "read" and then removed from field # - return T if aborted by user @@ -309,7 +342,9 @@ class NFCHandler: if dis.has_lcd: dis.real_clear() # bugfix - dis.text(None, -2, 'Tap phone to screen, or CANCEL.', dark=True) + dis.text(None, -2, prompt or 'Tap phone to screen, or CANCEL.', dark=True) + if line2: + dis.text(None, -3, line2) else: from graphics_mk4 import Graphics frames = [getattr(Graphics, 'mk4_nfc_%d'%i) for i in range(1, 5)] diff --git a/shared/utils.py b/shared/utils.py index e37c3a10..3ffbc5f3 100644 --- a/shared/utils.py +++ b/shared/utils.py @@ -180,7 +180,7 @@ class Base64Writer: def b2a_base64url(s): # see - return b2a_base64(s).rstrip(b'=\n').replace(b'+', b'-').replace(b'/', b'_') + return b2a_base64(s).rstrip(b'=\n').replace(b'+', b'-').replace(b'/', b'_').decode() def swab32(n): # endian swap: 32 bits diff --git a/stm32/COLDCARD_Q1/file_time.c b/stm32/COLDCARD_Q1/file_time.c index 75a29fe6..40410770 100644 --- a/stm32/COLDCARD_Q1/file_time.c +++ b/stm32/COLDCARD_Q1/file_time.c @@ -2,12 +2,12 @@ // // AUTO-generated. // -// built: 2024-05-08 -// version: 1.2.1Q +// built: 2024-06-03 +// version: 1.2.2Q // #include // this overrides ports/stm32/fatfs_port.c uint32_t get_fattime(void) { - return 0x58a80840UL; + return 0x58c30840UL; }