diff --git a/setup.py b/setup.py index 34e06d7..0a23c6d 100644 --- a/setup.py +++ b/setup.py @@ -40,6 +40,7 @@ setup(name="magic-wormhole", "dev": [ "mock", "tox", + "pyflakes", ], }, test_suite="wormhole.test", diff --git a/src/wormhole/cli/cli.py b/src/wormhole/cli/cli.py index dd0c010..4f0a341 100644 --- a/src/wormhole/cli/cli.py +++ b/src/wormhole/cli/cli.py @@ -3,7 +3,6 @@ from __future__ import print_function import os import time start = time.time() -import traceback from textwrap import fill, dedent from sys import stdout, stderr from . import public_relay @@ -11,6 +10,7 @@ from .. import __version__ from ..timing import DebugTiming from ..errors import WrongPasswordError, WelcomeError, KeyFormatError from twisted.internet.defer import inlineCallbacks, maybeDeferred +from twisted.python.failure import Failure from twisted.internet.task import react import click @@ -29,6 +29,7 @@ class Config(object): self.cwd = os.getcwd() self.stdout = stdout self.stderr = stderr + self.tor = False # XXX? def _compose(*decorators): def decorate(f): @@ -111,7 +112,10 @@ def _dispatch_command(reactor, cfg, command): msg = fill("ERROR: " + dedent(e.__doc__)) print(msg, file=stderr) except Exception as e: - traceback.print_exc() + # this prints a proper traceback, whereas + # traceback.print_exc() just prints a TB to the "yield" + # line above ... + Failure().printTraceback(file=stderr) print("ERROR:", e, file=stderr) raise SystemExit(1) @@ -213,3 +217,67 @@ def receive(cfg, code, **kwargs): cfg.code = None return go(cmd_receive.receive, cfg) + + +@wormhole.group() +def ssh(): + """ + Facilitate sending/receiving SSH public keys + """ + pass + + +@ssh.command(name="invite") +@click.option( + "-c", "--code-length", default=2, + metavar="NUMWORDS", + help="length of code (in bytes/words)", +) +@click.option( + "--user", "-u", + default=None, + metavar="USER", + help="Add to USER's ~/.ssh/authorized_keys", +) +@click.pass_context +def ssh_invite(ctx, code_length, user): + """ + Add a public-key to a ~/.ssh/authorized_keys file + """ + from . import cmd_ssh + ctx.obj.code_length = code_length + ctx.obj.ssh_user = user + return go(cmd_ssh.invite, ctx.obj) + + +@ssh.command(name="accept") +@click.argument( + "code", nargs=1, required=True, +) +@click.option( + "--key-file", "-F", + default=None, + type=click.Path(exists=True), +) +@click.option( + "--yes", "-y", is_flag=True, + help="Skip confirmation prompt to send key", +) +@click.pass_obj +def ssh_accept(cfg, code, key_file, yes): + """ + Send your SSH public-key + + In response to a 'wormhole ssh invite' this will send public-key + you specify (if there's only one in ~/.ssh/* that will be sent). + """ + + from . import cmd_ssh + kind, keyid, pubkey = cmd_ssh.find_public_key(key_file) + print("Sending public key type='{}' keyid='{}'".format(kind, keyid)) + if yes is not True: + click.confirm("Really send public key '{}' ?".format(keyid), abort=True) + cfg.public_key = (kind, keyid, pubkey) + cfg.code = code + + return go(cmd_ssh.accept, cfg) diff --git a/src/wormhole/cli/cmd_ssh.py b/src/wormhole/cli/cmd_ssh.py new file mode 100644 index 0000000..b452cec --- /dev/null +++ b/src/wormhole/cli/cmd_ssh.py @@ -0,0 +1,124 @@ +from __future__ import print_function + +import os +from os.path import expanduser, exists, join +from twisted.internet.defer import inlineCallbacks +from twisted.internet import reactor +import click + +from .. import xfer_util + +class PubkeyError(Exception): + pass + +def find_public_key(hint=None): + """ + This looks for an appropriate SSH key to send, possibly querying + the user in the meantime. DO NOT CALL after reactor.run as this + (possibly) does blocking stuff like asking the user questions (via + click.prompt()) + + Returns a 3-tuple: kind, keyid, pubkey_data + """ + + if hint is None: + hint = expanduser('~/.ssh/') + else: + if not exists(hint): + raise PubkeyError("Can't find '{}'".format(hint)) + + pubkeys = [f for f in os.listdir(hint) if f.endswith('.pub')] + if len(pubkeys) == 0: + raise PubkeyError("No public keys in '{}'".format(hint)) + elif len(pubkeys) > 1: + got_key = False + while not got_key: + ans = click.prompt( + "Multiple public-keys found:\n" + \ + "\n".join([" {}: {}".format(a, b) for a, b in enumerate(pubkeys)]) + \ + "\nSend which one?" + ) + try: + ans = int(ans) + if ans < 0 or ans >= len(pubkeys): + ans = None + else: + got_key = True + with open(join(hint, pubkeys[ans]), 'r') as f: + pubkey = f.read() + + except Exception: + got_key = False + else: + with open(join(hint, pubkeys[0]), 'r') as f: + pubkey = f.read() + parts = pubkey.strip().split() + kind = parts[0] + keyid = 'unknown' if len(parts) <= 2 else parts[2] + + return kind, keyid, pubkey + + +@inlineCallbacks +def accept(cfg, reactor=reactor): + yield xfer_util.send( + reactor, + u"lothar.com/wormhole/ssh-add", + cfg.relay_url, + data=cfg.public_key[2], + code=cfg.code, + use_tor=cfg.tor, + ) + print("Key sent.") + + +@inlineCallbacks +def invite(cfg, reactor=reactor): + + def on_code_created(code): + print("Now tell the other user to run:") + print() + print("wormhole ssh accept {}".format(code)) + print() + + if cfg.ssh_user is None: + ssh_path = expanduser('~/.ssh/'.format(cfg.ssh_user)) + else: + ssh_path = expanduser('~{}/.ssh/'.format(cfg.ssh_user)) + auth_key_path = join(ssh_path, 'authorized_keys') + if not exists(auth_key_path): + print("Note: '{}' not found; will be created".format(auth_key_path)) + if not exists(ssh_path): + print(" '{}' doesn't exist either".format(ssh_path)) + else: + try: + open(auth_key_path, 'a').close() + except OSError: + print("No write permission on '{}'".format(auth_key_path)) + return + try: + os.listdir(ssh_path) + except OSError: + print("Can't read '{}'".format(ssh_path)) + return + + pubkey = yield xfer_util.receive( + reactor, + u"lothar.com/wormhole/ssh-add", + cfg.relay_url, + None, # allocate a code for us + use_tor=cfg.tor, + on_code=on_code_created, + ) + + parts = pubkey.split() + kind = parts[0] + keyid = 'unknown' if len(parts) <= 2 else parts[2] + + if not exists(auth_key_path): + if not exists(ssh_path): + os.mkdir(ssh_path, mode=0o700) + with open(auth_key_path, 'a', 0o600) as f: + f.write('{}\n'.format(pubkey.strip())) + print("Appended key type='{kind}' id='{key_id}' to '{auth_file}'".format( + kind=kind, key_id=keyid, auth_file=auth_key_path)) diff --git a/src/wormhole/test/test_ssh.py b/src/wormhole/test/test_ssh.py new file mode 100644 index 0000000..775c22a --- /dev/null +++ b/src/wormhole/test/test_ssh.py @@ -0,0 +1,59 @@ +import os, io +import mock +from twisted.trial import unittest +from ..cli import cmd_ssh + +OTHERS = ["config", "config~", "known_hosts", "known_hosts~"] + +class FindPubkey(unittest.TestCase): + def test_find_one(self): + files = OTHERS + ["id_rsa.pub", "id_rsa"] + pubkey_data = u"ssh-rsa AAAAkeystuff email@host\n" + pubkey_file = io.StringIO(pubkey_data) + with mock.patch("wormhole.cli.cmd_ssh.exists", return_value=True): + with mock.patch("os.listdir", return_value=files) as ld: + with mock.patch("wormhole.cli.cmd_ssh.open", + return_value=pubkey_file): + res = cmd_ssh.find_public_key() + self.assertEqual(ld.mock_calls, + [mock.call(os.path.expanduser("~/.ssh/"))]) + self.assertEqual(len(res), 3, res) + kind, keyid, pubkey = res + self.assertEqual(kind, "ssh-rsa") + self.assertEqual(keyid, "email@host") + self.assertEqual(pubkey, pubkey_data) + + def test_find_none(self): + files = OTHERS # no pubkey + with mock.patch("wormhole.cli.cmd_ssh.exists", return_value=True): + with mock.patch("os.listdir", return_value=files): + e = self.assertRaises(cmd_ssh.PubkeyError, + cmd_ssh.find_public_key) + dot_ssh = os.path.expanduser("~/.ssh/") + self.assertEqual(str(e), "No public keys in '{}'".format(dot_ssh)) + + def test_bad_hint(self): + with mock.patch("wormhole.cli.cmd_ssh.exists", return_value=False): + e = self.assertRaises(cmd_ssh.PubkeyError, + cmd_ssh.find_public_key, + hint="bogus/path") + self.assertEqual(str(e), "Can't find 'bogus/path'") + + + def test_find_multiple(self): + files = OTHERS + ["id_rsa.pub", "id_rsa", "id_dsa.pub", "id_dsa"] + pubkey_data = u"ssh-rsa AAAAkeystuff email@host\n" + pubkey_file = io.StringIO(pubkey_data) + with mock.patch("wormhole.cli.cmd_ssh.exists", return_value=True): + with mock.patch("os.listdir", return_value=files): + responses = iter(["frog", "NaN", "-1", "0"]) + with mock.patch("click.prompt", + side_effect=lambda p: next(responses)): + with mock.patch("wormhole.cli.cmd_ssh.open", + return_value=pubkey_file): + res = cmd_ssh.find_public_key() + self.assertEqual(len(res), 3, res) + kind, keyid, pubkey = res + self.assertEqual(kind, "ssh-rsa") + self.assertEqual(keyid, "email@host") + self.assertEqual(pubkey, pubkey_data) diff --git a/src/wormhole/test/test_xfer_util.py b/src/wormhole/test/test_xfer_util.py new file mode 100644 index 0000000..1c08f3a --- /dev/null +++ b/src/wormhole/test/test_xfer_util.py @@ -0,0 +1,49 @@ +from twisted.trial import unittest +from twisted.internet import reactor, defer +from twisted.internet.defer import inlineCallbacks +from .. import xfer_util +from .common import ServerBase + +APPID = u"appid" + +class Xfer(ServerBase, unittest.TestCase): + @inlineCallbacks + def test_xfer(self): + code = u"1-code" + data = u"data" + d1 = xfer_util.send(reactor, APPID, self.relayurl, data, code) + d2 = xfer_util.receive(reactor, APPID, self.relayurl, code) + send_result = yield d1 + receive_result = yield d2 + self.assertEqual(send_result, None) + self.assertEqual(receive_result, data) + + @inlineCallbacks + def test_on_code(self): + code = u"1-code" + data = u"data" + send_code = [] + receive_code = [] + d1 = xfer_util.send(reactor, APPID, self.relayurl, data, code, + on_code=send_code.append) + d2 = xfer_util.receive(reactor, APPID, self.relayurl, code, + on_code=receive_code.append) + send_result = yield d1 + receive_result = yield d2 + self.assertEqual(send_code, [code]) + self.assertEqual(receive_code, [code]) + self.assertEqual(send_result, None) + self.assertEqual(receive_result, data) + + @inlineCallbacks + def test_make_code(self): + data = u"data" + got_code = defer.Deferred() + d1 = xfer_util.send(reactor, APPID, self.relayurl, data, code=None, + on_code=got_code.callback) + code = yield got_code + d2 = xfer_util.receive(reactor, APPID, self.relayurl, code) + send_result = yield d1 + receive_result = yield d2 + self.assertEqual(send_result, None) + self.assertEqual(receive_result, data) diff --git a/src/wormhole/xfer_util.py b/src/wormhole/xfer_util.py new file mode 100644 index 0000000..dfc0e1e --- /dev/null +++ b/src/wormhole/xfer_util.py @@ -0,0 +1,101 @@ +import json +from twisted.internet.defer import inlineCallbacks, returnValue + +from .wormhole import wormhole + + +@inlineCallbacks +def receive(reactor, appid, relay_url, code, use_tor=None, on_code=None): + """ + This is a convenience API which returns a Deferred that callbacks + with a single chunk of data from another wormhole (and then closes + the wormhole). Under the hood, it's just using an instance + returned from :func:`wormhole.wormhole`. This is similar to the + `wormhole receive` command. + + :param unicode appid: our application ID + + :param unicode relay_url: the relay URL to use + + :param unicode code: a pre-existing code to use, or None + + :param bool use_tor: True if we should use Tor, False to not use it (None for default) + + :param on_code: if not None, this is called when we have a code (even if you passed in one explicitly) + :type on_code: single-argument callable + """ + wh = wormhole(appid, relay_url, reactor, use_tor) + if code is None: + code = yield wh.get_code() + else: + wh.set_code(code) + # we'll call this no matter what, even if you passed in a code -- + # maybe it should be only in the 'if' block above? + if on_code: + on_code(code) + data = yield wh.get() + data = json.loads(data.decode("utf-8")) + offer = data.get('offer', None) + if not offer: + raise Exception( + "Do not understand response: {}".format(data) + ) + msg = None + if 'message' in offer: + msg = offer['message'] + wh.send(json.dumps({"answer": {"message_ack": "ok"}}).encode("utf-8")) + + else: + raise Exception( + "Unknown offer type: {}".format(offer.keys()) + ) + + yield wh.close() + returnValue(msg) + + +@inlineCallbacks +def send(reactor, appid, relay_url, data, code, use_tor=None, on_code=None): + """ + This is a convenience API which returns a Deferred that callbacks + after a single chunk of data has been sent to another + wormhole. Under the hood, it's just using an instance returned + from :func:`wormhole.wormhole`. This is similar to the `wormhole + send` command. + + :param unicode appid: the application ID + + :param unicode relay_url: the relay URL to use + + :param unicode code: a pre-existing code to use, or None + + :param bool use_tor: True if we should use Tor, False to not use it (None for default) + + :param on_code: if not None, this is called when we have a code (even if you passed in one explicitly) + :type on_code: single-argument callable + """ + wh = wormhole(appid, relay_url, reactor, use_tor) + if code is None: + code = yield wh.get_code() + else: + wh.set_code(code) + if on_code: + on_code(code) + + wh.send( + json.dumps({ + "offer": { + "message": data + } + }).encode("utf-8") + ) + data = yield wh.get() + data = json.loads(data.decode("utf-8")) + answer = data.get('answer', None) + yield wh.close() + if answer: + returnValue(None) + else: + raise Exception( + "Unknown answer: {}".format(data) + )