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