Skip to content

Commit 5252017

Browse files
committed
Merge #1838: Fix case commitments
2 parents ce32baf + 092b480 commit 5252017

File tree

2 files changed

+108
-6
lines changed

2 files changed

+108
-6
lines changed

src/jmdaemon/daemon_protocol.py

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,24 +73,42 @@ def set_blacklist_location(location):
7373
global blacklist_location
7474
blacklist_location = location
7575

76+
def canonicalize_commitment_str(commitment):
77+
# Worthy of note: an actually valid commitment
78+
# has a specific length, but that's not at issue here:
79+
# if the commitment isn't valid, it can't be reused.
80+
# This makes sure of a 1-1 mapping between a valid commitment
81+
# byte string and its hex representation.
82+
try:
83+
commitment = bytes.fromhex(commitment).hex()
84+
except ValueError:
85+
return False
86+
return commitment
87+
7688
def check_utxo_blacklist(commitment, persist=False):
7789
"""Compare a given commitment with the persisted blacklist log file,
7890
which is hardcoded to this directory and name 'commitmentlist' (no
7991
security or privacy issue here).
80-
If the commitment has been used before, return False (disallowed),
92+
If the commitment is not of a valid format, return "invalid_format"; if
93+
it has been used before, return False (disallowed),
8194
else return True.
8295
If flagged, persist the usage of this commitment to the above file.
8396
"""
84-
#TODO format error checking?
97+
# Fix DOS potential: should check what is given as a value, not as a string;
98+
# but since it's passed as a string, the format must be canonical:
99+
commitment = canonicalize_commitment_str(commitment)
100+
if not commitment:
101+
return "invalid_format"
85102
fname = blacklist_location
86103
if os.path.isfile(fname):
87104
with open(fname, "rb") as f:
88-
blacklisted_commitments = [x.decode('ascii').strip() for x in f.readlines()]
105+
blacklisted_commitments = [x.decode('ascii').strip().lower() for x in f.readlines()]
89106
else:
90107
blacklisted_commitments = []
91108
if commitment in blacklisted_commitments:
92109
return False
93110
elif persist:
111+
# persisted commitments guaranteed to be canonical format
94112
blacklisted_commitments += [commitment]
95113
with open(fname, "wb") as f:
96114
f.write('\n'.join(blacklisted_commitments).encode('ascii'))
@@ -712,6 +730,7 @@ def on_JM_IOAUTH(self, nick, utxolist, pubkey, cjaddr, changeaddr, pubkeysig):
712730
#we broadcast them here, and not early - to avoid accidentally
713731
#blacklisting commitments that are broadcast between makers in real time
714732
#for the same transaction.
733+
#We don't need validity check here; we already did it.
715734
self.transfer_commitment(self.active_orders[nick]["commit"])
716735
#now persist the fact that the commitment is actually used.
717736
check_utxo_blacklist(self.active_orders[nick]["commit"], persist=True)
@@ -773,7 +792,11 @@ def on_order_fill(self, nick, oid, amount, taker_pk, commit):
773792
"Unsupported commitment type: " + str(commit[0]))
774793
return
775794
scommit = commit[1:]
776-
if not check_utxo_blacklist(scommit):
795+
blacklist_check = check_utxo_blacklist(scommit)
796+
if blacklist_check == "invalid_format":
797+
# Bad behaviour; ignore; don't forward it.
798+
return
799+
if not blacklist_check:
777800
log.msg("Taker utxo commitment is blacklisted, rejecting.")
778801
self.mcc.send_error(nick, "Commitment is blacklisted: " + str(scommit))
779802
#Note that broadcast is happening here to reflect an already
@@ -832,7 +855,7 @@ def on_commitment_seen(self, nick, commitment):
832855
"""Triggered when we see a commitment for blacklisting
833856
appear in the public pit channel.
834857
"""
835-
#just add if necessary, ignore return value.
858+
#just add if necessary and valid, ignore return value.
836859
check_utxo_blacklist(commitment, persist=True)
837860
log.msg("Received commitment broadcast by other maker: " + str(
838861
commitment) + ", now blacklisted.")
@@ -842,8 +865,17 @@ def on_commitment_transferred(self, nick, commitment):
842865
"""Triggered when a privmsg is received from another maker
843866
with a commitment to announce in public (obfuscation of source).
844867
We simply post it in public (not affected by whether we ourselves
845-
are *accepting* commitment broadcasts.
868+
are *accepting* commitment broadcasts).
869+
There is no issue of malicious format in transfer: the only purpose
870+
of transfer is to give more possibility of blocking, not accepting.
871+
DOS risk would not be addressed by restricting format.
872+
However, as a kind of 'politeness', we are not going to pubmsg
873+
invalid-format commitments, in case other participants have old code
874+
that is not checking format (in short, why not).
846875
"""
876+
commitment = canonicalize_commitment_str(commitment)
877+
if not commitment:
878+
return
847879
self.mcc.pubmsg("!hp2 " + commitment)
848880

849881
@maker_only

test/jmdaemon/test_daemon_protocol.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,3 +326,73 @@ def test_waiter(self):
326326
def _called_by_deffered(self):
327327
global end_early
328328
end_early = False
329+
330+
def test_check_utxo_blacklist_case_and_whitespace(tmp_path):
331+
"""Regression test: a commitment that has already been used must not
332+
be accepted again, regardless of hex-digit case or surrounding
333+
whitespace (well; the line protocol doesn't allow whitespace in or around,
334+
but, why not be strict).
335+
We're gong to test canonicalize_commitment_str and check_utxo_blacklist.
336+
"""
337+
from jmdaemon.daemon_protocol import (check_utxo_blacklist,
338+
canonicalize_commitment_str,
339+
set_blacklist_location,
340+
blacklist_location)
341+
342+
old_blacklist_location = blacklist_location
343+
try:
344+
# Fresh, isolated commitmentlist file per test run, courtesy of the
345+
# pytest tmp_path fixture.
346+
commitmentlist = tmp_path / "commitmentlist"
347+
set_blacklist_location(str(commitmentlist))
348+
349+
# A plausible 32-byte (64 hex char) commitment.
350+
lc = "deadbeef" * 8
351+
uc = lc.upper()
352+
mixed = "DeAdBeEf" * 8
353+
padded = " " + lc + "\n"
354+
355+
# First use: accepted, and persisted to disk.
356+
assert check_utxo_blacklist(lc, persist=True) is True
357+
assert commitmentlist.is_file()
358+
359+
# Exact reuse: rejected.
360+
assert check_utxo_blacklist(lc) is False
361+
362+
# Case-variant reuse: must also be rejected. This is the regression
363+
# the fix targets — before the fix these returned True.
364+
assert check_utxo_blacklist(uc) is False
365+
assert check_utxo_blacklist(mixed) is False
366+
367+
# Whitespace-variant reuse: also rejected.
368+
assert check_utxo_blacklist(padded) is False
369+
370+
# A genuinely new commitment is still accepted.
371+
other = "cafebabe" * 8
372+
assert check_utxo_blacklist(other) is True
373+
374+
# Malformed input is rejected outright.
375+
assert canonicalize_commitment_str("not-a-hex-string!") is False
376+
assert check_utxo_blacklist("not-a-hex-string!") == "invalid_format"
377+
assert canonicalize_commitment_str("abc") is False
378+
assert check_utxo_blacklist("abc") == "invalid_format" # odd length; not even valid hex
379+
380+
# As explained elsewhere, whitespace inside the commitment is not actually
381+
# possible, but it's sensible to check the effect in case the protocol is different.
382+
# The current effect is to treat it as valid hex and canonicalize it.
383+
# Hence if the corresponding canonical form has been used, we should get False,
384+
# and otherwise True.
385+
assert check_utxo_blacklist("dead beef" * 8) is False
386+
assert check_utxo_blacklist("de ad be ef" * 8) is False
387+
assert check_utxo_blacklist("de ad be ef" * 7 + "cafe babe") is True
388+
389+
# Legacy-on-disk path: simulate a pre-fix commitmentlist that was
390+
# written in uppercase (e.g. from an !hp2 broadcast that arrived in
391+
# uppercase under the old code). A canonical lowercase query must
392+
# still detect the reuse after the fix normalizes entries on read.
393+
legacy = tmp_path / "legacy_commitmentlist"
394+
legacy.write_bytes(("F00DF00D" * 8 + "\n").encode("ascii"))
395+
set_blacklist_location(str(legacy))
396+
assert check_utxo_blacklist("f00df00d" * 8) is False
397+
finally:
398+
set_blacklist_location(old_blacklist_location)

0 commit comments

Comments
 (0)