Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,18 +98,25 @@ 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
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
```


Expand Down
181 changes: 50 additions & 131 deletions ckcc/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,146 +417,67 @@ 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: <https://github.com/bitcoin/bitcoin/issues/10542>

# 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: <basename>-signed.<ext>)")
@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: <https://github.com/bitcoin/bitcoin/issues/10542>

# 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()

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)
Expand All @@ -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
Expand Down
23 changes: 18 additions & 5 deletions ckcc/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down