1# Copyright 2005, 2006 Benoit Boissinot <benoit.boissinot@ens-lyon.org>
2#
3# This software may be used and distributed according to the terms of the
4# GNU General Public License version 2 or any later version.
5
6'''commands to sign and verify changesets'''
7
8from __future__ import absolute_import
9
10import binascii
11import os
12
13from mercurial.i18n import _
14from mercurial.node import (
15    bin,
16    hex,
17    short,
18)
19from mercurial import (
20    cmdutil,
21    error,
22    help,
23    match,
24    pycompat,
25    registrar,
26)
27from mercurial.utils import (
28    dateutil,
29    procutil,
30)
31
32cmdtable = {}
33command = registrar.command(cmdtable)
34# Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
35# extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
36# be specifying the version(s) of Mercurial they are tested with, or
37# leave the attribute unspecified.
38testedwith = b'ships-with-hg-core'
39
40configtable = {}
41configitem = registrar.configitem(configtable)
42
43configitem(
44    b'gpg',
45    b'cmd',
46    default=b'gpg',
47)
48configitem(
49    b'gpg',
50    b'key',
51    default=None,
52)
53configitem(
54    b'gpg',
55    b'.*',
56    default=None,
57    generic=True,
58)
59
60# Custom help category
61_HELP_CATEGORY = b'gpg'
62help.CATEGORY_ORDER.insert(
63    help.CATEGORY_ORDER.index(registrar.command.CATEGORY_HELP), _HELP_CATEGORY
64)
65help.CATEGORY_NAMES[_HELP_CATEGORY] = b'Signing changes (GPG)'
66
67
68class gpg(object):
69    def __init__(self, path, key=None):
70        self.path = path
71        self.key = (key and b" --local-user \"%s\"" % key) or b""
72
73    def sign(self, data):
74        gpgcmd = b"%s --sign --detach-sign%s" % (self.path, self.key)
75        return procutil.filter(data, gpgcmd)
76
77    def verify(self, data, sig):
78        """returns of the good and bad signatures"""
79        sigfile = datafile = None
80        try:
81            # create temporary files
82            fd, sigfile = pycompat.mkstemp(prefix=b"hg-gpg-", suffix=b".sig")
83            fp = os.fdopen(fd, 'wb')
84            fp.write(sig)
85            fp.close()
86            fd, datafile = pycompat.mkstemp(prefix=b"hg-gpg-", suffix=b".txt")
87            fp = os.fdopen(fd, 'wb')
88            fp.write(data)
89            fp.close()
90            gpgcmd = (
91                b"%s --logger-fd 1 --status-fd 1 --verify \"%s\" \"%s\""
92                % (
93                    self.path,
94                    sigfile,
95                    datafile,
96                )
97            )
98            ret = procutil.filter(b"", gpgcmd)
99        finally:
100            for f in (sigfile, datafile):
101                try:
102                    if f:
103                        os.unlink(f)
104                except OSError:
105                    pass
106        keys = []
107        key, fingerprint = None, None
108        for l in ret.splitlines():
109            # see DETAILS in the gnupg documentation
110            # filter the logger output
111            if not l.startswith(b"[GNUPG:]"):
112                continue
113            l = l[9:]
114            if l.startswith(b"VALIDSIG"):
115                # fingerprint of the primary key
116                fingerprint = l.split()[10]
117            elif l.startswith(b"ERRSIG"):
118                key = l.split(b" ", 3)[:2]
119                key.append(b"")
120                fingerprint = None
121            elif (
122                l.startswith(b"GOODSIG")
123                or l.startswith(b"EXPSIG")
124                or l.startswith(b"EXPKEYSIG")
125                or l.startswith(b"BADSIG")
126            ):
127                if key is not None:
128                    keys.append(key + [fingerprint])
129                key = l.split(b" ", 2)
130                fingerprint = None
131        if key is not None:
132            keys.append(key + [fingerprint])
133        return keys
134
135
136def newgpg(ui, **opts):
137    """create a new gpg instance"""
138    gpgpath = ui.config(b"gpg", b"cmd")
139    gpgkey = opts.get('key')
140    if not gpgkey:
141        gpgkey = ui.config(b"gpg", b"key")
142    return gpg(gpgpath, gpgkey)
143
144
145def sigwalk(repo):
146    """
147    walk over every sigs, yields a couple
148    ((node, version, sig), (filename, linenumber))
149    """
150
151    def parsefile(fileiter, context):
152        ln = 1
153        for l in fileiter:
154            if not l:
155                continue
156            yield (l.split(b" ", 2), (context, ln))
157            ln += 1
158
159    # read the heads
160    fl = repo.file(b".hgsigs")
161    for r in reversed(fl.heads()):
162        fn = b".hgsigs|%s" % short(r)
163        for item in parsefile(fl.read(r).splitlines(), fn):
164            yield item
165    try:
166        # read local signatures
167        fn = b"localsigs"
168        for item in parsefile(repo.vfs(fn), fn):
169            yield item
170    except IOError:
171        pass
172
173
174def getkeys(ui, repo, mygpg, sigdata, context):
175    """get the keys who signed a data"""
176    fn, ln = context
177    node, version, sig = sigdata
178    prefix = b"%s:%d" % (fn, ln)
179    node = bin(node)
180
181    data = node2txt(repo, node, version)
182    sig = binascii.a2b_base64(sig)
183    keys = mygpg.verify(data, sig)
184
185    validkeys = []
186    # warn for expired key and/or sigs
187    for key in keys:
188        if key[0] == b"ERRSIG":
189            ui.write(_(b"%s Unknown key ID \"%s\"\n") % (prefix, key[1]))
190            continue
191        if key[0] == b"BADSIG":
192            ui.write(_(b"%s Bad signature from \"%s\"\n") % (prefix, key[2]))
193            continue
194        if key[0] == b"EXPSIG":
195            ui.write(
196                _(b"%s Note: Signature has expired (signed by: \"%s\")\n")
197                % (prefix, key[2])
198            )
199        elif key[0] == b"EXPKEYSIG":
200            ui.write(
201                _(b"%s Note: This key has expired (signed by: \"%s\")\n")
202                % (prefix, key[2])
203            )
204        validkeys.append((key[1], key[2], key[3]))
205    return validkeys
206
207
208@command(b"sigs", [], _(b'hg sigs'), helpcategory=_HELP_CATEGORY)
209def sigs(ui, repo):
210    """list signed changesets"""
211    mygpg = newgpg(ui)
212    revs = {}
213
214    for data, context in sigwalk(repo):
215        node, version, sig = data
216        fn, ln = context
217        try:
218            n = repo.lookup(node)
219        except KeyError:
220            ui.warn(_(b"%s:%d node does not exist\n") % (fn, ln))
221            continue
222        r = repo.changelog.rev(n)
223        keys = getkeys(ui, repo, mygpg, data, context)
224        if not keys:
225            continue
226        revs.setdefault(r, [])
227        revs[r].extend(keys)
228    for rev in sorted(revs, reverse=True):
229        for k in revs[rev]:
230            r = b"%5d:%s" % (rev, hex(repo.changelog.node(rev)))
231            ui.write(b"%-30s %s\n" % (keystr(ui, k), r))
232
233
234@command(b"sigcheck", [], _(b'hg sigcheck REV'), helpcategory=_HELP_CATEGORY)
235def sigcheck(ui, repo, rev):
236    """verify all the signatures there may be for a particular revision"""
237    mygpg = newgpg(ui)
238    rev = repo.lookup(rev)
239    hexrev = hex(rev)
240    keys = []
241
242    for data, context in sigwalk(repo):
243        node, version, sig = data
244        if node == hexrev:
245            k = getkeys(ui, repo, mygpg, data, context)
246            if k:
247                keys.extend(k)
248
249    if not keys:
250        ui.write(_(b"no valid signature for %s\n") % short(rev))
251        return
252
253    # print summary
254    ui.write(_(b"%s is signed by:\n") % short(rev))
255    for key in keys:
256        ui.write(b" %s\n" % keystr(ui, key))
257
258
259def keystr(ui, key):
260    """associate a string to a key (username, comment)"""
261    keyid, user, fingerprint = key
262    comment = ui.config(b"gpg", fingerprint)
263    if comment:
264        return b"%s (%s)" % (user, comment)
265    else:
266        return user
267
268
269@command(
270    b"sign",
271    [
272        (b'l', b'local', None, _(b'make the signature local')),
273        (b'f', b'force', None, _(b'sign even if the sigfile is modified')),
274        (
275            b'',
276            b'no-commit',
277            None,
278            _(b'do not commit the sigfile after signing'),
279        ),
280        (b'k', b'key', b'', _(b'the key id to sign with'), _(b'ID')),
281        (b'm', b'message', b'', _(b'use text as commit message'), _(b'TEXT')),
282        (b'e', b'edit', False, _(b'invoke editor on commit messages')),
283    ]
284    + cmdutil.commitopts2,
285    _(b'hg sign [OPTION]... [REV]...'),
286    helpcategory=_HELP_CATEGORY,
287)
288def sign(ui, repo, *revs, **opts):
289    """add a signature for the current or given revision
290
291    If no revision is given, the parent of the working directory is used,
292    or tip if no revision is checked out.
293
294    The ``gpg.cmd`` config setting can be used to specify the command
295    to run. A default key can be specified with ``gpg.key``.
296
297    See :hg:`help dates` for a list of formats valid for -d/--date.
298    """
299    with repo.wlock():
300        return _dosign(ui, repo, *revs, **opts)
301
302
303def _dosign(ui, repo, *revs, **opts):
304    mygpg = newgpg(ui, **opts)
305    opts = pycompat.byteskwargs(opts)
306    sigver = b"0"
307    sigmessage = b""
308
309    date = opts.get(b'date')
310    if date:
311        opts[b'date'] = dateutil.parsedate(date)
312
313    if revs:
314        nodes = [repo.lookup(n) for n in revs]
315    else:
316        nodes = [
317            node for node in repo.dirstate.parents() if node != repo.nullid
318        ]
319        if len(nodes) > 1:
320            raise error.Abort(
321                _(b'uncommitted merge - please provide a specific revision')
322            )
323        if not nodes:
324            nodes = [repo.changelog.tip()]
325
326    for n in nodes:
327        hexnode = hex(n)
328        ui.write(_(b"signing %d:%s\n") % (repo.changelog.rev(n), short(n)))
329        # build data
330        data = node2txt(repo, n, sigver)
331        sig = mygpg.sign(data)
332        if not sig:
333            raise error.Abort(_(b"error while signing"))
334        sig = binascii.b2a_base64(sig)
335        sig = sig.replace(b"\n", b"")
336        sigmessage += b"%s %s %s\n" % (hexnode, sigver, sig)
337
338    # write it
339    if opts[b'local']:
340        repo.vfs.append(b"localsigs", sigmessage)
341        return
342
343    if not opts[b"force"]:
344        msigs = match.exact([b'.hgsigs'])
345        if any(repo.status(match=msigs, unknown=True, ignored=True)):
346            raise error.Abort(
347                _(b"working copy of .hgsigs is changed "),
348                hint=_(b"please commit .hgsigs manually"),
349            )
350
351    sigsfile = repo.wvfs(b".hgsigs", b"ab")
352    sigsfile.write(sigmessage)
353    sigsfile.close()
354
355    if b'.hgsigs' not in repo.dirstate:
356        repo[None].add([b".hgsigs"])
357
358    if opts[b"no_commit"]:
359        return
360
361    message = opts[b'message']
362    if not message:
363        # we don't translate commit messages
364        message = b"\n".join(
365            [b"Added signature for changeset %s" % short(n) for n in nodes]
366        )
367    try:
368        editor = cmdutil.getcommiteditor(
369            editform=b'gpg.sign', **pycompat.strkwargs(opts)
370        )
371        repo.commit(
372            message, opts[b'user'], opts[b'date'], match=msigs, editor=editor
373        )
374    except ValueError as inst:
375        raise error.Abort(pycompat.bytestr(inst))
376
377
378def node2txt(repo, node, ver):
379    """map a manifest into some text"""
380    if ver == b"0":
381        return b"%s\n" % hex(node)
382    else:
383        raise error.Abort(_(b"unknown signature version"))
384
385
386def extsetup(ui):
387    # Add our category before "Repository maintenance".
388    help.CATEGORY_ORDER.insert(
389        help.CATEGORY_ORDER.index(command.CATEGORY_MAINTENANCE), _HELP_CATEGORY
390    )
391    help.CATEGORY_NAMES[_HELP_CATEGORY] = b'GPG signing'
392