diff --git a/src/allmydata/scripts/admin.py b/src/allmydata/scripts/admin.py new file mode 100755 index 0000000..d958063 --- /dev/null +++ b/src/allmydata/scripts/admin.py @@ -0,0 +1,89 @@ + +from twisted.python import usage + +class GenerateKeypairOptions(usage.Options): + def getSynopsis(self): + return "Usage: tahoe admin generate-keypair" + + def getUsage(self, width=None): + t = usage.Options.getUsage(self, width) + t += """ +Generate an ECDSA192 public/private keypair, dumped to stdout as two lines of +base32-encoded text. + +""" + return t + +def print_keypair(options): + from allmydata.util.keyutil import make_keypair + out = options.stdout + privkey_vs, pubkey_vs = make_keypair() + print >>out, "private:", privkey_vs + print >>out, "public:", pubkey_vs + +class DerivePubkeyOptions(usage.Options): + def parseArgs(self, privkey): + self.privkey = privkey + + def getSynopsis(self): + return "Usage: tahoe admin derive-pubkey PRIVKEY" + + def getUsage(self, width=None): + t = usage.Options.getUsage(self, width) + t += """ +Given a private (signing) key that was previously generated with +generate-keypair, derive the public key and print it to stdout. + +""" + return t + +def derive_pubkey(options): + out = options.stdout + err = options.stderr + from allmydata.util import keyutil + privkey_vs = options.privkey + sk, pubkey_vs = keyutil.parse_privkey(privkey_vs) + print >>out, "private:", privkey_vs + print >>out, "public:", pubkey_vs + return 0 + +class AdminCommand(usage.Options): + subCommands = [ + ("generate-keypair", None, GenerateKeypairOptions, + "Generate a public/private keypair, write to stdout."), + ("derive-pubkey", None, DerivePubkeyOptions, + "Derive a public key from a private key."), + ] + def postOptions(self): + if not hasattr(self, 'subOptions'): + raise usage.UsageError("must specify a subcommand") + def getSynopsis(self): + return "Usage: tahoe admin SUBCOMMAND" + def getUsage(self, width=None): + t = usage.Options.getUsage(self, width) + t += """ +Please run e.g. 'tahoe admin generate-keypair --help' for more details on +each subcommand. +""" + return t + +subDispatch = { + "generate-keypair": print_keypair, + "derive-pubkey": derive_pubkey, + } + +def do_admin(options): + so = options.subOptions + so.stdout = options.stdout + so.stderr = options.stderr + f = subDispatch[options.subCommand] + return f(so) + + +subCommands = [ + ["admin", None, AdminCommand, "admin subcommands: use 'tahoe admin' for a list"], + ] + +dispatch = { + "admin": do_admin, + } diff --git a/src/allmydata/scripts/runner.py b/src/allmydata/scripts/runner.py index 50f2f07..17c2f7b 100644 --- a/src/allmydata/scripts/runner.py +++ b/src/allmydata/scripts/runner.py @@ -5,7 +5,7 @@ from cStringIO import StringIO from twisted.python import usage from allmydata.scripts.common import BaseOptions -from allmydata.scripts import debug, create_node, startstop_node, cli, keygen, stats_gatherer +from allmydata.scripts import debug, create_node, startstop_node, cli, keygen, stats_gatherer, admin from allmydata.util.encodingutil import quote_output, get_argv_encoding def GROUP(s): @@ -21,6 +21,7 @@ class Options(BaseOptions, usage.Options): + create_node.subCommands + keygen.subCommands + stats_gatherer.subCommands + + admin.subCommands + GROUP("Controlling a node") + startstop_node.subCommands + GROUP("Debugging") @@ -95,6 +96,8 @@ def runner(argv, rc = startstop_node.dispatch[command](so, stdout, stderr) elif command in debug.dispatch: rc = debug.dispatch[command](so) + elif command in admin.dispatch: + rc = admin.dispatch[command](so) elif command in cli.dispatch: rc = cli.dispatch[command](so) elif command in ac_dispatch: diff --git a/src/allmydata/test/test_cli.py b/src/allmydata/test/test_cli.py index 46527f7..d655dc4 100644 --- a/src/allmydata/test/test_cli.py +++ b/src/allmydata/test/test_cli.py @@ -7,10 +7,11 @@ import simplejson from mock import patch -from allmydata.util import fileutil, hashutil, base32 +from allmydata.util import fileutil, hashutil, base32, keyutil from allmydata import uri from allmydata.immutable import upload from allmydata.dirnode import normalize +from allmydata.util import ecdsa # Test that the scripts can be imported. from allmydata.scripts import create_node, debug, keygen, startstop_node, \ @@ -24,7 +25,7 @@ from allmydata.scripts import common from allmydata.scripts.common import DEFAULT_ALIAS, get_aliases, get_alias, \ DefaultAliasMarker -from allmydata.scripts import cli, debug, runner, backupdb +from allmydata.scripts import cli, debug, runner, backupdb, admin from allmydata.test.common_util import StallMixin, ReallyEqualMixin from allmydata.test.no_network import GridTestMixin from twisted.internet import threads # CLI tests use deferToThread @@ -1012,6 +1013,55 @@ class Put(GridTestMixin, CLITestMixin, unittest.TestCase): return d +class Admin(unittest.TestCase): + def do_cli(self, *args, **kwargs): + argv = list(args) + stdin = kwargs.get("stdin", "") + stdout, stderr = StringIO(), StringIO() + d = threads.deferToThread(runner.runner, argv, run_by_human=False, + stdin=StringIO(stdin), + stdout=stdout, stderr=stderr) + def _done(res): + return stdout.getvalue(), stderr.getvalue() + d.addCallback(_done) + return d + + def test_generate_keypair(self): + d = self.do_cli("admin", "generate-keypair") + def _done( (stdout, stderr) ): + lines = stdout.split("\n") + privkey_line = lines[0].strip() + pubkey_line = lines[1].strip() + sk_header = "private: priv-v0-" + vk_header = "public: pub-v0-" + self.failUnless(privkey_line.startswith(sk_header), privkey_line) + self.failUnless(pubkey_line.startswith(vk_header), pubkey_line) + privkey_b = base32.a2b(privkey_line[len(sk_header):]) + pubkey_b = base32.a2b(pubkey_line[len(vk_header):]) + sk = ecdsa.SigningKey.from_string(privkey_b) + vk = ecdsa.VerifyingKey.from_string(pubkey_b) + self.failUnlessEqual(sk.get_verifying_key().to_string(), + vk.to_string()) + d.addCallback(_done) + return d + + def test_derive_pubkey(self): + priv1,pub1 = keyutil.make_keypair() + d = self.do_cli("admin", "derive-pubkey", priv1) + def _done( (stdout, stderr) ): + lines = stdout.split("\n") + privkey_line = lines[0].strip() + pubkey_line = lines[1].strip() + sk_header = "private: priv-v0-" + vk_header = "public: pub-v0-" + self.failUnless(privkey_line.startswith(sk_header), privkey_line) + self.failUnless(pubkey_line.startswith(vk_header), pubkey_line) + pub2 = pubkey_line[len(vk_header):] + self.failUnlessEqual("pub-v0-"+pub2, pub1) + d.addCallback(_done) + return d + + class List(GridTestMixin, CLITestMixin, unittest.TestCase): def test_list(self): self.basedir = "cli/List/list" diff --git a/src/allmydata/util/keyutil.py b/src/allmydata/util/keyutil.py new file mode 100644 index 0000000..87d3778 --- /dev/null +++ b/src/allmydata/util/keyutil.py @@ -0,0 +1,35 @@ +from allmydata.util.ecdsa import SigningKey, VerifyingKey, NIST192p +from allmydata.util import base32 + +# in base32, the signing key is 39 chars long, and the verifying key is 77. +# in base62, the signing key is 33 chars long, and the verifying key is 65. +# in base64, the signing key is 32 chars long, and the verifying key is 64. +# +# We can't use base64 because we want to reserve punctuation and preserve +# cut-and-pasteability. The base62 encoding is not significantly shorter than +# the base32 form, and the minor usability improvement is not worth the +# documentation/specification confusion of using a non-standard encoding. So +# we stick with base32. + +def make_keypair(): + privkey = SigningKey.generate(curve=NIST192p) + privkey_vs = "priv-v0-%s" % base32.b2a(privkey.to_string()) + pubkey = privkey.get_verifying_key() + pubkey_vs = "pub-v0-%s" % base32.b2a(pubkey.to_string()) + return privkey_vs, pubkey_vs + +def parse_privkey(privkey_vs): + if not privkey_vs.startswith("priv-v0-"): + raise ValueError("private key must look like 'priv-v0-...', not '%s'" % privkey_vs) + privkey_s = privkey_vs[len("priv-v0-"):] + sk = SigningKey.from_string(base32.a2b(privkey_s), curve=NIST192p) + pubkey = sk.get_verifying_key() + pubkey_vs = "pub-v0-%s" % base32.b2a(pubkey.to_string()) + return sk, pubkey_vs + +def parse_pubkey(pubkey_vs): + if not pubkey_vs.startswith("pub-v0-"): + raise ValueError("public key must look like 'pub-v0-...', not '%s'" % pubkey_vs) + pubkey_s = pubkey_vs[len("pub-v0-"):] + vk = VerifyingKey.from_string(base32.a2b(pubkey_s), curve=NIST192p) + return vk