~justin-fathomdb/nova/justinsb-openstack-api-volumes

« back to all changes in this revision

Viewing changes to vendor/Twisted-10.0.0/twisted/conch/client/knownhosts.py

  • Committer: Jesse Andrews
  • Date: 2010-05-28 06:05:26 UTC
  • Revision ID: git-v1:bf6e6e718cdc7488e2da87b21e258ccc065fe499
initial commit

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# -*- test-case-name: twisted.conch.test.test_knownhosts -*-
 
2
# Copyright (c) 2008-2009 Twisted Matrix Laboratories.
 
3
# See LICENSE for details.
 
4
 
 
5
"""
 
6
An implementation of the OpenSSH known_hosts database.
 
7
 
 
8
@since: 8.2
 
9
"""
 
10
 
 
11
from binascii import Error as DecodeError, b2a_base64
 
12
 
 
13
from zope.interface import implements
 
14
 
 
15
from Crypto.Hash.HMAC import HMAC
 
16
from Crypto.Hash import SHA
 
17
 
 
18
from twisted.python.randbytes import secureRandom
 
19
 
 
20
from twisted.internet import defer
 
21
 
 
22
from twisted.python import log
 
23
from twisted.conch.interfaces import IKnownHostEntry
 
24
from twisted.conch.error import HostKeyChanged, UserRejectedKey, InvalidEntry
 
25
from twisted.conch.ssh.keys import Key, BadKeyError
 
26
 
 
27
 
 
28
def _b64encode(s):
 
29
    """
 
30
    Encode a binary string as base64 with no trailing newline.
 
31
    """
 
32
    return b2a_base64(s).strip()
 
33
 
 
34
 
 
35
 
 
36
def _extractCommon(string):
 
37
    """
 
38
    Extract common elements of base64 keys from an entry in a hosts file.
 
39
 
 
40
    @return: a 4-tuple of hostname data (L{str}), ssh key type (L{str}), key
 
41
    (L{Key}), and comment (L{str} or L{None}).  The hostname data is simply the
 
42
    beginning of the line up to the first occurrence of whitespace.
 
43
    """
 
44
    elements = string.split(None, 2)
 
45
    if len(elements) != 3:
 
46
        raise InvalidEntry()
 
47
    hostnames, keyType, keyAndComment = elements
 
48
    splitkey = keyAndComment.split(None, 1)
 
49
    if len(splitkey) == 2:
 
50
        keyString, comment = splitkey
 
51
        comment = comment.rstrip("\n")
 
52
    else:
 
53
        keyString = splitkey[0]
 
54
        comment = None
 
55
    key = Key.fromString(keyString.decode('base64'))
 
56
    return hostnames, keyType, key, comment
 
57
 
 
58
 
 
59
 
 
60
class _BaseEntry(object):
 
61
    """
 
62
    Abstract base of both hashed and non-hashed entry objects, since they
 
63
    represent keys and key types the same way.
 
64
 
 
65
    @ivar keyType: The type of the key; either ssh-dss or ssh-rsa.
 
66
    @type keyType: L{str}
 
67
 
 
68
    @ivar publicKey: The server public key indicated by this line.
 
69
    @type publicKey: L{twisted.conch.ssh.keys.Key}
 
70
 
 
71
    @ivar comment: Trailing garbage after the key line.
 
72
    @type comment: L{str}
 
73
    """
 
74
 
 
75
    def __init__(self, keyType, publicKey, comment):
 
76
        self.keyType = keyType
 
77
        self.publicKey = publicKey
 
78
        self.comment = comment
 
79
 
 
80
 
 
81
    def matchesKey(self, keyObject):
 
82
        """
 
83
        Check to see if this entry matches a given key object.
 
84
 
 
85
        @type keyObject: L{Key}
 
86
 
 
87
        @rtype: bool
 
88
        """
 
89
        return self.publicKey == keyObject
 
90
 
 
91
 
 
92
 
 
93
class PlainEntry(_BaseEntry):
 
94
    """
 
95
    A L{PlainEntry} is a representation of a plain-text entry in a known_hosts
 
96
    file.
 
97
 
 
98
    @ivar _hostnames: the list of all host-names associated with this entry.
 
99
    @type _hostnames: L{list} of L{str}
 
100
    """
 
101
 
 
102
    implements(IKnownHostEntry)
 
103
 
 
104
    def __init__(self, hostnames, keyType, publicKey, comment):
 
105
        self._hostnames = hostnames
 
106
        super(PlainEntry, self).__init__(keyType, publicKey, comment)
 
107
 
 
108
 
 
109
    def fromString(cls, string):
 
110
        """
 
111
        Parse a plain-text entry in a known_hosts file, and return a
 
112
        corresponding L{PlainEntry}.
 
113
 
 
114
        @param string: a space-separated string formatted like "hostname
 
115
        key-type base64-key-data comment".
 
116
 
 
117
        @type string: L{str}
 
118
 
 
119
        @raise DecodeError: if the key is not valid encoded as valid base64.
 
120
 
 
121
        @raise InvalidEntry: if the entry does not have the right number of
 
122
        elements and is therefore invalid.
 
123
 
 
124
        @raise BadKeyError: if the key, once decoded from base64, is not
 
125
        actually an SSH key.
 
126
 
 
127
        @return: an IKnownHostEntry representing the hostname and key in the
 
128
        input line.
 
129
 
 
130
        @rtype: L{PlainEntry}
 
131
        """
 
132
        hostnames, keyType, key, comment = _extractCommon(string)
 
133
        self = cls(hostnames.split(","), keyType, key, comment)
 
134
        return self
 
135
 
 
136
    fromString = classmethod(fromString)
 
137
 
 
138
 
 
139
    def matchesHost(self, hostname):
 
140
        """
 
141
        Check to see if this entry matches a given hostname.
 
142
 
 
143
        @type hostname: L{str}
 
144
 
 
145
        @rtype: bool
 
146
        """
 
147
        return hostname in self._hostnames
 
148
 
 
149
 
 
150
    def toString(self):
 
151
        """
 
152
        Implement L{IKnownHostEntry.toString} by recording the comma-separated
 
153
        hostnames, key type, and base-64 encoded key.
 
154
        """
 
155
        fields = [','.join(self._hostnames),
 
156
                  self.keyType,
 
157
                  _b64encode(self.publicKey.blob())]
 
158
        if self.comment is not None:
 
159
            fields.append(self.comment)
 
160
        return ' '.join(fields)
 
161
 
 
162
 
 
163
class UnparsedEntry(object):
 
164
    """
 
165
    L{UnparsedEntry} is an entry in a L{KnownHostsFile} which can't actually be
 
166
    parsed; therefore it matches no keys and no hosts.
 
167
    """
 
168
 
 
169
    implements(IKnownHostEntry)
 
170
 
 
171
    def __init__(self, string):
 
172
        """
 
173
        Create an unparsed entry from a line in a known_hosts file which cannot
 
174
        otherwise be parsed.
 
175
        """
 
176
        self._string = string
 
177
 
 
178
 
 
179
    def matchesHost(self, hostname):
 
180
        """
 
181
        Always returns False.
 
182
        """
 
183
        return False
 
184
 
 
185
 
 
186
    def matchesKey(self, key):
 
187
        """
 
188
        Always returns False.
 
189
        """
 
190
        return False
 
191
 
 
192
 
 
193
    def toString(self):
 
194
        """
 
195
        Returns the input line, without its newline if one was given.
 
196
        """
 
197
        return self._string.rstrip("\n")
 
198
 
 
199
 
 
200
 
 
201
def _hmacedString(key, string):
 
202
    """
 
203
    Return the SHA-1 HMAC hash of the given key and string.
 
204
    """
 
205
    hash = HMAC(key, digestmod=SHA)
 
206
    hash.update(string)
 
207
    return hash.digest()
 
208
 
 
209
 
 
210
 
 
211
class HashedEntry(_BaseEntry):
 
212
    """
 
213
    A L{HashedEntry} is a representation of an entry in a known_hosts file
 
214
    where the hostname has been hashed and salted.
 
215
 
 
216
    @ivar _hostSalt: the salt to combine with a hostname for hashing.
 
217
 
 
218
    @ivar _hostHash: the hashed representation of the hostname.
 
219
 
 
220
    @cvar MAGIC: the 'hash magic' string used to identify a hashed line in a
 
221
    known_hosts file as opposed to a plaintext one.
 
222
    """
 
223
 
 
224
    implements(IKnownHostEntry)
 
225
 
 
226
    MAGIC = '|1|'
 
227
 
 
228
    def __init__(self, hostSalt, hostHash, keyType, publicKey, comment):
 
229
        self._hostSalt = hostSalt
 
230
        self._hostHash = hostHash
 
231
        super(HashedEntry, self).__init__(keyType, publicKey, comment)
 
232
 
 
233
 
 
234
    def fromString(cls, string):
 
235
        """
 
236
        Load a hashed entry from a string representing a line in a known_hosts
 
237
        file.
 
238
 
 
239
        @raise DecodeError: if the key, the hostname, or the is not valid
 
240
        encoded as valid base64
 
241
 
 
242
        @raise InvalidEntry: if the entry does not have the right number of
 
243
        elements and is therefore invalid, or the host/hash portion contains
 
244
        more items than just the host and hash.
 
245
 
 
246
        @raise BadKeyError: if the key, once decoded from base64, is not
 
247
        actually an SSH key.
 
248
        """
 
249
        stuff, keyType, key, comment = _extractCommon(string)
 
250
        saltAndHash = stuff[len(cls.MAGIC):].split("|")
 
251
        if len(saltAndHash) != 2:
 
252
            raise InvalidEntry()
 
253
        hostSalt, hostHash = saltAndHash
 
254
        self = cls(hostSalt.decode("base64"), hostHash.decode("base64"),
 
255
                   keyType, key, comment)
 
256
        return self
 
257
 
 
258
    fromString = classmethod(fromString)
 
259
 
 
260
 
 
261
    def matchesHost(self, hostname):
 
262
        """
 
263
        Implement L{IKnownHostEntry.matchesHost} to compare the hash of the
 
264
        input to the stored hash.
 
265
        """
 
266
        return (_hmacedString(self._hostSalt, hostname) == self._hostHash)
 
267
 
 
268
 
 
269
    def toString(self):
 
270
        """
 
271
        Implement L{IKnownHostEntry.toString} by base64-encoding the salt, host
 
272
        hash, and key.
 
273
        """
 
274
        fields = [self.MAGIC + '|'.join([_b64encode(self._hostSalt),
 
275
                                         _b64encode(self._hostHash)]),
 
276
                  self.keyType,
 
277
                  _b64encode(self.publicKey.blob())]
 
278
        if self.comment is not None:
 
279
            fields.append(self.comment)
 
280
        return ' '.join(fields)
 
281
 
 
282
 
 
283
 
 
284
class KnownHostsFile(object):
 
285
    """
 
286
    A structured representation of an OpenSSH-format ~/.ssh/known_hosts file.
 
287
 
 
288
    @ivar _entries: a list of L{IKnownHostEntry} providers.
 
289
 
 
290
    @ivar _savePath: the L{FilePath} to save new entries to.
 
291
    """
 
292
 
 
293
    def __init__(self, savePath):
 
294
        """
 
295
        Create a new, empty KnownHostsFile.
 
296
 
 
297
        You want to use L{KnownHostsFile.fromPath} to parse one of these.
 
298
        """
 
299
        self._entries = []
 
300
        self._savePath = savePath
 
301
 
 
302
 
 
303
    def hasHostKey(self, hostname, key):
 
304
        """
 
305
        @return: True if the given hostname and key are present in this file,
 
306
        False if they are not.
 
307
 
 
308
        @rtype: L{bool}
 
309
 
 
310
        @raise HostKeyChanged: if the host key found for the given hostname
 
311
        does not match the given key.
 
312
        """
 
313
        for lineidx, entry in enumerate(self._entries):
 
314
            if entry.matchesHost(hostname):
 
315
                if entry.matchesKey(key):
 
316
                    return True
 
317
                else:
 
318
                    raise HostKeyChanged(entry, self._savePath, lineidx + 1)
 
319
        return False
 
320
 
 
321
 
 
322
    def verifyHostKey(self, ui, hostname, ip, key):
 
323
        """
 
324
        Verify the given host key for the given IP and host, asking for
 
325
        confirmation from, and notifying, the given UI about changes to this
 
326
        file.
 
327
 
 
328
        @param ui: The user interface to request an IP address from.
 
329
 
 
330
        @param hostname: The hostname that the user requested to connect to.
 
331
 
 
332
        @param ip: The string representation of the IP address that is actually
 
333
        being connected to.
 
334
 
 
335
        @param key: The public key of the server.
 
336
 
 
337
        @return: a L{Deferred} that fires with True when the key has been
 
338
        verified, or fires with an errback when the key either cannot be
 
339
        verified or has changed.
 
340
 
 
341
        @rtype: L{Deferred}
 
342
        """
 
343
        hhk = defer.maybeDeferred(self.hasHostKey, hostname, key)
 
344
        def gotHasKey(result):
 
345
            if result:
 
346
                if not self.hasHostKey(ip, key):
 
347
                    ui.warn("Warning: Permanently added the %s host key for "
 
348
                            "IP address '%s' to the list of known hosts." %
 
349
                            (key.type(), ip))
 
350
                    self.addHostKey(ip, key)
 
351
                    self.save()
 
352
                return result
 
353
            else:
 
354
                def promptResponse(response):
 
355
                    if response:
 
356
                        self.addHostKey(hostname, key)
 
357
                        self.addHostKey(ip, key)
 
358
                        self.save()
 
359
                        return response
 
360
                    else:
 
361
                        raise UserRejectedKey()
 
362
                return ui.prompt(
 
363
                    "The authenticity of host '%s (%s)' "
 
364
                    "can't be established.\n"
 
365
                    "RSA key fingerprint is %s.\n"
 
366
                    "Are you sure you want to continue connecting (yes/no)? " %
 
367
                    (hostname, ip, key.fingerprint())).addCallback(promptResponse)
 
368
        return hhk.addCallback(gotHasKey)
 
369
 
 
370
 
 
371
    def addHostKey(self, hostname, key):
 
372
        """
 
373
        Add a new L{HashedEntry} to the key database.
 
374
 
 
375
        Note that you still need to call L{KnownHostsFile.save} if you wish
 
376
        these changes to be persisted.
 
377
 
 
378
        @return: the L{HashedEntry} that was added.
 
379
        """
 
380
        salt = secureRandom(20)
 
381
        keyType = "ssh-" + key.type().lower()
 
382
        entry = HashedEntry(salt, _hmacedString(salt, hostname),
 
383
                            keyType, key, None)
 
384
        self._entries.append(entry)
 
385
        return entry
 
386
 
 
387
 
 
388
    def save(self):
 
389
        """
 
390
        Save this L{KnownHostsFile} to the path it was loaded from.
 
391
        """
 
392
        p = self._savePath.parent()
 
393
        if not p.isdir():
 
394
            p.makedirs()
 
395
        self._savePath.setContent('\n'.join(
 
396
                [entry.toString() for entry in self._entries]) + "\n")
 
397
 
 
398
 
 
399
    def fromPath(cls, path):
 
400
        """
 
401
        @param path: A path object to use for both reading contents from and
 
402
        later saving to.
 
403
 
 
404
        @type path: L{FilePath}
 
405
        """
 
406
        self = cls(path)
 
407
        try:
 
408
            fp = path.open()
 
409
        except IOError:
 
410
            return self
 
411
        for line in fp:
 
412
            if line.startswith(HashedEntry.MAGIC):
 
413
                entry = HashedEntry.fromString(line)
 
414
            else:
 
415
                try:
 
416
                    entry = PlainEntry.fromString(line)
 
417
                except (DecodeError, InvalidEntry, BadKeyError):
 
418
                    entry = UnparsedEntry(line)
 
419
            self._entries.append(entry)
 
420
        return self
 
421
 
 
422
    fromPath = classmethod(fromPath)
 
423
 
 
424
 
 
425
class ConsoleUI(object):
 
426
    """
 
427
    A UI object that can ask true/false questions and post notifications on the
 
428
    console, to be used during key verification.
 
429
 
 
430
    @ivar opener: a no-argument callable which should open a console file-like
 
431
    object to be used for reading and writing.
 
432
    """
 
433
 
 
434
    def __init__(self, opener):
 
435
        self.opener = opener
 
436
 
 
437
 
 
438
    def prompt(self, text):
 
439
        """
 
440
        Write the given text as a prompt to the console output, then read a
 
441
        result from the console input.
 
442
 
 
443
        @return: a L{Deferred} which fires with L{True} when the user answers
 
444
        'yes' and L{False} when the user answers 'no'.  It may errback if there
 
445
        were any I/O errors.
 
446
        """
 
447
        d = defer.succeed(None)
 
448
        def body(ignored):
 
449
            f = self.opener()
 
450
            f.write(text)
 
451
            while True:
 
452
                answer = f.readline().strip().lower()
 
453
                if answer == 'yes':
 
454
                    f.close()
 
455
                    return True
 
456
                elif answer == 'no':
 
457
                    f.close()
 
458
                    return False
 
459
                else:
 
460
                    f.write("Please type 'yes' or 'no': ")
 
461
        return d.addCallback(body)
 
462
 
 
463
 
 
464
    def warn(self, text):
 
465
        """
 
466
        Notify the user (non-interactively) of the provided text, by writing it
 
467
        to the console.
 
468
        """
 
469
        try:
 
470
            f = self.opener()
 
471
            f.write(text)
 
472
            f.close()
 
473
        except:
 
474
            log.err()