gpg.py
390 lines
| 11.0 KiB
| text/x-python
|
PythonLexer
/ hgext / gpg.py
|
r1681 | # Copyright 2005, 2006 Benoit Boissinot <benoit.boissinot@ens-lyon.org> | ||
# | ||||
|
r8225 | # This software may be used and distributed according to the terms of the | ||
|
r10263 | # GNU General Public License version 2 or any later version. | ||
|
r1681 | |||
|
r8934 | '''commands to sign and verify changesets''' | ||
|
r8873 | |||
|
r29124 | |||
import binascii | ||||
import os | ||||
|
r29205 | |||
from mercurial.i18n import _ | ||||
|
r46729 | from mercurial.node import ( | ||
bin, | ||||
hex, | ||||
short, | ||||
) | ||||
|
r29124 | from mercurial import ( | ||
cmdutil, | ||||
error, | ||||
|
r40329 | help, | ||
|
r29124 | match, | ||
|
r30925 | pycompat, | ||
|
r32337 | registrar, | ||
|
r29124 | ) | ||
|
r37138 | from mercurial.utils import ( | ||
dateutil, | ||||
procutil, | ||||
) | ||||
|
r1592 | |||
|
r14299 | cmdtable = {} | ||
|
r32337 | command = registrar.command(cmdtable) | ||
|
r29841 | # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for | ||
|
r25186 | # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should | ||
# be specifying the version(s) of Mercurial they are tested with, or | ||||
# leave the attribute unspecified. | ||||
|
r43347 | testedwith = b'ships-with-hg-core' | ||
|
r14299 | |||
|
r34502 | configtable = {} | ||
configitem = registrar.configitem(configtable) | ||||
|
r43346 | configitem( | ||
|
r46554 | b'gpg', | ||
b'cmd', | ||||
default=b'gpg', | ||||
|
r34502 | ) | ||
|
r43346 | configitem( | ||
|
r46554 | b'gpg', | ||
b'key', | ||||
default=None, | ||||
|
r34503 | ) | ||
|
r43346 | configitem( | ||
|
r46554 | b'gpg', | ||
b'.*', | ||||
default=None, | ||||
generic=True, | ||||
|
r34771 | ) | ||
|
r34502 | |||
|
r40329 | # Custom help category | ||
|
r43347 | _HELP_CATEGORY = b'gpg' | ||
|
r42421 | help.CATEGORY_ORDER.insert( | ||
|
r43346 | help.CATEGORY_ORDER.index(registrar.command.CATEGORY_HELP), _HELP_CATEGORY | ||
|
r42421 | ) | ||
|
r43347 | help.CATEGORY_NAMES[_HELP_CATEGORY] = b'Signing changes (GPG)' | ||
|
r40329 | |||
|
r43346 | |||
|
r8778 | class gpg(object): | ||
|
r1592 | def __init__(self, path, key=None): | ||
self.path = path | ||||
|
r43347 | self.key = (key and b" --local-user \"%s\"" % key) or b"" | ||
|
r1592 | |||
def sign(self, data): | ||||
|
r43347 | gpgcmd = b"%s --sign --detach-sign%s" % (self.path, self.key) | ||
|
r37138 | return procutil.filter(data, gpgcmd) | ||
|
r1592 | |||
def verify(self, data, sig): | ||||
|
r47856 | """returns of the good and bad signatures""" | ||
|
r2231 | sigfile = datafile = None | ||
|
r1592 | try: | ||
|
r1681 | # create temporary files | ||
|
r43347 | fd, sigfile = pycompat.mkstemp(prefix=b"hg-gpg-", suffix=b".sig") | ||
|
r43906 | fp = os.fdopen(fd, 'wb') | ||
|
r1592 | fp.write(sig) | ||
fp.close() | ||||
|
r43347 | fd, datafile = pycompat.mkstemp(prefix=b"hg-gpg-", suffix=b".txt") | ||
|
r43906 | fp = os.fdopen(fd, 'wb') | ||
|
r1592 | fp.write(data) | ||
fp.close() | ||||
r43663 | gpgcmd = ( | |||
b"%s --logger-fd 1 --status-fd 1 --verify \"%s\" \"%s\"" | ||||
|
r46554 | % ( | ||
self.path, | ||||
sigfile, | ||||
datafile, | ||||
) | ||||
|
r43346 | ) | ||
|
r43347 | ret = procutil.filter(b"", gpgcmd) | ||
|
r2231 | finally: | ||
|
r1592 | for f in (sigfile, datafile): | ||
try: | ||||
|
r10282 | if f: | ||
os.unlink(f) | ||||
|
r16688 | except OSError: | ||
|
r10282 | pass | ||
|
r1592 | keys = [] | ||
key, fingerprint = None, None | ||||
for l in ret.splitlines(): | ||||
# see DETAILS in the gnupg documentation | ||||
# filter the logger output | ||||
|
r43347 | if not l.startswith(b"[GNUPG:]"): | ||
|
r1592 | continue | ||
l = l[9:] | ||||
|
r43347 | if l.startswith(b"VALIDSIG"): | ||
|
r1592 | # fingerprint of the primary key | ||
fingerprint = l.split()[10] | ||||
|
r43347 | elif l.startswith(b"ERRSIG"): | ||
key = l.split(b" ", 3)[:2] | ||||
key.append(b"") | ||||
|
r19441 | fingerprint = None | ||
|
r43346 | elif ( | ||
|
r43347 | l.startswith(b"GOODSIG") | ||
or l.startswith(b"EXPSIG") | ||||
or l.startswith(b"EXPKEYSIG") | ||||
or l.startswith(b"BADSIG") | ||||
|
r43346 | ): | ||
|
r1592 | if key is not None: | ||
keys.append(key + [fingerprint]) | ||||
|
r43347 | key = l.split(b" ", 2) | ||
|
r1592 | fingerprint = None | ||
if key is not None: | ||||
keys.append(key + [fingerprint]) | ||||
|
r19442 | return keys | ||
|
r1592 | |||
|
r43346 | |||
|
r1592 | def newgpg(ui, **opts): | ||
|
r1681 | """create a new gpg instance""" | ||
|
r43347 | gpgpath = ui.config(b"gpg", b"cmd") | ||
|
r43906 | gpgkey = opts.get('key') | ||
|
r1592 | if not gpgkey: | ||
|
r43347 | gpgkey = ui.config(b"gpg", b"key") | ||
|
r1592 | return gpg(gpgpath, gpgkey) | ||
|
r43346 | |||
|
r1681 | def sigwalk(repo): | ||
""" | ||||
walk over every sigs, yields a couple | ||||
((node, version, sig), (filename, linenumber)) | ||||
""" | ||||
|
r43346 | |||
|
r1681 | def parsefile(fileiter, context): | ||
ln = 1 | ||||
for l in fileiter: | ||||
if not l: | ||||
continue | ||||
|
r43347 | yield (l.split(b" ", 2), (context, ln)) | ||
|
r10394 | ln += 1 | ||
|
r1681 | |||
|
r8210 | # read the heads | ||
|
r43347 | fl = repo.file(b".hgsigs") | ||
|
r8210 | for r in reversed(fl.heads()): | ||
|
r46729 | fn = b".hgsigs|%s" % short(r) | ||
|
r1681 | for item in parsefile(fl.read(r).splitlines(), fn): | ||
yield item | ||||
try: | ||||
# read local signatures | ||||
|
r43347 | fn = b"localsigs" | ||
|
r23877 | for item in parsefile(repo.vfs(fn), fn): | ||
|
r1681 | yield item | ||
except IOError: | ||||
pass | ||||
|
r43346 | |||
|
r1681 | def getkeys(ui, repo, mygpg, sigdata, context): | ||
"""get the keys who signed a data""" | ||||
fn, ln = context | ||||
node, version, sig = sigdata | ||||
|
r43347 | prefix = b"%s:%d" % (fn, ln) | ||
|
r46729 | node = bin(node) | ||
|
r1681 | |||
data = node2txt(repo, node, version) | ||||
sig = binascii.a2b_base64(sig) | ||||
|
r19442 | keys = mygpg.verify(data, sig) | ||
|
r1681 | |||
validkeys = [] | ||||
# warn for expired key and/or sigs | ||||
for key in keys: | ||||
|
r43347 | if key[0] == b"ERRSIG": | ||
ui.write(_(b"%s Unknown key ID \"%s\"\n") % (prefix, key[1])) | ||||
|
r19444 | continue | ||
|
r43347 | if key[0] == b"BADSIG": | ||
ui.write(_(b"%s Bad signature from \"%s\"\n") % (prefix, key[2])) | ||||
|
r1681 | continue | ||
|
r43347 | if key[0] == b"EXPSIG": | ||
|
r43346 | ui.write( | ||
|
r43387 | _(b"%s Note: Signature has expired (signed by: \"%s\")\n") | ||
|
r43346 | % (prefix, key[2]) | ||
) | ||||
|
r43347 | elif key[0] == b"EXPKEYSIG": | ||
|
r43346 | ui.write( | ||
|
r43387 | _(b"%s Note: This key has expired (signed by: \"%s\")\n") | ||
|
r43346 | % (prefix, key[2]) | ||
) | ||||
|
r1681 | validkeys.append((key[1], key[2], key[3])) | ||
return validkeys | ||||
|
r43346 | |||
|
r43347 | @command(b"sigs", [], _(b'hg sigs'), helpcategory=_HELP_CATEGORY) | ||
|
r1681 | def sigs(ui, repo): | ||
"""list signed changesets""" | ||||
mygpg = newgpg(ui) | ||||
revs = {} | ||||
for data, context in sigwalk(repo): | ||||
node, version, sig = data | ||||
fn, ln = context | ||||
try: | ||||
n = repo.lookup(node) | ||||
except KeyError: | ||||
|
r43347 | ui.warn(_(b"%s:%d node does not exist\n") % (fn, ln)) | ||
|
r1681 | continue | ||
r = repo.changelog.rev(n) | ||||
keys = getkeys(ui, repo, mygpg, data, context) | ||||
if not keys: | ||||
continue | ||||
revs.setdefault(r, []) | ||||
revs[r].extend(keys) | ||||
|
r8303 | for rev in sorted(revs, reverse=True): | ||
|
r1682 | for k in revs[rev]: | ||
|
r46729 | r = b"%5d:%s" % (rev, hex(repo.changelog.node(rev))) | ||
|
r43347 | ui.write(b"%-30s %s\n" % (keystr(ui, k), r)) | ||
|
r1681 | |||
|
r43346 | |||
|
r43347 | @command(b"sigcheck", [], _(b'hg sigcheck REV'), helpcategory=_HELP_CATEGORY) | ||
|
r27117 | def sigcheck(ui, repo, rev): | ||
|
r1592 | """verify all the signatures there may be for a particular revision""" | ||
mygpg = newgpg(ui) | ||||
rev = repo.lookup(rev) | ||||
|
r46729 | hexrev = hex(rev) | ||
|
r1592 | keys = [] | ||
|
r1681 | for data, context in sigwalk(repo): | ||
node, version, sig = data | ||||
if node == hexrev: | ||||
k = getkeys(ui, repo, mygpg, data, context) | ||||
if k: | ||||
keys.extend(k) | ||||
|
r1592 | |||
if not keys: | ||||
|
r46729 | ui.write(_(b"no valid signature for %s\n") % short(rev)) | ||
|
r1592 | return | ||
|
r1681 | |||
|
r1592 | # print summary | ||
|
r46729 | ui.write(_(b"%s is signed by:\n") % short(rev)) | ||
|
r1681 | for key in keys: | ||
|
r43347 | ui.write(b" %s\n" % keystr(ui, key)) | ||
|
r1592 | |||
|
r43346 | |||
|
r1681 | def keystr(ui, key): | ||
"""associate a string to a key (username, comment)""" | ||||
keyid, user, fingerprint = key | ||||
|
r43347 | comment = ui.config(b"gpg", fingerprint) | ||
|
r1681 | if comment: | ||
|
r43347 | return b"%s (%s)" % (user, comment) | ||
|
r1681 | else: | ||
return user | ||||
|
r1592 | |||
|
r43346 | |||
@command( | ||||
|
r43347 | b"sign", | ||
|
r43346 | [ | ||
|
r43347 | (b'l', b'local', None, _(b'make the signature local')), | ||
(b'f', b'force', None, _(b'sign even if the sigfile is modified')), | ||||
( | ||||
b'', | ||||
b'no-commit', | ||||
None, | ||||
_(b'do not commit the sigfile after signing'), | ||||
), | ||||
(b'k', b'key', b'', _(b'the key id to sign with'), _(b'ID')), | ||||
(b'm', b'message', b'', _(b'use text as commit message'), _(b'TEXT')), | ||||
(b'e', b'edit', False, _(b'invoke editor on commit messages')), | ||||
|
r43346 | ] | ||
+ cmdutil.commitopts2, | ||||
|
r43347 | _(b'hg sign [OPTION]... [REV]...'), | ||
|
r43346 | helpcategory=_HELP_CATEGORY, | ||
) | ||||
|
r1592 | def sign(ui, repo, *revs, **opts): | ||
|
r3916 | """add a signature for the current or given revision | ||
If no revision is given, the parent of the working directory is used, | ||||
or tip if no revision is checked out. | ||||
|
r6163 | |||
|
r25791 | The ``gpg.cmd`` config setting can be used to specify the command | ||
to run. A default key can be specified with ``gpg.key``. | ||||
|
r11193 | See :hg:`help dates` for a list of formats valid for -d/--date. | ||
|
r3916 | """ | ||
|
r27814 | with repo.wlock(): | ||
|
r27196 | return _dosign(ui, repo, *revs, **opts) | ||
|
r3916 | |||
|
r43346 | |||
|
r27196 | def _dosign(ui, repo, *revs, **opts): | ||
|
r1592 | mygpg = newgpg(ui, **opts) | ||
|
r34979 | opts = pycompat.byteskwargs(opts) | ||
|
r43347 | sigver = b"0" | ||
sigmessage = b"" | ||||
|
r6139 | |||
|
r43347 | date = opts.get(b'date') | ||
|
r6139 | if date: | ||
|
r43347 | opts[b'date'] = dateutil.parsedate(date) | ||
|
r6139 | |||
|
r1592 | if revs: | ||
nodes = [repo.lookup(n) for n in revs] | ||||
else: | ||||
|
r47771 | nodes = [ | ||
node for node in repo.dirstate.parents() if node != repo.nullid | ||||
] | ||||
|
r3916 | if len(nodes) > 1: | ||
|
r43346 | raise error.Abort( | ||
|
r43387 | _(b'uncommitted merge - please provide a specific revision') | ||
|
r43346 | ) | ||
|
r3916 | if not nodes: | ||
nodes = [repo.changelog.tip()] | ||||
|
r1592 | |||
for n in nodes: | ||||
|
r46729 | hexnode = hex(n) | ||
ui.write(_(b"signing %d:%s\n") % (repo.changelog.rev(n), short(n))) | ||||
|
r1592 | # build data | ||
data = node2txt(repo, n, sigver) | ||||
sig = mygpg.sign(data) | ||||
if not sig: | ||||
|
r43347 | raise error.Abort(_(b"error while signing")) | ||
|
r1592 | sig = binascii.b2a_base64(sig) | ||
|
r43347 | sig = sig.replace(b"\n", b"") | ||
sigmessage += b"%s %s %s\n" % (hexnode, sigver, sig) | ||||
|
r1592 | |||
# write it | ||||
|
r43347 | if opts[b'local']: | ||
repo.vfs.append(b"localsigs", sigmessage) | ||||
|
r1592 | return | ||
|
r43347 | if not opts[b"force"]: | ||
msigs = match.exact([b'.hgsigs']) | ||||
|
r25149 | if any(repo.status(match=msigs, unknown=True, ignored=True)): | ||
|
r43346 | raise error.Abort( | ||
|
r43347 | _(b"working copy of .hgsigs is changed "), | ||
hint=_(b"please commit .hgsigs manually"), | ||||
|
r43346 | ) | ||
|
r1592 | |||
|
r43347 | sigsfile = repo.wvfs(b".hgsigs", b"ab") | ||
|
r13400 | sigsfile.write(sigmessage) | ||
sigsfile.close() | ||||
|
r1592 | |||
|
r43347 | if b'.hgsigs' not in repo.dirstate: | ||
repo[None].add([b".hgsigs"]) | ||||
|
r1592 | |||
|
r43347 | if opts[b"no_commit"]: | ||
|
r1592 | return | ||
|
r43347 | message = opts[b'message'] | ||
|
r1592 | if not message: | ||
|
r9183 | # we don't translate commit messages | ||
|
r43347 | message = b"\n".join( | ||
|
r46729 | [b"Added signature for changeset %s" % short(n) for n in nodes] | ||
|
r43346 | ) | ||
|
r1592 | try: | ||
|
r43346 | editor = cmdutil.getcommiteditor( | ||
|
r43347 | editform=b'gpg.sign', **pycompat.strkwargs(opts) | ||
|
r43346 | ) | ||
repo.commit( | ||||
|
r43347 | message, opts[b'user'], opts[b'date'], match=msigs, editor=editor | ||
|
r43346 | ) | ||
|
r25660 | except ValueError as inst: | ||
|
r36671 | raise error.Abort(pycompat.bytestr(inst)) | ||
|
r1592 | |||
|
r43346 | |||
|
r1592 | def node2txt(repo, node, ver): | ||
"""map a manifest into some text""" | ||||
|
r43347 | if ver == b"0": | ||
|
r46729 | return b"%s\n" % hex(node) | ||
|
r1592 | else: | ||
|
r43347 | raise error.Abort(_(b"unknown signature version")) | ||
|
r40329 | |||
|
r43346 | |||
|
r40329 | def extsetup(ui): | ||
# Add our category before "Repository maintenance". | ||||
help.CATEGORY_ORDER.insert( | ||||
|
r43346 | help.CATEGORY_ORDER.index(command.CATEGORY_MAINTENANCE), _HELP_CATEGORY | ||
) | ||||
|
r43347 | help.CATEGORY_NAMES[_HELP_CATEGORY] = b'GPG signing' | ||