diff --git a/README.md b/README.md index 4e3fd89..1104231 100644 --- a/README.md +++ b/README.md @@ -98,11 +98,15 @@ Usage: ckcc msg [OPTIONS] MESSAGE Sign a short text message Options: - -p, --path DERIVATION Derivation for key to use [default: m/44'/0'/0'/0/0] + -p, --path DERIVATION Derivation for key to use -v, --verbose Include fancy ascii armour -j, --just-sig Just the signature itself, nothing more -s, --segwit Address in segwit native (p2wpkh, bech32) - -w, --wrap Address in segwit wrapped in P2SH (p2wpkh) + -w, --wrap Address in segwit wrapped in P2SH (p2sh-p2wpkh) + --in-file If present, argument MESSAGE is treated as path to + input file containing message to be signed as defined + in https://coldcard.com/docs/message-signing/#text-file-format. + -o, --output FILENAME Output file (default stdout) --help Show this message and exit. % ckcc msg "Hello Coldcard" -p m/34/23/33 @@ -110,6 +114,9 @@ Waiting for OK on the Coldcard... Hello Coldcard 1KSXaNHh3G4sfTMsp9q8CmACeqsJn46drd H4mTuwMUdnu3MyMA+6aJ3hiAF4L0WBDZFseTEno511hNN8/THIeM4GW4SnrcJJhS3WxMZEWFdEIZDSP+H5aIcao= + +# to sign message in file test.json and output RFC armored signature to test-signed.txt +% ckcc msg test.json --in-file -v -o test-signed.txt ``` diff --git a/ckcc/cli.py b/ckcc/cli.py index 5138d25..3e89438 100755 --- a/ckcc/cli.py +++ b/ckcc/cli.py @@ -417,137 +417,57 @@ def run_eval(stmt): @click.option('--just-sig', '-j', is_flag=True, help='Just the signature itself, nothing more') @click.option('--segwit', '-s', is_flag=True, help='Address in segwit native (p2wpkh, bech32)') @click.option('--wrap', '-w', is_flag=True, help='Address in segwit wrapped in P2SH (p2sh-p2wpkh)') -def sign_message(message, path, verbose, just_sig, wrap, segwit): +@click.option('--in-file', is_flag=True, + help='If present, argument MESSAGE is treated as path to input file containing message' + ' to be signed as defined in https://coldcard.com/docs/message-signing/#text-file-format.') +@click.option("-o", "--output", type=click.File("w"), default="-", help="Output file (default stdout)") +def sign_message(message, path, verbose, just_sig, wrap, segwit, in_file, output): """Sign a short text message""" - with get_device() as dev: - - addr_fmt, af_path = addr_fmt_help(dev, wrap, segwit) - - # NOTE: initial version of firmware not expected to do segwit stuff right, since - # standard very much still in flux, see: - - # not enforcing policy here on msg contents, so we can define that on product - message = message.encode('ascii') if not isinstance(message, bytes) else message - - ok = dev.send_recv(CCProtocolPacker.sign_message(message, path or af_path, addr_fmt), timeout=None) - assert ok == None - - print("Waiting for OK on the Coldcard...", end='', file=sys.stderr) - sys.stderr.flush() - - while 1: - time.sleep(0.250) - done = dev.send_recv(CCProtocolPacker.get_signed_msg(), timeout=None) - if done == None: - continue - - break - print("\r \r", end='', file=sys.stderr) - sys.stderr.flush() - - if len(done) != 2: - click.echo('Failed: %r' % done) + addr_fmt = None + if in_file: + # message is treated as path to input file + if not os.path.exists(message): + click.echo("Error: file does not exist", err=True) sys.exit(1) - addr, raw = done - - sig = str(b64encode(raw), 'ascii').replace('\n', '') - - if just_sig: - click.echo(str(sig)) - elif verbose: - click.echo(RFC_SIGNATURE_TEMPLATE.format(msg=message.decode('ascii'), - addr=addr, sig=sig)) - else: - click.echo('%s\n%s\n%s' % (message.decode('ascii'), addr, sig)) - + with open(message, 'r') as f: + raw = f.read() -@main.command('msg-file') -@click.argument('json_file', type=click.Path(exists=True, dir_okay=False), metavar="FILE_PATH") -@click.option('--outfile', '-o', type=click.Path(), default=None, - help="Output file path for signed result (default: -signed.)") -@click.option('--outdir', '-d', type=click.Path(exists=True, dir_okay=True, file_okay=False), - default=None, help="Directory to save signed file (default: same as input)") -def sign_msg_file(json_file, outfile, outdir): - """ - Sign a text file containing a message (TXT or JSON format). - - Reads a JSON or TXT file, extracts the message to sign, sends it to the Coldcard - for signing, and writes the result as an RFC2440-like signed message file. - - For JSON files, the file must contain a 'msg' field. The 'subpath' and 'addr_fmt' - fields are optional: - - {"msg":"this address belongs to me","subpath":"m/84h/0h/0h/0/120"} - - For TXT files, the first line is the message. The optional second line specifies - the subkey derivation path. The optional third line specifies the address format - (p2pkh, p2sh-p2wpkh, or p2wpkh). - - The signed output file is saved with '-signed' inserted before the file extension. - """ - - basename = os.path.basename(json_file) - name, ext = os.path.splitext(basename) - - if '-signed' in name: - click.echo("Error: filename cannot contain '-signed'") - sys.exit(1) - - raw = open(json_file, 'r').read() - if len(raw.encode('utf-8')) > 500: #CC Spec: "Be less than 500 bytes in size" - click.echo("Error: file must be less than 500 bytes") - sys.exit(1) - - # parse file contents - message = None - subpath = None - addr_fmt_str = None - - try: - parsed = json.loads(raw) - if isinstance(parsed, dict): - if 'msg' not in parsed: - click.echo("Error: JSON must contain 'msg' field") + try: + parsed = json.loads(raw) + if isinstance(parsed, dict): + if 'msg' not in parsed: + click.echo("Error: JSON must contain 'msg' field", err=True) + sys.exit(1) + message = parsed['msg'] + path = parsed.get('subpath', None) + addr_fmt = parsed.get('addr_fmt', None) + else: + raise ValueError + except (json.JSONDecodeError, ValueError): + lines = raw.strip().split('\n') + if not lines or not lines[0].strip(): + click.echo("Error: file is empty or has no message", err=True) sys.exit(1) - message = parsed['msg'] - subpath = parsed.get('subpath', None) - addr_fmt_str = parsed.get('addr_fmt', None) - else: - raise ValueError("not a JSON object") - except (json.JSONDecodeError, ValueError): - lines = raw.strip().split('\n') - if not lines or not lines[0].strip(): - click.echo("Error: file is empty or has no message") - sys.exit(1) - message = lines[0].strip() - if len(lines) >= 2 and lines[1].strip(): - subpath = lines[1].strip() - if len(lines) >= 3 and lines[2].strip(): - addr_fmt_str = lines[2].strip() - - wrap = False - segwit = False - if addr_fmt_str: - af = addr_fmt_str.lower().replace('-', '').replace('_', '') - if af in ('p2shp2wpkh', 'p2sh_p2wpkh', 'p2shp2wpkh'): - wrap = True - elif af in ('p2wpkh', 'segwit', 'bech32'): - segwit = True - elif af not in ('p2pkh', 'classic', ''): - click.echo("Warning: unknown addr_fmt '%s', using default (p2pkh)" % addr_fmt_str, - err=True) + message = lines[0].strip() + if len(lines) >= 2 and lines[1].strip(): + path = lines[1].strip() + if len(lines) >= 3 and lines[2].strip(): + addr_fmt = lines[2].strip() + with get_device() as dev: - addr_fmt, af_path = addr_fmt_help(dev, wrap, segwit) - signing_path = subpath or af_path - msg_bytes = message.encode('ascii') if not isinstance(message, bytes) else message + addr_fmt, af_path = addr_fmt_help(dev, wrap, segwit, addr_fmt=addr_fmt) - ok = dev.send_recv(CCProtocolPacker.sign_message(msg_bytes, signing_path, addr_fmt), - timeout=None) - assert ok == None + # NOTE: initial version of firmware not expected to do segwit stuff right, since + # standard very much still in flux, see: + + # not enforcing policy here on msg contents, so we can define that on product + ok = dev.send_recv(CCProtocolPacker.sign_message(message.encode('ascii'), path or af_path, + addr_fmt), timeout=None) + assert ok is None print("Waiting for OK on the Coldcard...", end='', file=sys.stderr) sys.stderr.flush() @@ -555,8 +475,9 @@ def sign_msg_file(json_file, outfile, outdir): while 1: time.sleep(0.250) done = dev.send_recv(CCProtocolPacker.get_signed_msg(), timeout=None) - if done == None: + if done is None: continue + break print("\r \r", end='', file=sys.stderr) @@ -567,19 +488,17 @@ def sign_msg_file(json_file, outfile, outdir): sys.exit(1) addr, raw_sig = done - sig = str(b64encode(raw_sig), 'ascii').replace('\n', '') - # format as RFC2440-like armoured output - result = RFC_SIGNATURE_TEMPLATE.format(msg=message, addr=addr, sig=sig) + if just_sig: + res = sig + elif verbose: + res = RFC_SIGNATURE_TEMPLATE.format(msg=message, addr=addr, sig=sig) + else: + res = '%s\n%s\n%s' % (message, addr, sig) - if not outfile: - out_dir = outdir or os.path.dirname(os.path.abspath(json_file)) - signed_name = '%s-signed%s' % (name, ext) - outfile = os.path.join(out_dir, signed_name) + click.echo(res, file=output) - open(outfile, 'w').write(result) - click.echo("Wrote signed message to: %s" % outfile) def wait_and_download(dev, req, fn): # Wait for user action on the device... by polling w/ indicated request diff --git a/ckcc/utils.py b/ckcc/utils.py index 9b1a057..a181c92 100644 --- a/ckcc/utils.py +++ b/ckcc/utils.py @@ -134,23 +134,36 @@ def descriptor_template(xfp: str, xpub: str, path: str, fmt: int, m: int = None) return res -def addr_fmt_help(dev, wrap=False, segwit=False, taproot=False): +def addr_fmt_help(dev, wrap=False, segwit=False, taproot=False, addr_fmt=None): + if addr_fmt is None: + if wrap: + addr_fmt = "p2sh-p2wpkh" + elif segwit: + addr_fmt = "p2wpkh" + elif taproot: + addr_fmt = "p2tr" + else: + addr_fmt = "p2pkh" + chain = 0 if dev.master_xpub and dev.master_xpub[0] == "t": # testnet chain = 1 - if wrap: + if addr_fmt == "p2sh-p2wpkh": addr_fmt = AF_P2WPKH_P2SH af_path = f"m/49h/{chain}h/0h/0/0" - elif segwit: + elif addr_fmt == "p2wpkh": addr_fmt = AF_P2WPKH af_path = f"m/84h/{chain}h/0h/0/0" - elif taproot: + elif addr_fmt == "p2tr": addr_fmt = AF_P2TR af_path = f"m/86h/{chain}h/0h/0/0" - else: + elif addr_fmt == "p2pkh": addr_fmt = AF_CLASSIC af_path = f"m/44h/{chain}h/0h/0/0" + else: + # addr_fmt cannot be None + raise ValueError(addr_fmt) return addr_fmt, af_path