From 2a332471d33046a937a91812941c808ba631d723 Mon Sep 17 00:00:00 2001 From: meejah Date: Sun, 14 Aug 2016 16:49:34 -0600 Subject: [PATCH 1/4] add pyflakes to 'dev' extra --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 34e06d78..0a23c6d3 100644 --- a/setup.py +++ b/setup.py @@ -40,6 +40,7 @@ "dev": [ "mock", "tox", + "pyflakes", ], }, test_suite="wormhole.test", From 026c8fd0939ddfad864400c935a35d808d5654f5 Mon Sep 17 00:00:00 2001 From: meejah Date: Sun, 14 Aug 2016 16:50:29 -0600 Subject: [PATCH 2/4] Print proper tracebacks when inlineCallbacks + yield involved --- src/wormhole/cli/cli.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/wormhole/cli/cli.py b/src/wormhole/cli/cli.py index dd0c0107..8e48bbd1 100644 --- a/src/wormhole/cli/cli.py +++ b/src/wormhole/cli/cli.py @@ -3,7 +3,7 @@ import os import time start = time.time() -import traceback +from os.path import expanduser, exists from textwrap import fill, dedent from sys import stdout, stderr from . import public_relay @@ -11,6 +11,7 @@ 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 @@ -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) From 069b76485b3014edc6af21e4f3027c2bcc798567 Mon Sep 17 00:00:00 2001 From: meejah Date: Sun, 14 Aug 2016 17:14:29 -0600 Subject: [PATCH 3/4] Add 'wormhole ssh-add' and 'wormhole ssh-send' commands --- src/wormhole/cli/cli.py | 41 +++++++++++++++ src/wormhole/cli/cmd_ssh.py | 72 +++++++++++++++++++++++++ src/wormhole/xfer_util.py | 101 ++++++++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+) create mode 100644 src/wormhole/cli/cmd_ssh.py create mode 100644 src/wormhole/xfer_util.py diff --git a/src/wormhole/cli/cli.py b/src/wormhole/cli/cli.py index 8e48bbd1..ff706a08 100644 --- a/src/wormhole/cli/cli.py +++ b/src/wormhole/cli/cli.py @@ -30,6 +30,7 @@ def __init__(self): self.cwd = os.getcwd() self.stdout = stdout self.stderr = stderr + self.tor = False # XXX? def _compose(*decorators): def decorate(f): @@ -217,3 +218,43 @@ def receive(cfg, code, **kwargs): cfg.code = None return go(cmd_receive.receive, cfg) + + +@wormhole.command(name="ssh-add") +@click.option( + "-c", "--code-length", default=2, + metavar="NUMWORDS", + help="length of code (in bytes/words)", +) +@click.option( + "--auth-file", "-f", + default=expanduser('~/.ssh/authorized_keys'), + type=click.Path(exists=False), +) +@click.pass_context +def ssh_add(ctx, code_length, auth_file): + from . import cmd_ssh + ctx.obj.code_length = code_length + ctx.obj.auth_file = auth_file + return go(cmd_ssh.add, ctx.obj) + + +@wormhole.command(name="ssh-send") +@click.argument( + "code", nargs=1, required=True, +) +@click.option( + "--yes", "-y", is_flag=True, + help="Skip confirmation prompt to send key", +) +@click.pass_obj +def ssh_send(cfg, code, yes): + from . import cmd_ssh + kind, keyid, pubkey = cmd_ssh.find_public_key() + 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.send, cfg) diff --git a/src/wormhole/cli/cmd_ssh.py b/src/wormhole/cli/cmd_ssh.py new file mode 100644 index 00000000..8b2faf38 --- /dev/null +++ b/src/wormhole/cli/cmd_ssh.py @@ -0,0 +1,72 @@ +from __future__ import print_function + +from os.path import expanduser, exists +from twisted.internet.defer import inlineCallbacks +from twisted.internet import reactor + +from .. import xfer_util + + +def find_public_key(): + """ + This looks for an appropriate SSH key to send, possibly querying + the user in the meantime. + + Returns a 3-tuple: kind, keyid, pubkey_data + """ + + # XXX FIXME don't blindly just send this one... + with open(expanduser('~/.ssh/id_rsa.pub'), '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 send(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 add(cfg, reactor=reactor): + + def on_code_created(code): + print("Now tell the other user to run:") + print() + print("wormhole ssh-send {}".format(code)) + print() + + 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] + + path = cfg.auth_file + if path == '-': + print(pubkey.strip()) + else: + if not exists(path): + print("Note: '{}' not found; will be created".format(path)) + with open(path, 'a') 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=path)) diff --git a/src/wormhole/xfer_util.py b/src/wormhole/xfer_util.py new file mode 100644 index 00000000..41f9c9f0 --- /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) + 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"}})) + + 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 + } + }) + ) + data = yield wh.get() + data = json.loads(data) + answer = data.get('answer', None) + yield wh.close() + if answer: + returnValue(None) + else: + raise Exception( + "Unknown answer: {}".format(data) + ) From fe29b3130be3d1644e49399026475a79902e5fc8 Mon Sep 17 00:00:00 2001 From: meejah Date: Sun, 14 Aug 2016 19:57:00 -0600 Subject: [PATCH 4/4] 'wormhole ssh' cleanups - move to 'wormhole ssh' group with accept/invite subcommands - change names of methods - check for permissions - use --user option (instead of --auth-file) - move implementation to cmd_ssh.py - if multiple public-keys, ask user --- src/wormhole/cli/cli.py | 47 +++++++++++++++----- src/wormhole/cli/cmd_ssh.py | 88 +++++++++++++++++++++++++++++-------- 2 files changed, 104 insertions(+), 31 deletions(-) diff --git a/src/wormhole/cli/cli.py b/src/wormhole/cli/cli.py index ff706a08..4f0a3419 100644 --- a/src/wormhole/cli/cli.py +++ b/src/wormhole/cli/cli.py @@ -3,7 +3,6 @@ import os import time start = time.time() -from os.path import expanduser, exists from textwrap import fill, dedent from sys import stdout, stderr from . import public_relay @@ -220,41 +219,65 @@ def receive(cfg, code, **kwargs): return go(cmd_receive.receive, cfg) -@wormhole.command(name="ssh-add") +@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( - "--auth-file", "-f", - default=expanduser('~/.ssh/authorized_keys'), - type=click.Path(exists=False), + "--user", "-u", + default=None, + metavar="USER", + help="Add to USER's ~/.ssh/authorized_keys", ) @click.pass_context -def ssh_add(ctx, code_length, auth_file): +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.auth_file = auth_file - return go(cmd_ssh.add, ctx.obj) + ctx.obj.ssh_user = user + return go(cmd_ssh.invite, ctx.obj) -@wormhole.command(name="ssh-send") +@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_send(cfg, code, yes): +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() + 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.send, cfg) + return go(cmd_ssh.accept, cfg) diff --git a/src/wormhole/cli/cmd_ssh.py b/src/wormhole/cli/cmd_ssh.py index 8b2faf38..6b5c4eeb 100644 --- a/src/wormhole/cli/cmd_ssh.py +++ b/src/wormhole/cli/cmd_ssh.py @@ -1,23 +1,55 @@ from __future__ import print_function -from os.path import expanduser, exists +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 -def find_public_key(): +def find_public_key(hint=None): """ This looks for an appropriate SSH key to send, possibly querying - the user in the meantime. + 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 """ - # XXX FIXME don't blindly just send this one... - with open(expanduser('~/.ssh/id_rsa.pub'), 'r') as f: - pubkey = f.read() + if hint is None: + hint = expanduser('~/.ssh/') + else: + if not exists(hint): + raise RuntimeError("Can't find '{}'".format(hint)) + + pubkeys = [f for f in os.listdir(hint) if f.endswith('.pub')] + if len(pubkeys) == 0: + raise RuntimeError("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] @@ -26,7 +58,7 @@ def find_public_key(): @inlineCallbacks -def send(cfg, reactor=reactor): +def accept(cfg, reactor=reactor): yield xfer_util.send( reactor, u"lothar.com/wormhole/ssh-add", @@ -39,14 +71,35 @@ def send(cfg, reactor=reactor): @inlineCallbacks -def add(cfg, reactor=reactor): +def invite(cfg, reactor=reactor): def on_code_created(code): print("Now tell the other user to run:") print() - print("wormhole ssh-send {}".format(code)) + print("wormhole ssh accept {}".format(code)) print() + if cfg.ssh_user is None: + ssh_path = expanduser('~/.ssh/'.format(cfg.ssh_user)) + else: + ssh_path = '/home/{}/.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", @@ -60,13 +113,10 @@ def on_code_created(code): kind = parts[0] keyid = 'unknown' if len(parts) <= 2 else parts[2] - path = cfg.auth_file - if path == '-': - print(pubkey.strip()) - else: - if not exists(path): - print("Note: '{}' not found; will be created".format(path)) - with open(path, 'a') 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=path)) + 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))