'tahoe admin generate-keypair/derive-pubkey': add Ed25519 keypair commands
authorBrian Warner <warner@lothar.com>
Mon, 12 Mar 2012 22:02:58 +0000 (15:02 -0700)
committerBrian Warner <warner@lothar.com>
Wed, 14 Mar 2012 01:24:32 +0000 (18:24 -0700)
Also add parse_privkey/parse_pubkey tools to util.keyutil

src/allmydata/scripts/admin.py [new file with mode: 0644]
src/allmydata/scripts/runner.py
src/allmydata/test/test_cli.py
src/allmydata/util/keyutil.py [new file with mode: 0644]

diff --git a/src/allmydata/scripts/admin.py b/src/allmydata/scripts/admin.py
new file mode 100644 (file)
index 0000000..581224d
--- /dev/null
@@ -0,0 +1,87 @@
+
+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 a public/private keypair, dumped to stdout as two lines of ASCII..
+
+"""
+        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
+    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,
+    }
index 3147fb5a9e748bb70986000d30894be28da1e16d..e66f8d33b95f38f20063671e04c40a3c034a3a8f 100644 (file)
@@ -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_io_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:
index 464e2b568c1c1ab17ab38fbff544ed4a4a100fda..59e2c6aa2d1579c2c214fbae1247a12591183b4a 100644 (file)
@@ -7,12 +7,13 @@ 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.interfaces import MDMF_VERSION, SDMF_VERSION
 from allmydata.mutable.publish import MutableData
 from allmydata.dirnode import normalize
+from pycryptopp.publickey import ed25519
 
 # Test that the scripts can be imported.
 from allmydata.scripts import create_node, debug, keygen, startstop_node, \
@@ -1366,6 +1367,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 = [line.strip() for line in stdout.splitlines()]
+            privkey_bits = lines[0].split()
+            pubkey_bits = lines[1].split()
+            sk_header = "private:"
+            vk_header = "public:"
+            self.failUnlessEqual(privkey_bits[0], sk_header, lines[0])
+            self.failUnlessEqual(pubkey_bits[0], vk_header, lines[1])
+            self.failUnless(privkey_bits[1].startswith("priv-v0-"), lines[0])
+            self.failUnless(pubkey_bits[1].startswith("pub-v0-"), lines[1])
+            sk_bytes = base32.a2b(keyutil.remove_prefix(privkey_bits[1], "priv-v0-"))
+            sk = ed25519.SigningKey(sk_bytes)
+            vk_bytes = base32.a2b(keyutil.remove_prefix(pubkey_bits[1], "pub-v0-"))
+            self.failUnlessEqual(sk.get_verifying_key_bytes(), vk_bytes)
+        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 (file)
index 0000000..ee28bd7
--- /dev/null
@@ -0,0 +1,39 @@
+import os
+from pycryptopp.publickey import ed25519
+from allmydata.util.base32 import a2b, b2a
+
+BadSignatureError = ed25519.BadSignatureError
+
+class BadPrefixError(Exception):
+    pass
+
+def remove_prefix(s_bytes, prefix):
+    if not s_bytes.startswith(prefix):
+        raise BadPrefixError("did not see expected '%s' prefix" % (prefix,))
+    return s_bytes[len(prefix):]
+
+# in base32, keys are 52 chars long (both signing and verifying keys)
+# in base62, keys is 43 chars long
+# in base64, keys is 43 chars long
+#
+# We can't use base64 because we want to reserve punctuation and preserve
+# cut-and-pasteability. The base62 encoding is shorter than the base32 form,
+# but the minor usability improvement is not worth the documentation and
+# specification confusion of using a non-standard encoding. So we stick with
+# base32.
+
+def make_keypair():
+    sk_bytes = os.urandom(32)
+    sk = ed25519.SigningKey(sk_bytes)
+    vk_bytes = sk.get_verifying_key_bytes()
+    return ("priv-v0-"+b2a(sk_bytes), "pub-v0-"+b2a(vk_bytes))
+
+def parse_privkey(privkey_vs):
+    sk_bytes = a2b(remove_prefix(privkey_vs, "priv-v0-"))
+    sk = ed25519.SigningKey(sk_bytes)
+    vk_bytes = sk.get_verifying_key_bytes()
+    return (sk, "pub-v0-"+b2a(vk_bytes))
+
+def parse_pubkey(pubkey_vs):
+    vk_bytes = a2b(remove_prefix(pubkey_vs, "pub-v0-"))
+    return ed25519.VerifyingKey(vk_bytes)