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