diff --git a/src/wormhole/blocking/transcribe.py b/src/wormhole/blocking/transcribe.py index eb76576..29de3fa 100644 --- a/src/wormhole/blocking/transcribe.py +++ b/src/wormhole/blocking/transcribe.py @@ -1,7 +1,7 @@ from __future__ import print_function -import sys, time, re, requests, json, textwrap +import os, sys, time, re, requests, json, textwrap from binascii import hexlify, unhexlify -from spake2 import SPAKE2_A, SPAKE2_B +from spake2 import SPAKE2_Symmetric from nacl.secret import SecretBox from nacl.exceptions import CryptoError from nacl import utils @@ -19,26 +19,20 @@ class Timeout(Exception): class WrongPasswordError(Exception): """ - Key confirmation failed. + Key confirmation failed. Either you or your correspondent typed the code + wrong, or a would-be man-in-the-middle attacker guessed incorrectly. You + could try again, giving both your correspondent and the attacker another + chance. """ # or the data blob was corrupted, and that's why decrypt failed def explain(self): return textwrap.dedent(self.__doc__) -class InitiatorWrongPasswordError(WrongPasswordError): - """ - Key confirmation failed. Either your correspondent typed the code wrong, - or a would-be man-in-the-middle attacker guessed incorrectly. You could - try again, giving both your correspondent and the attacker another - chance. - """ +class ReflectionAttack(Exception): + """An attacker (or bug) reflected our outgoing message back to us.""" -class ReceiverWrongPasswordError(WrongPasswordError): - """ - Key confirmation failed. Either you typed the code wrong, or a would-be - man-in-the-middle attacker guessed incorrectly. You could try again, - giving both you and the attacker another chance. - """ +class UsageError(Exception): + """The programmer did something wrong.""" # relay URLs are: # GET /list -> {channel-ids: [INT..]} @@ -49,7 +43,19 @@ class ReceiverWrongPasswordError(WrongPasswordError): # GET /CHANNEL-ID/SIDE/poll/MSGNUM (eventsource) -> STR, STR, .. # POST /CHANNEL-ID/SIDE/deallocate -> waiting | deleted -class Common: +class Wormhole: + def __init__(self, appid, relay): + self.appid = appid + self.relay = relay + assert self.relay.endswith("/") + self.started = time.time() + self.wait = 0.5*SECOND + self.timeout = 3*MINUTE + self.side = None + self.code = None + self.key = None + self.verifier = None + def url(self, verb, msgnum=None): url = "%s%d/%s/%s" % (self.relay, self.channel_id, self.side, verb) if msgnum is not None: @@ -107,7 +113,7 @@ class Common: f.close() return msgs - def _allocate(self): + def _allocate_channel(self): r = requests.post(self.relay + "allocate/%s" % self.side) r.raise_for_status() data = r.json() @@ -116,19 +122,93 @@ class Common: channel_id = data["channel-id"] return channel_id - def _post_pake(self): - msg = self.sp.start() - post_data = {"message": hexlify(msg).decode("ascii")} - r = requests.post(self.url("post", "pake"), data=json.dumps(post_data)) - r.raise_for_status() - other_msgs = r.json()["messages"] - return other_msgs + def derive_key(self, purpose, length=SecretBox.KEY_SIZE): + assert type(purpose) == type(b"") + return HKDF(self.key, length, CTXinfo=purpose) - def _get_pake(self, other_msgs): - msgs = self.get(other_msgs, "poll", "pake") - pake_msg = unhexlify(msgs[0].encode("ascii")) - key = self.sp.finish(pake_msg) - return key + + def get_code(self, code_length=2): + if self.code is not None: raise UsageError + self.side = hexlify(os.urandom(5)) + channel_id = self._allocate_channel() # allocate channel + code = codes.make_code(channel_id, code_length) + self._set_code_and_channel_id(code) + self._start() + return code + + def list_channels(self): + r = requests.get(self.relay + "list") + r.raise_for_status() + channel_ids = r.json()["channel-ids"] + return channel_ids + + def input_code(self, prompt="Enter wormhole code: ", code_length=2): + code = codes.input_code_with_completion(prompt, self.list_channels, + code_length) + return code + + def set_code(self, code): # used for human-made pre-generated codes + if self.code is not None: raise UsageError + if self.side is not None: raise UsageError + self._set_code_and_channel_id(code) + self.side = hexlify(os.urandom(5)) + self._start() + + def _set_code_and_channel_id(self, code): + if self.code is not None: raise UsageError + mo = re.search(r'^(\d+)-', code) + if not mo: + raise ValueError("code (%s) must start with NN-" % code) + self.channel_id = int(mo.group(1)) + self.code = code + + def _start(self): + # allocate the rest now too, so it can be serialized + self.sp = SPAKE2_Symmetric(self.code.encode("ascii"), + idA=self.appid+":SymmetricA", + idB=self.appid+":SymmetricB") + self.msg1 = self.sp.start() + + def _get_key(self): + if not self.key: + post_data = {"message": hexlify(self.msg1).decode("ascii")} + r = requests.post(self.url("post", "pake"), + data=json.dumps(post_data)) + r.raise_for_status() + other_msgs = r.json()["messages"] + msgs = self.get(other_msgs, "poll", "pake") + pake_msg = unhexlify(msgs[0].encode("ascii")) + self.key = self.sp.finish(pake_msg) + self.verifier = self.derive_key(self.appid+b":Verifier") + + def get_verifier(self): + self._get_key() + return self.verifier + + def get_data(self, outbound_data): + assert self.code is not None + assert self.channel_id is not None + + self._get_key() + # Without predefined roles, we can't derive predictably unique keys + # for each side, so we use the same key for both. We use random + # nonces to keep the messages distinct, and check for reflection. + try: + data_key = self.derive_key(b"data-key") + + outbound_encrypted = self._encrypt_data(data_key, outbound_data) + other_msgs = self._post_data(outbound_encrypted) + + inbound_encrypted = self._get_data(other_msgs) + if inbound_encrypted == outbound_encrypted: + raise ReflectionAttack + try: + inbound_data = self._decrypt_data(data_key, inbound_encrypted) + except CryptoError: + raise WrongPasswordError + finally: + self._deallocate() + return inbound_data def _encrypt_data(self, key, data): assert len(key) == SecretBox.KEY_SIZE @@ -136,6 +216,12 @@ class Common: nonce = utils.random(SecretBox.NONCE_SIZE) return box.encrypt(data, nonce) + def _decrypt_data(self, key, encrypted): + assert len(key) == SecretBox.KEY_SIZE + box = SecretBox(key) + data = box.decrypt(encrypted) + return data + def _post_data(self, data): post_data = json.dumps({"message": hexlify(data).decode("ascii")}) r = requests.post(self.url("post", "data"), data=post_data) @@ -148,140 +234,6 @@ class Common: data = unhexlify(msgs[0].encode("ascii")) return data - def _decrypt_data(self, key, encrypted): - assert len(key) == SecretBox.KEY_SIZE - box = SecretBox(key) - data = box.decrypt(encrypted) - return data - def _deallocate(self): r = requests.post(self.url("deallocate")) r.raise_for_status() - - def derive_key(self, purpose, length=SecretBox.KEY_SIZE): - assert type(purpose) == type(b"") - return HKDF(self.key, length, CTXinfo=purpose) - -class Initiator(Common): - def __init__(self, appid, relay): - self.appid = appid - self.relay = relay - assert self.relay.endswith("/") - self.started = time.time() - self.wait = 0.5*SECOND - self.timeout = 3*MINUTE - self.side = "initiator" - self.key = None - self.verifier = None - - def set_code(self, code): # used for human-made pre-generated codes - mo = re.search(r'^(\d+)-', code) - if not mo: - raise ValueError("code (%s) must start with NN-" % code) - self.channel_id = int(mo.group(1)) - self.code = code - self.sp = SPAKE2_A(self.code.encode("ascii"), - idA=self.appid+":Initiator", - idB=self.appid+":Receiver") - self._post_pake() - - def get_code(self, code_length=2): - channel_id = self._allocate() # allocate channel - code = codes.make_code(channel_id, code_length) - self.set_code(code) - return code - - def _wait_for_key(self): - if not self.key: - key = self._get_pake([]) - self.key = key - self.verifier = self.derive_key(self.appid+b":Verifier") - - def get_verifier(self): - self._wait_for_key() - return self.verifier - - def get_data(self, outbound_data): - self._wait_for_key() - try: - outbound_key = self.derive_key(b"sender") - outbound_encrypted = self._encrypt_data(outbound_key, outbound_data) - other_msgs = self._post_data(outbound_encrypted) - - inbound_encrypted = self._get_data(other_msgs) - inbound_key = self.derive_key(b"receiver") - try: - inbound_data = self._decrypt_data(inbound_key, - inbound_encrypted) - except CryptoError: - raise InitiatorWrongPasswordError - finally: - self._deallocate() - return inbound_data - - -class Receiver(Common): - def __init__(self, appid, relay): - self.appid = appid - self.relay = relay - assert self.relay.endswith("/") - self.started = time.time() - self.wait = 0.5*SECOND - self.timeout = 3*MINUTE - self.side = "receiver" - self.code = None - self.channel_id = None - self.key = None - self.verifier = None - - def list_channels(self): - r = requests.get(self.relay + "list") - r.raise_for_status() - channel_ids = r.json()["channel-ids"] - return channel_ids - - def input_code(self, prompt="Enter wormhole code: ", code_length=2): - code = codes.input_code_with_completion(prompt, self.list_channels, - code_length) - return code - - def set_code(self, code): - assert self.code is None - assert self.channel_id is None - self.code = code - self.channel_id = codes.extract_channel_id(code) - self.sp = SPAKE2_B(code.encode("ascii"), - idA=self.appid+":Initiator", - idB=self.appid+":Receiver") - - def _wait_for_key(self): - if not self.key: - other_msgs = self._post_pake() - key = self._get_pake(other_msgs) - self.key = key - self.verifier = self.derive_key(self.appid+b":Verifier") - - def get_verifier(self): - self._wait_for_key() - return self.verifier - - def get_data(self, outbound_data): - assert self.code is not None - assert self.channel_id is not None - self._wait_for_key() - - try: - outbound_key = self.derive_key(b"receiver") - outbound_encrypted = self._encrypt_data(outbound_key, outbound_data) - other_msgs = self._post_data(outbound_encrypted) - - inbound_encrypted = self._get_data(other_msgs) - inbound_key = self.derive_key(b"sender") - try: - inbound_data = self._decrypt_data(inbound_key, - inbound_encrypted) - except CryptoError: - raise ReceiverWrongPasswordError - finally: - self._deallocate() - return inbound_data diff --git a/src/wormhole/scripts/cmd_receive_file.py b/src/wormhole/scripts/cmd_receive_file.py index aefdff6..c215da1 100644 --- a/src/wormhole/scripts/cmd_receive_file.py +++ b/src/wormhole/scripts/cmd_receive_file.py @@ -7,24 +7,24 @@ APPID = "lothar.com/wormhole/file-xfer" @handle_server_error def receive_file(args): # we're receiving - from ..blocking.transcribe import Receiver, WrongPasswordError + from ..blocking.transcribe import Wormhole, WrongPasswordError from ..blocking.transit import TransitReceiver, TransitError from .progress import start_progress, update_progress, finish_progress transit_receiver = TransitReceiver(args.transit_helper) - r = Receiver(APPID, args.relay_url) + w = Wormhole(APPID, args.relay_url) if args.zeromode: assert not args.code args.code = "0-" code = args.code if not code: - code = r.input_code("Enter receive-file wormhole code: ", + code = w.input_code("Enter receive-file wormhole code: ", args.code_length) - r.set_code(code) + w.set_code(code) if args.verify: - verifier = binascii.hexlify(r.get_verifier()) + verifier = binascii.hexlify(w.get_verifier()) print("Verifier %s." % verifier) mydata = json.dumps({ @@ -34,7 +34,7 @@ def receive_file(args): }, }).encode("utf-8") try: - data = json.loads(r.get_data(mydata).decode("utf-8")) + data = json.loads(w.get_data(mydata).decode("utf-8")) except WrongPasswordError as e: print("ERROR: " + e.explain(), file=sys.stderr) return 1 @@ -50,7 +50,7 @@ def receive_file(args): # now receive the rest of the owl tdata = data["transit"] - transit_key = r.derive_key(APPID+"/transit-key") + transit_key = w.derive_key(APPID+"/transit-key") transit_receiver.set_transit_key(transit_key) transit_receiver.add_their_direct_hints(tdata["direct_connection_hints"]) transit_receiver.add_their_relay_hints(tdata["relay_connection_hints"]) diff --git a/src/wormhole/scripts/cmd_receive_text.py b/src/wormhole/scripts/cmd_receive_text.py index 64ba2da..ea374c2 100644 --- a/src/wormhole/scripts/cmd_receive_text.py +++ b/src/wormhole/scripts/cmd_receive_text.py @@ -7,25 +7,25 @@ APPID = "lothar.com/wormhole/text-xfer" @handle_server_error def receive_text(args): # we're receiving - from ..blocking.transcribe import Receiver, WrongPasswordError + from ..blocking.transcribe import Wormhole, WrongPasswordError - r = Receiver(APPID, args.relay_url) + w = Wormhole(APPID, args.relay_url) if args.zeromode: assert not args.code args.code = "0-" code = args.code if not code: - code = r.input_code("Enter receive-text wormhole code: ", + code = w.input_code("Enter receive-text wormhole code: ", args.code_length) - r.set_code(code) + w.set_code(code) if args.verify: - verifier = binascii.hexlify(r.get_verifier()) + verifier = binascii.hexlify(w.get_verifier()) print("Verifier %s." % verifier) data = json.dumps({"message": "ok"}).encode("utf-8") try: - them_bytes = r.get_data(data) + them_bytes = w.get_data(data) except WrongPasswordError as e: print("ERROR: " + e.explain(), file=sys.stderr) return 1 diff --git a/src/wormhole/scripts/cmd_send_file.py b/src/wormhole/scripts/cmd_send_file.py index 8858bc4..3d3046d 100644 --- a/src/wormhole/scripts/cmd_send_file.py +++ b/src/wormhole/scripts/cmd_send_file.py @@ -7,7 +7,7 @@ APPID = "lothar.com/wormhole/file-xfer" @handle_server_error def send_file(args): # we're sending - from ..blocking.transcribe import Initiator, WrongPasswordError + from ..blocking.transcribe import Wormhole, WrongPasswordError from ..blocking.transit import TransitSender from .progress import start_progress, update_progress, finish_progress @@ -15,15 +15,15 @@ def send_file(args): assert os.path.isfile(filename) transit_sender = TransitSender(args.transit_helper) - i = Initiator(APPID, args.relay_url) + w = Wormhole(APPID, args.relay_url) if args.zeromode: assert not args.code args.code = "0-" if args.code: - i.set_code(args.code) + w.set_code(args.code) code = args.code else: - code = i.get_code(args.code_length) + code = w.get_code(args.code_length) other_cmd = "wormhole receive-file" if args.verify: other_cmd = "wormhole --verify receive-file" @@ -35,7 +35,7 @@ def send_file(args): print() if args.verify: - verifier = binascii.hexlify(i.get_verifier()) + verifier = binascii.hexlify(w.get_verifier()) while True: ok = raw_input("Verifier %s. ok? (yes/no): " % verifier) if ok.lower() == "yes": @@ -45,7 +45,7 @@ def send_file(args): file=sys.stderr) reject_data = json.dumps({"error": "verification rejected", }).encode("utf-8") - i.get_data(reject_data) + w.get_data(reject_data) return 1 filesize = os.stat(filename).st_size @@ -61,7 +61,7 @@ def send_file(args): }).encode("utf-8") try: - them_bytes = i.get_data(data) + them_bytes = w.get_data(data) except WrongPasswordError as e: print("ERROR: " + e.explain(), file=sys.stderr) return 1 @@ -70,7 +70,7 @@ def send_file(args): tdata = them_d["transit"] - transit_key = i.derive_key(APPID+"/transit-key") + transit_key = w.derive_key(APPID+"/transit-key") transit_sender.set_transit_key(transit_key) transit_sender.add_their_direct_hints(tdata["direct_connection_hints"]) transit_sender.add_their_relay_hints(tdata["relay_connection_hints"]) diff --git a/src/wormhole/scripts/cmd_send_text.py b/src/wormhole/scripts/cmd_send_text.py index c25620f..7651cb1 100644 --- a/src/wormhole/scripts/cmd_send_text.py +++ b/src/wormhole/scripts/cmd_send_text.py @@ -7,17 +7,17 @@ APPID = "lothar.com/wormhole/text-xfer" @handle_server_error def send_text(args): # we're sending - from ..blocking.transcribe import Initiator, WrongPasswordError + from ..blocking.transcribe import Wormhole, WrongPasswordError - i = Initiator(APPID, args.relay_url) + w = Wormhole(APPID, args.relay_url) if args.zeromode: assert not args.code args.code = "0-" if args.code: - i.set_code(args.code) + w.set_code(args.code) code = args.code else: - code = i.get_code(args.code_length) + code = w.get_code(args.code_length) other_cmd = "wormhole receive-text" if args.verify: other_cmd = "wormhole --verify receive-text" @@ -29,7 +29,7 @@ def send_text(args): print("") if args.verify: - verifier = binascii.hexlify(i.get_verifier()) + verifier = binascii.hexlify(w.get_verifier()) while True: ok = raw_input("Verifier %s. ok? (yes/no): " % verifier) if ok.lower() == "yes": @@ -39,14 +39,14 @@ def send_text(args): file=sys.stderr) reject_data = json.dumps({"error": "verification rejected", }).encode("utf-8") - i.get_data(reject_data) + w.get_data(reject_data) return 1 message = args.text data = json.dumps({"message": message, }).encode("utf-8") try: - them_bytes = i.get_data(data) + them_bytes = w.get_data(data) except WrongPasswordError as e: print("ERROR: " + e.explain(), file=sys.stderr) return 1