Commits

ojno committed 8ad31ed

Add support for SSH keys

Comments (0)

Files changed (7)

 you commit. The signature is embedded directly in the changeset
 itself; there wont be any extra commits.
 
-It currently uses uses GnuPG_ and OpenSSL_, but it should be easy to
-extend it to use other programs in the future.
+It currently uses uses GnuPG_, OpenSSL_ or SSH keys (through
+Paramiko_), but it should be easy to extend it to use other programs
+in the future. 
 
 Activate it and see ``hg help commitsigs`` for more information.
 
 
 .. _GnuPG: http://gnupg.org/
 .. _OpenSSL: http://openssl.org/
+.. _Paramiko: http://www.lag.net/paramiko/
 .. _aragost Trifork: http://aragost.com/mercurial/
 
 """sign changesets upon commit
 
-This extension will use GnuPG or OpenSSL to sign the changeset hash
-upon each commit and embed the signature directly in the changelog.
+This extension will use GnuPG, OpenSSL or SSH keys to sign the
+changeset hash upon each commit and embed the signature directly in
+the changelog. 
 
 Use 'hg log --debug' to see the extra meta data for each changeset,
 including the signature.
   [commitsigs]
   scheme = gnupg
 
-The two recognized schemes are ``gnupg`` (the default) and
-``openssl``. If you use ``gnupg``, then you normally wont have to
+The three recognized schemes are ``gnupg`` (the default), ``openssl''
+and ``ssh''. If you use ``gnupg``, then you normally wont have to
 configure other options. However, if ``gpg`` is not in your path or if
 you have multiple private keys, then you may want to set the following
 options::
 You must use the ``c_rehash`` program from OpenSSL to prepare the
 directoy with trusted certificates for use by OpenSSL. Otherwise
 OpenSSL wont be able to lookup the certificates.
+
+The ``ssh'' scheme can use keys either from an SSH agent, selecting
+the key by ID, (last eight hex digits of fingerprint) or from a private
+key file. If a private key file is specified and the key is encrypted,
+you will be prompted for the passphrase on each commit.
+
+This scheme is designed for use with mercurial-server, or other
+shared-SSH systems that use public keys to authenticate users. This
+way, when verifying signatures, the username on the changeset can be
+checked to be the username of the key, authenticating commits. The
+verify hook looks for a public key file in the given list of
+directories named after the username on the commit.
+Example configuration for clients::
+
+  [commitsigs]
+  scheme = ssh
+  ssh.type = id
+  ssh.id = ABCD1234
+
+  [commitsigs]
+  scheme = ssh
+  ssh.type = file
+  ssh.file = /home/user/.ssh/id_rsa
+
+Example configuration for servers::
+
+  [commitsigs]
+  scheme = ssh
+  ssh.publickeydirs = /etc/mercurial-server/keys/root /etc/mercurial-server/keys/users
+
+  
+  
 """
 
-import os, tempfile, subprocess, binascii, shlex
+import os, os.path, tempfile, subprocess, binascii, shlex
+from base64 import b64encode, b64decode
+import paramiko
 
 from mercurial import (util, cmdutil, extensions, revlog, error,
                        encoding, changelog)
 from mercurial.i18n import _
 
 
-CONFIG = {
+CONFIG_DEFAULTS = {
     'scheme': 'gnupg',
     'gnupg.path': 'gpg',
     'gnupg.flags': [],
     'openssl.path': 'openssl',
     'openssl.capath': '',
-    'openssl.certificate': ''
+    'openssl.certificate': '',
+    'ssh.type': 'id',
+    'ssh.key': '',
+    'ssh.publickeydirs': ["hi"]
     }
 
+def CONFIG(ui, key):
+    val = CONFIG_DEFAULTS[key]
+    uival = ui.config('commitsigs', key, val)
+    if isinstance(val, list) and not isinstance(uival, list):
+        return shlex.split(uival)
+    else:
+        return uival
 
-def gnupgsign(msg):
-    cmd = [CONFIG["gnupg.path"], "--detach-sign"] + CONFIG["gnupg.flags"]
+
+def gnupgsign(ui, msg):
+    cmd = [CONFIG(ui, "gnupg.path"), "--detach-sign"] + CONFIG(ui, "gnupg.flags")
     p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
     sig = p.communicate(msg)[0]
     return binascii.b2a_base64(sig).strip()
 
 
-def gnupgverify(msg, sig, quiet=False):
+def gnupgverify(ui, msg, sig, user, quiet=False):
     sig = binascii.a2b_base64(sig)
     try:
         fd, filename = tempfile.mkstemp(prefix="hg-", suffix=".sig")
         fp.close()
         stderr = quiet and subprocess.PIPE or None
 
-        cmd = [CONFIG["gnupg.path"]] + CONFIG["gnupg.flags"] + \
+        cmd = [CONFIG(ui, "gnupg.path")] + CONFIG(ui, "gnupg.flags") + \
             ["--status-fd", "1", "--verify", filename, '-']
         p = subprocess.Popen(cmd, stdin=subprocess.PIPE,
                              stdout=subprocess.PIPE, stderr=stderr)
             pass
 
 
-def opensslsign(msg):
+def opensslsign(ui, msg):
     try:
         fd, filename = tempfile.mkstemp(prefix="hg-", suffix=".msg")
         fp = os.fdopen(fd, 'wb')
         fp.close()
 
 
-        cmd = [CONFIG["openssl.path"], "smime", "-sign", "-outform", "pem",
-               "-signer", CONFIG["openssl.certificate"], "-in", filename]
+        cmd = [CONFIG(ui, "openssl.path"), "smime", "-sign", "-outform", "pem",
+               "-signer", CONFIG(ui, "openssl.certificate"), "-in", filename]
         p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
         sig = p.communicate()[0]
         return sig
             pass
 
 
-def opensslverify(msg, sig, quiet=False):
+def opensslverify(ui, msg, sig, user, quiet=False):
     try:
         fd, filename = tempfile.mkstemp(prefix="hg-", suffix=".msg")
         fp = os.fdopen(fd, 'wb')
         fp.write(msg)
         fp.close()
 
-        cmd = [CONFIG["openssl.path"], "smime",
-               "-verify", "-CApath", CONFIG["openssl.capath"],
+        cmd = [CONFIG(ui, "openssl.path"), "smime",
+               "-verify", "-CApath", CONFIG(ui, "openssl.capath"),
                "-inform", "pem", "-content", filename]
         p = subprocess.Popen(cmd, stdin=subprocess.PIPE,
                              stdout=subprocess.PIPE,
         except OSError:
             pass
 
+def get_ssh_key(ui):
+    mode = CONFIG(ui, "ssh.type")
+    if mode not in ("id", "file"):
+        raise error.Abort("invalid ssh key type specified for commitsigs")
+    if mode == "id":
+        key_id = CONFIG(ui, "ssh.key")
+        if not key_id:
+            raise error.Abort("no ssh key ID given")
+        try:
+            agent = paramiko.Agent()
+            for key in agent.keys:
+                if paramiko.util.hexify(key.get_fingerprint())[-8:] == key_id:
+                    return key
+        except paramiko.SSHException:
+            raise error.Abort("error connecting to ssh agent")
+        raise error.Abort("key not found matching given ID")
+    elif mode == "file":
+        key_file = CONFIG(ui, "ssh.key")
+        if not key_file:
+            raise error.Abort("no ssh key filename given")
+        try:
+            f = open(key_file)
+            line = f.readline()[:-1]
+            if line == "-----BEGIN RSA PRIVATE KEY-----":
+                try:
+                    key = paramiko.RSAKey.from_private_key_file(key_file)
+                    return key
+                except paramiko.PasswordRequiredException:
+                    try:
+                        key = paramiko.RSAKey.from_private_key_file(key_file, password=ui.getpass("Enter passphrase for %s: " % key_file))
+                        return key
+                    except paramiko.SSHException:
+                        raise error.Abort("couldn't read private key %s - incorrect passphrase?" % key_file) 
+            elif line == "-----BEGIN DSA PRIVATE KEY-----":
+                try:
+                    key = paramiko.DSSKey.from_private_key_file(key_file)
+                    return key
+                except paramiko.PasswordRequiredException:
+                    try:
+                        key = paramiko.DSSKey.from_private_key_file(key_file, password=ui.getpass("Enter passphrase for %s: " % key_file))
+                        return key
+                    except paramiko.SSHException:
+                        raise error.Abort("couldn't read private key %s - incorrect passphrase?" % key_file)
+            else:
+                raise error.Abort("%s is not a private key file" % key_file)
+        except IOError:
+            raise error.Abort("couldn't read private key file %s" % key_file)
+
+def sshsign(ui, data):
+    key = get_ssh_key(ui)
+    message = key.sign_ssh_data(paramiko.util.rng, data)
+    return b64encode(str(message))
+
+def find_ssh_public_key(ui, username):
+    dirs = CONFIG(ui, "ssh.publickeydirs")
+    for keydir in dirs:
+        if not os.path.isdir(keydir):
+            continue
+        for keyfile in os.listdir(keydir):
+            path = os.path.join(keydir, keyfile)
+            if not os.path.isfile(path):
+                continue
+            if keyfile == username:
+                try:
+                    f = open(path)
+                    data = f.read().split()
+                    keytype, keydata = data[:2]
+                    if keytype not in ("ssh-rsa", "ssh-dss"):
+                        continue
+                    if keytype == "ssh-rsa":
+                        key = paramiko.RSAKey(data=b64decode(keydata))
+                        return key
+                    elif keytype == "ssh-dss":
+                        key = paramiko.DSSKey(data=b64decode(keydata))
+                        return key
+                except:
+                    continue
+    return None
+                    
+def sshverify(ui, data, sig, user, quiet=True):
+    message = paramiko.Message(b64decode(sig))
+    key = find_ssh_public_key(ui, user)
+    if not key:
+        return False
+    return key.verify_ssh_sig(data, message)
 
 def chash(manifest, files, desc, p1, p2, user, date, extra):
     """Compute changeset hash from the changeset pieces."""
         ctx = repo[rev]
         h = ctxhash(ctx)
         extra = ctx.extra()
+        user = ctx.user()
         sig = extra.get('signature')
         if not sig:
             msg = _("** no signature")
             try:
                 scheme, sig = sig.split(":", 1)
                 verifyfunc = sigschemes[scheme][1]
-                if verifyfunc(hex(h), sig, quiet=True):
+                if verifyfunc(ui, hex(h), sig, user, quiet=True):
                     msg = _("good %s signature") % scheme
                 else:
                     msg = _("** bad %s signature on %s") % (scheme, short(h))
                     retcode = max(retcode, 3)
             except Exception, e:
-                msg = _("** exception while verifying %s signature: %s") \
-                    % (scheme, e)
+                try:
+                    msg = _("** exception while verifying %s signature: %s") % (scheme, e)
+                except:
+                    msg = _("** exception while verifying signature: %s") % e
                 retcode = max(retcode, 2)
         ui.write("%d:%s: %s\n" % (ctx.rev(), ctx, msg))
     return retcode
         raise error.Abort(_("could not verify all new changesets"))
 
 sigschemes = {'gnupg': (gnupgsign, gnupgverify),
-              'openssl': (opensslsign, opensslverify)}
+              'openssl': (opensslsign, opensslverify),
+              'ssh': (sshsign, sshverify)}
 
 def uisetup(ui):
-    for key in CONFIG:
-        val = CONFIG[key]
-        uival = ui.config('commitsigs', key, val)
-        if isinstance(val, list) and not isinstance(uival, list):
-            CONFIG[key] = shlex.split(uival)
-        else:
-            CONFIG[key] = uival
-    if CONFIG['scheme'] not in sigschemes:
+    if CONFIG(ui, 'scheme') not in sigschemes:
         raise util.Abort(_("unknown signature scheme: %s")
-                         % CONFIG['scheme'])
+                         % CONFIG(ui, 'scheme'))
 
-def extsetup():
+def extsetup(ui):
 
     def add(orig, self, manifest, files, desc, transaction,
             p1=None, p2=None, user=None, date=None, extra={}):
         h = chash(manifest, files, desc, p1, p2, user, date, extra)
-        scheme = CONFIG['scheme']
+        scheme = CONFIG(ui, 'scheme')
         signfunc = sigschemes[scheme][0]
-        extra['signature'] = "%s:%s" % (scheme, signfunc(hex(h)))
+        extra['signature'] = "%s:%s" % (scheme, signfunc(ui, hex(h)))
         return orig(self, manifest, files, desc, transaction,
                     p1, p2, user, date, extra)
 
     except ImportError:
         return False
 
+def has_paramiko():
+    try:
+        import paramiko
+        return (paramiko.__version_info__ >= (1, 7, 7, 1))
+    except ImportError:
+        return False
+
 def has_outer_repo():
     return matchoutput('hg root 2>&1', r'')
 
     "symlink": (has_symlink, "symbolic links"),
     "tla": (has_tla, "GNU Arch tla client"),
     "unix-permissions": (has_unix_permissions, "unix-style permissions"),
+    "paramiko": (has_paramiko, "Paramiko SSH2 library for Python"),
 }
 
 def list_features():
+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC4IhtkxkY2mHg3UnCHbVQtp9q+LbMiQ09dDNyyzAgPRp1O63XemNdHi5ncEBk6YdwvhXXO51dY9A0wBNkkDiorpIOYMT1KahKOscREpMlmkNgDfuhY0WSjD6yfNN2vtP0HpbuSbLoWVcCu98rcmNrNUVvkME4aCRuvVytP+VMros/8WQ+bGAhkSpCeXsLLjuVZn1Xcwt5EZJBzMHcULeNpXFnZ0Y/mg0BkYGewB/CAvwfzS3ar8H1ya882+SxEYq8uj2RS3tXLri5/vaX/3mojN0Rv51ttn1MeBXHhe3s+WoKjYiooFJF54GPU3KZFeE45YVzrIn0qp63PNxL7w8W1 alice

tests/ssh/alice.private

+-----BEGIN RSA PRIVATE KEY-----
+MIIEpgIBAAKCAQEAuCIbZMZGNph4N1Jwh21ULafavi2zIkNPXQzcsswID0adTut1
+3pjXR4uZ3BAZOmHcL4V1zudXWPQNMATZJA4qK6SDmDE9SmoSjrHERKTJZpDYA37o
+WNFkow+snzTdr7T9B6W7kmy6FlXArvfK3JjazVFb5DBOGgkbr1crT/lTK6LP/FkP
+mxgIZEqQnl7Cy47lWZ9V3MLeRGSQczB3FC3jaVxZ2dGP5oNAZGBnsAfwgL8H80t2
+q/B9cmvPNvksRGKvLo9kUt7Vy64uf72l/95qIzdEb+dbbZ9THgVx4Xt7PlqCo2Iq
+KBSReeBj1NymRXhOOWFc6yJ9KqetzzcS+8PFtQIDAQABAoIBAQCTtli/aQeeeIXH
+64x2N9BVp9gkxEh17gVWoiDSFjdI+d7sFpvB7F6W+Hh/PzLd0O9v9+JgDtnVdEK4
+CMugzvQpeppOlwGSFrG4YwcOAhvG+d8wPrhpt+DBpqtWNHS6pbhuUBe/CRXnIPen
+5QSKHt035FKRJechn7jj2kvWpi6AS4mFZrlnnt5Y3z3fvVNBv2I1goq1SupfA+jH
+uiuRJzArVmq5uF2zehV3nDwwmvvmWc4fo9Ri1T5xAuvnaiAD3ID6C4vCaeM6c1DI
+NSlKaZz+6WsqiZtFCFbvkcKTyLFPrRL6sfOWkUT4IMkRz7ocKdMrTMAJVZidyn3F
+VfyUl2mBAoGBAOAYkR+NFJhz/YL+Wd+xkKyZF6eY9Ft9FGFlyHb79ND9jvab6fy8
+I6cQ1G5z2N/EAOuSEKZndZowZAyB8ks8XkBk4+aoxYb/C95T/cY9N99MtzQUabbY
+4Kqr5U8Ttos/U9rEbjVZGPLDfvEyWUUZKSNlM7Rko09FmmVQWKtF6LtRAoGBANJZ
+DXy81yVhfFIuMBe5V/GxyrD7li8iM+TddU6K80m+bBQnTjubJemLjiLzjpbhd/EJ
+J1EO12EW/5DfcEYFbx2aisAfCFhF/pc0+2YvYNOBUu9Bg5gaSvaC5GD8wIo+tIbH
+s8b017TBAbiBuqCZ+f8MT/YI8ChbPpoTvqhS0sMlAoGBANFbwkESwdoICyDVVcuZ
+jUrin+nRCQFsIp03xQf8LeUN7gFQ+lJGxpwvKfeivUuYRy/Nv0ZnQ8RwTxgsUtud
+I4TPfOciZ5/OKR1lpKIO0WMJveKm63iKt6Jbg5jUFueSm1m3yCqI+bjbkX3vBt16
+Oye5RYvTtYq6wRrD7ILehiZxAoGBAJkwIZJmNIkUpeVznbKpWFK8mFCr6IQK9KAI
+aBX7O5LJCwziUyc1pjafW7Q4i+915FO1xtxRYqlihlyLXMihzHpSwhmIgBtJXk/g
+VfXlKy7dT/jOTXfILi+4w3memNoVMIO3jEWoqi8JIKeuUqwDdv03ikQz3jKFwOGU
+35TWPIfBAoGBAKDj4NufG4i1xv9rWP9oiuatfxttBX/5DMSx3a8qs2o0VMwObKZH
+geUpt31G/oQ90PvUb7ccfd7LJ43BK9polcJGbOOF2l3KKua+JDtNJTP0tDaSVTJY
+rUHtqAt8I7MXDekBWiit3qe5wfcOl4Pu0fWOv7lOQ98pmlpUiH7OQ+uI
+-----END RSA PRIVATE KEY-----

tests/test-commitsigs

 #!/bin/sh
 
-"$TESTDIR/hghave" gpg openssl || exit 80
+TESTDIR=$PWD
+HGRCPATH=$TESTDIR/repo/.hg/hgrc
+
+"$TESTDIR/hghave" gpg openssl paramiko || exit 80
 
 hgverifysigs () {
     hg verifysigs > output
 echo "Hello" > a.txt
 hg add a.txt
 echo "% Commit with no signature"
-hg commit -m "Unsigned" -d '1000 0'
+hg commit -u "alice" -m "Unsigned" -d '1000 0'
 
-cat >> $HGRCPATH <<EOF
+cat > $HGRCPATH <<EOF
 [extensions]
 commitsigs = $TESTDIR/../commitsigs.py
 
+[ui]
+username = alice
+
 [commitsigs]
+scheme = gnupg
 gnupg.flags = --no-permission-warning --no-secmem-warning \
               --homedir $TESTDIR/gpg
 
 hg commit -m "GnuPG" -d '2000 0'
 hgverifysigs
 
-cat >> $HGRCPATH <<EOF
+cat > $HGRCPATH <<EOF
+[extensions]
+commitsigs = $TESTDIR/../commitsigs.py
+
+[ui]
+username = alice
+
 [commitsigs]
 scheme = openssl
 openssl.certificate = $TESTDIR/openssl/alice.pem
 openssl.capath = $TESTDIR/openssl
+gnupg.flags = --no-permission-warning --no-secmem-warning \
+              --homedir $TESTDIR/gpg
 EOF
 
 echo "!" >> a.txt
 echo "% Commit with OpenSSL signature"
-hg commit -m "OpenSSL" -d '3000 0'
+hg commit -u "alice" -m "OpenSSL" -d '3000 0'
 
 hgverifysigs
 
-cat >> .hg/hgrc <<EOF
+cat > $HGRCPATH <<EOF
+[paths]
+default = $TESTDIR/repo
+
+[extensions]
+commitsigs = $TESTDIR/../commitsigs.py
+
+[ui]
+username = alice
+
+[commitsigs]
+scheme = ssh
+ssh.type = file
+ssh.key = $TESTDIR/ssh/alice.private
+ssh.publickeydirs = $TESTDIR/ssh
+openssl.certificate = $TESTDIR/openssl/alice.pem
+openssl.capath = $TESTDIR/openssl
+gnupg.flags = --no-permission-warning --no-secmem-warning \
+              --homedir $TESTDIR/gpg
+
+EOF
+
+echo " Now with 50% more signature types" >> a.txt
+echo "% Commit with SSH signature"
+hg commit -m "SSH" -d '4000 0'
+hgverifysigs
+
+cat >> $HGRCPATH <<EOF
 [hooks]
 pretxncommit = python:$TESTDIR/../commitsigs.py:verifyheadshook
 pretxnchangegroup = python:$TESTDIR/../commitsigs.py:verifyallhook
 
 echo "% push with pretxnchangegroup hook"
 hg clone -q . ../repo2
+cp .hg/hgrc ../repo2/.hg/hgrc
 cd ../repo2
 echo "!" >> a.txt
-hg commit --config extensions.commitsigs=! -m "No signature" -d '5000 0'
+mv .hg/hgrc .hg/hgrc.old
+hg commit -u "alice" -m "No signature" -d '5000 0'
+mv .hg/hgrc.old .hg/hgrc
 hg push -q 2>&1 | sed 's|:[0-9a-f]\+:|:XXXXXXXXXXXX:|'
 
 
 
 echo >> a.txt
 echo "% Commit with trailing whitespace"
-hg commit -l logmsg -d '3500 0'
+hg commit -m nottoolong -d '6000 0'
 
 hgverifysigs
 
-cat >> $HGRCPATH <<EOF
+cat > $HGRCPATH <<EOF
+[extensions]
+commitsigs = $TESTDIR/../commitsigs.py
+
 [commitsigs]
 scheme = x
 EOF
 
 echo "% unknown scheme"
+cd ../repo
 hg status
 
 true

tests/test-commitsigs.out

 2:XXXXXXXXXXXX: good openssl signature
 % hg verifysigs --only-heads (exit code: 0)
 2:XXXXXXXXXXXX: good openssl signature
+% Commit with SSH signature
+% hg verifysigs (exit code: 1)
+0:XXXXXXXXXXXX: ** no signature
+1:XXXXXXXXXXXX: good gnupg signature
+2:XXXXXXXXXXXX: good openssl signature
+3:XXXXXXXXXXXX: good ssh signature
+% hg verifysigs --only-heads (exit code: 0)
+3:XXXXXXXXXXXX: good ssh signature
 % commit with pretxncommit hook
-3:XXXXXXXXXXXX: ** no signature
+4:XXXXXXXXXXXX: ** no signature
 error: pretxncommit hook failed: could not verify all new changesets
 transaction abort!
 rollback completed
 abort: could not verify all new changesets
 % push with pretxnchangegroup hook
-3:XXXXXXXXXXXX: ** no signature
+4:XXXXXXXXXXXX: ** no signature
 error: pretxnchangegroup hook failed: could not verify all new changesets
 transaction abort!
 rollback completed
 abort: could not verify all new changesets
 % Commit with trailing whitespace
+5:7b8088cb1967: good ssh signature
 % hg verifysigs (exit code: 1)
 0:XXXXXXXXXXXX: ** no signature
 1:XXXXXXXXXXXX: good gnupg signature
 2:XXXXXXXXXXXX: good openssl signature
-3:XXXXXXXXXXXX: ** no signature
-4:XXXXXXXXXXXX: good openssl signature
+3:XXXXXXXXXXXX: good ssh signature
+4:XXXXXXXXXXXX: ** no signature
+5:XXXXXXXXXXXX: good ssh signature
 % hg verifysigs --only-heads (exit code: 0)
-4:XXXXXXXXXXXX: good openssl signature
+5:XXXXXXXXXXXX: good ssh signature
 % unknown scheme
 abort: unknown signature scheme: x