Restrict BIP322 messages to printable ASCII
What changed, and why it matters
This commit tightens the rules for messages that COLDCARD will sign using the BIP-322 proof-of-reserves feature. Previously, non-ASCII characters were allowed with only a warning; now the message must be plain printable ASCII (with limited exceptions), must be 2-330 characters long, and cannot have tricky spacing. This reduces the chance that a crafted message could mislead the user into signing something they did not intend.
No immediate action required beyond normal review and merge. Users relying on BIP-322 proof-of-reserves with non-ASCII messages will need to switch to printable ASCII messages.
Security signals we found
Input validation hardened: non-printable and non-ASCII bytes now rejected instead of warned
Reuses existing message-sanitization routine, reducing code duplication
Removes user-facing warning path that could be abused for display-confusion attacks
Adds test coverage for invalid UTF-8, control characters, length, and minimum-size cases
Evidence from the diff
The patch changes BIP-322 message validation in shared/psbt.py to reuse validate_text_for_signing() from shared/msgsign.py instead of a simple length check and a warning for non-ASCII bytes. The helper now accepts allow_tab_nl=True and a caller-supplied max_length. As a result, BIP-322 messages must be 2-330 ASCII characters, printable except for newline/tab, with no leading/trailing spaces or runs of three spaces. The documentation and tests are updated accordingly; the old UTF-8 warning test is replaced with rejection tests.
Changed components
shared/psbt.pyshared/msgsign.pydocs/proof-of-reserves-bip-322.mdtesting/test_bip322.pyInspect captured patch +35 / −31
### docs/proof-of-reserves-bip-322.md
@@ -15,7 +15,9 @@ must meet all these requirements:
signature string is the responsibility of the finalizer.
* PSBT MUST include `PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE = 0x09`; the value is
the exact message shown to the user and signed by BIP-322. The decoded
- message must be non-empty and no longer than 330 characters.
+ message must contain 2 to 330 ASCII characters. Newline and tab are allowed;
+ all other characters must be printable. Leading or trailing spaces and runs
+ of three spaces are not allowed.
* PSBT requires `PSBT_IN_BIP32_DERIVATION` for each input
* P2SH wrapped segwit addresses MUST have proper redeem script in PSBT: `PSBT_IN_REDEEM_SCRIPT`
* P2WSH segwit addresses MUST have proper witness script in PSBT: `PSBT_IN_WITNESS_SCRIPT`
@@ -69,9 +71,6 @@ of any additional reserve UTXOs. In that case it does not show transaction
input/output counts. When the PSBT contains additional inputs, COLDCARD labels
the request as `Proof of Reserves` and shows the reserve amount.
-If the message contains non-ASCII characters, COLDCARD warns that some
-characters may not be readable on screen.
-
Legacy PoR PSBTs without `PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE` are rejected by
this flow.
### shared/msgsign.py
@@ -262,7 +262,8 @@ def write_sig_file(content_list, derive=None, addr_fmt=AF_CLASSIC, pk=None, sig_
return sig_nice
-def validate_text_for_signing(text, allow_tab_nl=False):
+def validate_text_for_signing(text, allow_tab_nl=False,
+ max_length=MSG_SIGNING_MAX_LENGTH):
# Check for some UX/UI traps in the message itself.
# - messages must be short and ascii only. Our charset is limited
# - too many spaces, leading/trailing can be an issue
@@ -272,7 +273,7 @@ def validate_text_for_signing(text, allow_tab_nl=False):
length = len(result)
assert length >= 2, "msg too short (min. 2)"
- assert length <= MSG_SIGNING_MAX_LENGTH, "msg too long (max. %d)" % MSG_SIGNING_MAX_LENGTH
+ assert length <= max_length, "msg too long (max. %d)" % max_length
assert " " not in result, 'too many spaces together in msg(max. 3)'
# other confusion w/ whitepace
assert result[0] != ' ', 'leading space(s) in msg'
### shared/psbt.py
@@ -1532,12 +1532,10 @@ async def validate(self):
self.por322 = bool(self.por322_msg)
if self.por322:
- assert len(self.por322_msg) <= 330, "msg len"
- if len(self.por322_msg) != len(self.por322_msg.encode()):
- self.warnings.append((
- "Message",
- "Message contains non-ASCII characters that may not be readable on this screen."
- ))
+ from msgsign import validate_text_for_signing
+ validate_text_for_signing(
+ self.por322_msg.encode(), allow_tab_nl=True, max_length=330
+ )
if self.txn_version == 0:
# only allow txn version 0 for Proof of Reserves txn (BIP-322)
### testing/test_bip322.py
@@ -85,20 +85,28 @@ def test_bip322_por(msg, ins, bip322_txn, start_sign, end_sign, cap_story, need_
press_cancel()
-def test_bip322_por_utf8_msg(bip322_txn, start_sign, end_sign, cap_story, press_select,
- bip322_verify):
- msg = "UTF-8 support: öäüéàè - test text".encode()
+@pytest.mark.parametrize("msg, concern", [
+ ("UTF-8: öäüéàè".encode(), "ascii"),
+ (b"shown\x03hidden", "must be ascii printable"),
+])
+def test_bip322_invalid_msg_text(msg, concern, bip322_txn, start_sign, cap_story):
+ psbt, _ = bip322_txn([["p2wpkh", None, None]], msg=msg)
+
+ start_sign(psbt, finalize=True)
+ title, story = cap_story()
+ assert title == "Failure"
+ assert concern in story.lower()
+
+
+def test_bip322_tab_newline_msg(bip322_txn, start_sign, end_sign, cap_story,
+ bip322_verify):
+ msg = b"first line\n\tsecond line"
psbt, _ = bip322_txn([["p2wpkh", None, None]], msg=msg)
start_sign(psbt, finalize=True)
title, story = cap_story()
assert title == "OK TO SIGN?"
- assert "BIP-322 Message" in story
- assert "Proof of Reserves" not in story
assert msg.decode() in story
- assert "WARNING" in story
- assert "non-ASCII characters" in story
- assert "Message Hash:" not in story
signed = end_sign(accept=True)
bip322_verify(signed)
@@ -734,13 +742,14 @@ def hack(psbt_in):
assert title == "Failure"
-@pytest.mark.parametrize("msg", [
- b"A"*330, # allowed
- b"X"*331, # too long
- b"", # empty
+@pytest.mark.parametrize("msg, valid, concern", [
+ (b"A"*330, True, None),
+ (b"X"*331, False, "msg too long (max. 330)"),
+ (b"A", False, "msg too short (min. 2)"),
+ (b"", False, "msg"),
])
-def test_msg_size(msg, bip322_txn, start_sign, end_sign, cap_story, need_keypress,
- press_select, press_cancel, bip322_verify):
+def test_msg_size(msg, valid, concern, bip322_txn, start_sign, end_sign, cap_story,
+ need_keypress, press_select, press_cancel, bip322_verify):
psbt, msg_challenge = bip322_txn([["p2wpkh", None, None]], msg=msg)
@@ -749,7 +758,7 @@ def test_msg_size(msg, bip322_txn, start_sign, end_sign, cap_story, need_keypres
time.sleep(.1)
title, story = cap_story()
- if 0 < len(msg) <= 330:
+ if valid:
assert title == "OK TO SIGN?"
assert "BIP-322 Message" in story
assert "sign message" in story
@@ -765,10 +774,7 @@ def test_msg_size(msg, bip322_txn, start_sign, end_sign, cap_story, need_keypres
else:
assert title == "Failure"
- if msg:
- assert "msg len" in story
- else:
- assert "msg" in story
+ assert concern in story
# EOFWhy this scored 48/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.