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