diff --git a/ckcc/cli.py b/ckcc/cli.py index eade658..b6f3987 100755 --- a/ckcc/cli.py +++ b/ckcc/cli.py @@ -463,6 +463,129 @@ def sign_message(message, path, verbose, just_sig, wrap, segwit): click.echo('%s\n%s\n%s' % (message.decode('ascii'), addr, sig)) +@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) + ext_lower = ext.lower() + + if ext_lower not in ('.txt', '.json'): + click.echo("Error: file must have .txt or .json extension") + sys.exit(1) + + 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") + 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) + + 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 + + ok = dev.send_recv(CCProtocolPacker.sign_message(msg_bytes, signing_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) + 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 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) + + 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 # - also download resulting file