1# -*- test-case-name: twisted.conch.test.test_checkers -*-
2# Copyright (c) Twisted Matrix Laboratories.
3# See LICENSE for details.
4
5"""
6Provide L{ICredentialsChecker} implementations to be used in Conch protocols.
7"""
8
9
10import binascii
11import errno
12import sys
13from base64 import decodebytes
14from typing import BinaryIO, Callable, Iterator
15
16try:
17    import pwd as _pwd
18except ImportError:
19    pwd = None
20else:
21    pwd = _pwd
22
23try:
24    import spwd as _spwd
25except ImportError:
26    spwd = None
27else:
28    spwd = _spwd
29
30from zope.interface import Interface, implementer, providedBy
31
32from incremental import Version
33
34from twisted.conch import error
35from twisted.conch.ssh import keys
36from twisted.cred.checkers import ICredentialsChecker
37from twisted.cred.credentials import ISSHPrivateKey, IUsernamePassword
38from twisted.cred.error import UnauthorizedLogin, UnhandledCredentials
39from twisted.internet import defer
40from twisted.logger import Logger
41from twisted.plugins.cred_unix import verifyCryptedPassword
42from twisted.python import failure, reflect
43from twisted.python.deprecate import deprecatedModuleAttribute
44from twisted.python.filepath import FilePath
45from twisted.python.util import runAsEffectiveUser
46
47_log = Logger()
48
49
50def _pwdGetByName(username):
51    """
52    Look up a user in the /etc/passwd database using the pwd module.  If the
53    pwd module is not available, return None.
54
55    @param username: the username of the user to return the passwd database
56        information for.
57    @type username: L{str}
58    """
59    if pwd is None:
60        return None
61    return pwd.getpwnam(username)
62
63
64def _shadowGetByName(username):
65    """
66    Look up a user in the /etc/shadow database using the spwd module. If it is
67    not available, return L{None}.
68
69    @param username: the username of the user to return the shadow database
70        information for.
71    @type username: L{str}
72    """
73    if spwd is not None:
74        f = spwd.getspnam
75    else:
76        return None
77    return runAsEffectiveUser(0, 0, f, username)
78
79
80@implementer(ICredentialsChecker)
81class UNIXPasswordDatabase:
82    """
83    A checker which validates users out of the UNIX password databases, or
84    databases of a compatible format.
85
86    @ivar _getByNameFunctions: a C{list} of functions which are called in order
87        to valid a user.  The default value is such that the C{/etc/passwd}
88        database will be tried first, followed by the C{/etc/shadow} database.
89    """
90
91    credentialInterfaces = (IUsernamePassword,)
92
93    def __init__(self, getByNameFunctions=None):
94        if getByNameFunctions is None:
95            getByNameFunctions = [_pwdGetByName, _shadowGetByName]
96        self._getByNameFunctions = getByNameFunctions
97
98    def requestAvatarId(self, credentials):
99        # We get bytes, but the Py3 pwd module uses str. So attempt to decode
100        # it using the same method that CPython does for the file on disk.
101        username = credentials.username.decode(sys.getfilesystemencoding())
102        password = credentials.password.decode(sys.getfilesystemencoding())
103
104        for func in self._getByNameFunctions:
105            try:
106                pwnam = func(username)
107            except KeyError:
108                return defer.fail(UnauthorizedLogin("invalid username"))
109            else:
110                if pwnam is not None:
111                    crypted = pwnam[1]
112                    if crypted == "":
113                        continue
114
115                    if verifyCryptedPassword(crypted, password):
116                        return defer.succeed(credentials.username)
117        # fallback
118        return defer.fail(UnauthorizedLogin("unable to verify password"))
119
120
121@implementer(ICredentialsChecker)
122class SSHPublicKeyDatabase:
123    """
124    Checker that authenticates SSH public keys, based on public keys listed in
125    authorized_keys and authorized_keys2 files in user .ssh/ directories.
126    """
127
128    credentialInterfaces = (ISSHPrivateKey,)
129
130    _userdb = pwd
131
132    def requestAvatarId(self, credentials):
133        d = defer.maybeDeferred(self.checkKey, credentials)
134        d.addCallback(self._cbRequestAvatarId, credentials)
135        d.addErrback(self._ebRequestAvatarId)
136        return d
137
138    def _cbRequestAvatarId(self, validKey, credentials):
139        """
140        Check whether the credentials themselves are valid, now that we know
141        if the key matches the user.
142
143        @param validKey: A boolean indicating whether or not the public key
144            matches a key in the user's authorized_keys file.
145
146        @param credentials: The credentials offered by the user.
147        @type credentials: L{ISSHPrivateKey} provider
148
149        @raise UnauthorizedLogin: (as a failure) if the key does not match the
150            user in C{credentials}. Also raised if the user provides an invalid
151            signature.
152
153        @raise ValidPublicKey: (as a failure) if the key matches the user but
154            the credentials do not include a signature. See
155            L{error.ValidPublicKey} for more information.
156
157        @return: The user's username, if authentication was successful.
158        """
159        if not validKey:
160            return failure.Failure(UnauthorizedLogin("invalid key"))
161        if not credentials.signature:
162            return failure.Failure(error.ValidPublicKey())
163        else:
164            try:
165                pubKey = keys.Key.fromString(credentials.blob)
166                if pubKey.verify(credentials.signature, credentials.sigData):
167                    return credentials.username
168            except Exception:  # any error should be treated as a failed login
169                _log.failure("Error while verifying key")
170                return failure.Failure(UnauthorizedLogin("error while verifying key"))
171        return failure.Failure(UnauthorizedLogin("unable to verify key"))
172
173    def getAuthorizedKeysFiles(self, credentials):
174        """
175        Return a list of L{FilePath} instances for I{authorized_keys} files
176        which might contain information about authorized keys for the given
177        credentials.
178
179        On OpenSSH servers, the default location of the file containing the
180        list of authorized public keys is
181        U{$HOME/.ssh/authorized_keys<http://www.openbsd.org/cgi-bin/man.cgi?query=sshd_config>}.
182
183        I{$HOME/.ssh/authorized_keys2} is also returned, though it has been
184        U{deprecated by OpenSSH since
185        2001<http://marc.info/?m=100508718416162>}.
186
187        @return: A list of L{FilePath} instances to files with the authorized keys.
188        """
189        pwent = self._userdb.getpwnam(credentials.username)
190        root = FilePath(pwent.pw_dir).child(".ssh")
191        files = ["authorized_keys", "authorized_keys2"]
192        return [root.child(f) for f in files]
193
194    def checkKey(self, credentials):
195        """
196        Retrieve files containing authorized keys and check against user
197        credentials.
198        """
199        ouid, ogid = self._userdb.getpwnam(credentials.username)[2:4]
200        for filepath in self.getAuthorizedKeysFiles(credentials):
201            if not filepath.exists():
202                continue
203            try:
204                lines = filepath.open()
205            except OSError as e:
206                if e.errno == errno.EACCES:
207                    lines = runAsEffectiveUser(ouid, ogid, filepath.open)
208                else:
209                    raise
210            with lines:
211                for l in lines:
212                    l2 = l.split()
213                    if len(l2) < 2:
214                        continue
215                    try:
216                        if decodebytes(l2[1]) == credentials.blob:
217                            return True
218                    except binascii.Error:
219                        continue
220        return False
221
222    def _ebRequestAvatarId(self, f):
223        if not f.check(UnauthorizedLogin):
224            _log.error(
225                "Unauthorized login due to internal error: {error}", error=f.value
226            )
227            return failure.Failure(UnauthorizedLogin("unable to get avatar id"))
228        return f
229
230
231@implementer(ICredentialsChecker)
232class SSHProtocolChecker:
233    """
234    SSHProtocolChecker is a checker that requires multiple authentications
235    to succeed.  To add a checker, call my registerChecker method with
236    the checker and the interface.
237
238    After each successful authenticate, I call my areDone method with the
239    avatar id.  To get a list of the successful credentials for an avatar id,
240    use C{SSHProcotolChecker.successfulCredentials[avatarId]}.  If L{areDone}
241    returns True, the authentication has succeeded.
242    """
243
244    def __init__(self):
245        self.checkers = {}
246        self.successfulCredentials = {}
247
248    @property
249    def credentialInterfaces(self):
250        return list(self.checkers.keys())
251
252    def registerChecker(self, checker, *credentialInterfaces):
253        if not credentialInterfaces:
254            credentialInterfaces = checker.credentialInterfaces
255        for credentialInterface in credentialInterfaces:
256            self.checkers[credentialInterface] = checker
257
258    def requestAvatarId(self, credentials):
259        """
260        Part of the L{ICredentialsChecker} interface.  Called by a portal with
261        some credentials to check if they'll authenticate a user.  We check the
262        interfaces that the credentials provide against our list of acceptable
263        checkers.  If one of them matches, we ask that checker to verify the
264        credentials.  If they're valid, we call our L{_cbGoodAuthentication}
265        method to continue.
266
267        @param credentials: the credentials the L{Portal} wants us to verify
268        """
269        ifac = providedBy(credentials)
270        for i in ifac:
271            c = self.checkers.get(i)
272            if c is not None:
273                d = defer.maybeDeferred(c.requestAvatarId, credentials)
274                return d.addCallback(self._cbGoodAuthentication, credentials)
275        return defer.fail(
276            UnhandledCredentials(
277                "No checker for %s" % ", ".join(map(reflect.qual, ifac))
278            )
279        )
280
281    def _cbGoodAuthentication(self, avatarId, credentials):
282        """
283        Called if a checker has verified the credentials.  We call our
284        L{areDone} method to see if the whole of the successful authentications
285        are enough.  If they are, we return the avatar ID returned by the first
286        checker.
287        """
288        if avatarId not in self.successfulCredentials:
289            self.successfulCredentials[avatarId] = []
290        self.successfulCredentials[avatarId].append(credentials)
291        if self.areDone(avatarId):
292            del self.successfulCredentials[avatarId]
293            return avatarId
294        else:
295            raise error.NotEnoughAuthentication()
296
297    def areDone(self, avatarId):
298        """
299        Override to determine if the authentication is finished for a given
300        avatarId.
301
302        @param avatarId: the avatar returned by the first checker.  For
303            this checker to function correctly, all the checkers must
304            return the same avatar ID.
305        """
306        return True
307
308
309deprecatedModuleAttribute(
310    Version("Twisted", 15, 0, 0),
311    (
312        "Please use twisted.conch.checkers.SSHPublicKeyChecker, "
313        "initialized with an instance of "
314        "twisted.conch.checkers.UNIXAuthorizedKeysFiles instead."
315    ),
316    __name__,
317    "SSHPublicKeyDatabase",
318)
319
320
321class IAuthorizedKeysDB(Interface):
322    """
323    An object that provides valid authorized ssh keys mapped to usernames.
324
325    @since: 15.0
326    """
327
328    def getAuthorizedKeys(avatarId):
329        """
330        Gets an iterable of authorized keys that are valid for the given
331        C{avatarId}.
332
333        @param avatarId: the ID of the avatar
334        @type avatarId: valid return value of
335            L{twisted.cred.checkers.ICredentialsChecker.requestAvatarId}
336
337        @return: an iterable of L{twisted.conch.ssh.keys.Key}
338        """
339
340
341def readAuthorizedKeyFile(
342    fileobj: BinaryIO, parseKey: Callable[[bytes], keys.Key] = keys.Key.fromString
343) -> Iterator[keys.Key]:
344    """
345    Reads keys from an authorized keys file.  Any non-comment line that cannot
346    be parsed as a key will be ignored, although that particular line will
347    be logged.
348
349    @param fileobj: something from which to read lines which can be parsed
350        as keys
351    @param parseKey: a callable that takes bytes and returns a
352        L{twisted.conch.ssh.keys.Key}, mainly to be used for testing.  The
353        default is L{twisted.conch.ssh.keys.Key.fromString}.
354    @return: an iterable of L{twisted.conch.ssh.keys.Key}
355    @since: 15.0
356    """
357    for line in fileobj:
358        line = line.strip()
359        if line and not line.startswith(b"#"):  # for comments
360            try:
361                yield parseKey(line)
362            except keys.BadKeyError as e:
363                _log.error(
364                    "Unable to parse line {line!r} as a key: {error!s}",
365                    line=line,
366                    error=e,
367                )
368
369
370def _keysFromFilepaths(filepaths, parseKey):
371    """
372    Helper function that turns an iterable of filepaths into a generator of
373    keys.  If any file cannot be read, a message is logged but it is
374    otherwise ignored.
375
376    @param filepaths: iterable of L{twisted.python.filepath.FilePath}.
377    @type filepaths: iterable
378
379    @param parseKey: a callable that takes a string and returns a
380        L{twisted.conch.ssh.keys.Key}
381    @type parseKey: L{callable}
382
383    @return: generator of L{twisted.conch.ssh.keys.Key}
384    @rtype: generator
385
386    @since: 15.0
387    """
388    for fp in filepaths:
389        if fp.exists():
390            try:
391                with fp.open() as f:
392                    yield from readAuthorizedKeyFile(f, parseKey)
393            except OSError as e:
394                _log.error("Unable to read {path!r}: {error!s}", path=fp.path, error=e)
395
396
397@implementer(IAuthorizedKeysDB)
398class InMemorySSHKeyDB:
399    """
400    Object that provides SSH public keys based on a dictionary of usernames
401    mapped to L{twisted.conch.ssh.keys.Key}s.
402
403    @since: 15.0
404    """
405
406    def __init__(self, mapping):
407        """
408        Initializes a new L{InMemorySSHKeyDB}.
409
410        @param mapping: mapping of usernames to iterables of
411            L{twisted.conch.ssh.keys.Key}s
412        @type mapping: L{dict}
413
414        """
415        self._mapping = mapping
416
417    def getAuthorizedKeys(self, username):
418        return self._mapping.get(username, [])
419
420
421@implementer(IAuthorizedKeysDB)
422class UNIXAuthorizedKeysFiles:
423    """
424    Object that provides SSH public keys based on public keys listed in
425    authorized_keys and authorized_keys2 files in UNIX user .ssh/ directories.
426    If any of the files cannot be read, a message is logged but that file is
427    otherwise ignored.
428
429    @since: 15.0
430    """
431
432    def __init__(self, userdb=None, parseKey=keys.Key.fromString):
433        """
434        Initializes a new L{UNIXAuthorizedKeysFiles}.
435
436        @param userdb: access to the Unix user account and password database
437            (default is the Python module L{pwd})
438        @type userdb: L{pwd}-like object
439
440        @param parseKey: a callable that takes a string and returns a
441            L{twisted.conch.ssh.keys.Key}, mainly to be used for testing.  The
442            default is L{twisted.conch.ssh.keys.Key.fromString}.
443        @type parseKey: L{callable}
444        """
445        self._userdb = userdb
446        self._parseKey = parseKey
447        if userdb is None:
448            self._userdb = pwd
449
450    def getAuthorizedKeys(self, username):
451        try:
452            passwd = self._userdb.getpwnam(username)
453        except KeyError:
454            return ()
455
456        root = FilePath(passwd.pw_dir).child(".ssh")
457        files = ["authorized_keys", "authorized_keys2"]
458        return _keysFromFilepaths((root.child(f) for f in files), self._parseKey)
459
460
461@implementer(ICredentialsChecker)
462class SSHPublicKeyChecker:
463    """
464    Checker that authenticates SSH public keys, based on public keys listed in
465    authorized_keys and authorized_keys2 files in user .ssh/ directories.
466
467    Initializing this checker with a L{UNIXAuthorizedKeysFiles} should be
468    used instead of L{twisted.conch.checkers.SSHPublicKeyDatabase}.
469
470    @since: 15.0
471    """
472
473    credentialInterfaces = (ISSHPrivateKey,)
474
475    def __init__(self, keydb):
476        """
477        Initializes a L{SSHPublicKeyChecker}.
478
479        @param keydb: a provider of L{IAuthorizedKeysDB}
480        @type keydb: L{IAuthorizedKeysDB} provider
481        """
482        self._keydb = keydb
483
484    def requestAvatarId(self, credentials):
485        d = defer.maybeDeferred(self._sanityCheckKey, credentials)
486        d.addCallback(self._checkKey, credentials)
487        d.addCallback(self._verifyKey, credentials)
488        return d
489
490    def _sanityCheckKey(self, credentials):
491        """
492        Checks whether the provided credentials are a valid SSH key with a
493        signature (does not actually verify the signature).
494
495        @param credentials: the credentials offered by the user
496        @type credentials: L{ISSHPrivateKey} provider
497
498        @raise ValidPublicKey: the credentials do not include a signature. See
499            L{error.ValidPublicKey} for more information.
500
501        @raise BadKeyError: The key included with the credentials is not
502            recognized as a key.
503
504        @return: the key in the credentials
505        @rtype: L{twisted.conch.ssh.keys.Key}
506        """
507        if not credentials.signature:
508            raise error.ValidPublicKey()
509
510        return keys.Key.fromString(credentials.blob)
511
512    def _checkKey(self, pubKey, credentials):
513        """
514        Checks the public key against all authorized keys (if any) for the
515        user.
516
517        @param pubKey: the key in the credentials (just to prevent it from
518            having to be calculated again)
519        @type pubKey:
520
521        @param credentials: the credentials offered by the user
522        @type credentials: L{ISSHPrivateKey} provider
523
524        @raise UnauthorizedLogin: If the key is not authorized, or if there
525            was any error obtaining a list of authorized keys for the user.
526
527        @return: C{pubKey} if the key is authorized
528        @rtype: L{twisted.conch.ssh.keys.Key}
529        """
530        if any(
531            key == pubKey for key in self._keydb.getAuthorizedKeys(credentials.username)
532        ):
533            return pubKey
534
535        raise UnauthorizedLogin("Key not authorized")
536
537    def _verifyKey(self, pubKey, credentials):
538        """
539        Checks whether the credentials themselves are valid, now that we know
540        if the key matches the user.
541
542        @param pubKey: the key in the credentials (just to prevent it from
543            having to be calculated again)
544        @type pubKey: L{twisted.conch.ssh.keys.Key}
545
546        @param credentials: the credentials offered by the user
547        @type credentials: L{ISSHPrivateKey} provider
548
549        @raise UnauthorizedLogin: If the key signature is invalid or there
550            was any error verifying the signature.
551
552        @return: The user's username, if authentication was successful
553        @rtype: L{bytes}
554        """
555        try:
556            if pubKey.verify(credentials.signature, credentials.sigData):
557                return credentials.username
558        except Exception as e:  # Any error should be treated as a failed login
559            raise UnauthorizedLogin("Error while verifying key") from e
560
561        raise UnauthorizedLogin("Key signature invalid.")
562