1### 2# Copyright (c) 2015, Valentin Lorentz 3# All rights reserved. 4# 5# Redistribution and use in source and binary forms, with or without 6# modification, are permitted provided that the following conditions are met: 7# 8# * Redistributions of source code must retain the above copyright notice, 9# this list of conditions, and the following disclaimer. 10# * Redistributions in binary form must reproduce the above copyright notice, 11# this list of conditions, and the following disclaimer in the 12# documentation and/or other materials provided with the distribution. 13# * Neither the name of the author of this software nor the name of 14# contributors to this software may be used to endorse or promote products 15# derived from this software without specific prior written consent. 16# 17# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 18# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 19# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 20# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 21# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 22# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 23# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 24# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 25# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 26# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 27# POSSIBILITY OF SUCH DAMAGE. 28 29### 30 31import re 32import sys 33import time 34import uuid 35import functools 36 37import supybot.gpg as gpg 38import supybot.conf as conf 39import supybot.utils as utils 40import supybot.ircdb as ircdb 41from supybot.commands import * 42import supybot.utils.minisix as minisix 43import supybot.plugins as plugins 44import supybot.commands as commands 45import supybot.ircutils as ircutils 46import supybot.callbacks as callbacks 47if minisix.PY3: 48 import http.client as http_client 49else: 50 import httplib as http_client 51try: 52 from supybot.i18n import PluginInternationalization 53 _ = PluginInternationalization('GPG') 54except ImportError: 55 # Placeholder that allows to run the plugin on a bot 56 # without the i18n module 57 _ = lambda x: x 58 59def check_gpg_available(f): 60 if gpg.available: 61 return f 62 else: 63 if not gpg.found_gnupg_lib: 64 def newf(self, irc, *args): 65 irc.error(_('gnupg features are not available because ' 66 'the python-gnupg library is not installed.')) 67 elif not gpg.found_gnupg_bin: 68 def newf(self, irc, *args): 69 irc.error(_('gnupg features are not available because ' 70 'the gnupg executable is not installed.')) 71 else: 72 # This case should never happen. 73 def newf(self, irc, *args): 74 irc.error(_('gnupg features are not available.')) 75 newf.__doc__ = f.__doc__ 76 newf.__name__ = f.__name__ 77 return newf 78 79if hasattr(http_client, '_MAXHEADERS'): 80 safe_getUrl = utils.web.getUrl 81else: 82 def safe_getUrl(url): 83 try: 84 return commands.process(utils.web.getUrl, url, 85 timeout=10, heap_size=10*1024*1024, 86 pn='GPG') 87 except (commands.ProcessTimeoutError, MemoryError): 88 raise utils.web.Error(_('Page is too big or the server took ' 89 'too much time to answer the request.')) 90 91class GPG(callbacks.Plugin): 92 """Provides authentication based on GPG keys.""" 93 class key(callbacks.Commands): 94 @check_gpg_available 95 def add(self, irc, msg, args, user, keyid, keyserver): 96 """<key id> <key server> 97 98 Add a GPG key to your account.""" 99 if keyid in user.gpgkeys: 100 irc.error(_('This key is already associated with your ' 101 'account.')) 102 return 103 result = gpg.keyring.recv_keys(keyserver, keyid) 104 reply = format(_('%n imported, %i unchanged, %i not imported.'), 105 (result.imported, _('key')), 106 result.unchanged, 107 result.not_imported, 108 [x['fingerprint'] for x in result.results]) 109 if result.imported == 1: 110 user.gpgkeys.append(keyid) 111 irc.reply(reply) 112 else: 113 irc.error(reply) 114 add = wrap(add, ['user', 115 ('somethingWithoutSpaces', 116 _('You must give a valid key id')), 117 ('somethingWithoutSpaces', 118 _('You must give a valid key server'))]) 119 120 @check_gpg_available 121 def remove(self, irc, msg, args, user, fingerprint): 122 """<fingerprint> 123 124 Remove a GPG key from your account.""" 125 try: 126 keyids = [x['keyid'] for x in gpg.keyring.list_keys() 127 if x['fingerprint'] == fingerprint] 128 if len(keyids) == 0: 129 raise ValueError 130 for keyid in keyids: 131 try: 132 user.gpgkeys.remove(keyid) 133 except ValueError: 134 user.gpgkeys.remove('0x' + keyid) 135 gpg.keyring.delete_keys(fingerprint) 136 irc.replySuccess() 137 except ValueError: 138 irc.error(_('GPG key not associated with your account.')) 139 remove = wrap(remove, ['user', 'somethingWithoutSpaces']) 140 141 @check_gpg_available 142 def list(self, irc, msg, args, user): 143 """takes no arguments 144 145 List your GPG keys.""" 146 keyids = user.gpgkeys 147 if len(keyids) == 0: 148 irc.reply(_('No key is associated with your account.')) 149 else: 150 irc.reply(format('%L', keyids)) 151 list = wrap(list, ['user']) 152 153 class signing(callbacks.Commands): 154 def __init__(self, *args): 155 super(GPG.signing, self).__init__(*args) 156 self._tokens = {} 157 158 def _expire_tokens(self): 159 now = time.time() 160 self._tokens = dict(filter(lambda x_y: x_y[1][1]>now, 161 self._tokens.items())) 162 163 @check_gpg_available 164 def gettoken(self, irc, msg, args): 165 """takes no arguments 166 167 Send you a token that you'll have to sign with your key.""" 168 self._expire_tokens() 169 token = '{%s}' % str(uuid.uuid4()) 170 lifetime = conf.supybot.plugins.GPG.auth.sign.TokenTimeout() 171 self._tokens.update({token: (msg.prefix, time.time()+lifetime)}) 172 irc.reply(_('Your token is: %s. Please sign it with your ' 173 'GPG key, paste it somewhere, and call the \'auth\' ' 174 'command with the URL to the (raw) file containing the ' 175 'signature.') % token) 176 gettoken = wrap(gettoken, []) 177 178 _auth_re = re.compile(r'-----BEGIN PGP SIGNED MESSAGE-----\r?\n' 179 r'Hash: .*\r?\n\r?\n' 180 r'\s*({[0-9a-z-]+})\s*\r?\n' 181 r'-----BEGIN PGP SIGNATURE-----\r?\n.*' 182 r'\r?\n-----END PGP SIGNATURE-----', 183 re.S) 184 185 @check_gpg_available 186 def auth(self, irc, msg, args, url): 187 """<url> 188 189 Check the GPG signature at the <url> and authenticates you if 190 the key used is associated to a user.""" 191 self._expire_tokens() 192 content = safe_getUrl(url) 193 if minisix.PY3 and isinstance(content, bytes): 194 content = content.decode() 195 match = self._auth_re.search(content) 196 if not match: 197 irc.error(_('Signature or token not found.'), Raise=True) 198 data = match.group(0) 199 token = match.group(1) 200 if token not in self._tokens: 201 irc.error(_('Unknown token. It may have expired before you ' 202 'submit it.'), Raise=True) 203 if self._tokens[token][0] != msg.prefix: 204 irc.error(_('Your hostname/nick changed in the process. ' 205 'Authentication aborted.'), Raise=True) 206 verified = gpg.keyring.verify(data) 207 if verified and verified.valid: 208 keyid = verified.pubkey_fingerprint[-16:] 209 prefix, expiry = self._tokens.pop(token) 210 found = False 211 for (id, user) in ircdb.users.items(): 212 if keyid in [x[-len(keyid):] for x in user.gpgkeys]: 213 try: 214 user.addAuth(msg.prefix) 215 except ValueError: 216 irc.error(_('Your secure flag is true and your ' 217 'hostmask doesn\'t match any of your ' 218 'known hostmasks.'), Raise=True) 219 ircdb.users.setUser(user, flush=False) 220 irc.reply(_('You are now authenticated as %s.') % 221 user.name) 222 return 223 irc.error(_('Unknown GPG key.'), Raise=True) 224 else: 225 irc.error(_('Signature could not be verified. Make sure ' 226 'this is a valid GPG signature and the URL is valid.')) 227 auth = wrap(auth, ['url']) 228 229 230Class = GPG 231 232 233# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: 234