1# Copyright (C) 2012 Canonical Ltd. 2# Copyright (C) 2012 Hewlett-Packard Development Company, L.P. 3# 4# Author: Scott Moser <scott.moser@canonical.com> 5# Author: Juerg Hafliger <juerg.haefliger@hp.com> 6# 7# This file is part of cloud-init. See LICENSE file for license information. 8 9import os 10import pwd 11 12from cloudinit import log as logging 13from cloudinit import util 14 15LOG = logging.getLogger(__name__) 16 17# See: man sshd_config 18DEF_SSHD_CFG = "/etc/ssh/sshd_config" 19 20# this list has been filtered out from keytypes of OpenSSH source 21# openssh-8.3p1/sshkey.c: 22# static const struct keytype keytypes[] = { 23# filter out the keytypes with the sigonly flag, eg: 24# { "rsa-sha2-256", "RSA", NULL, KEY_RSA, 0, 0, 1 }, 25# refer to the keytype struct of OpenSSH in the same file, to see 26# if the position of the sigonly flag has been moved. 27# 28# dsa, rsa, ecdsa and ed25519 are added for legacy, as they are valid 29# public keys in some old distros. They can possibly be removed 30# in the future when support for the older distros is dropped 31# 32# When updating the list, also update the _is_printable_key list in 33# cloudinit/config/cc_ssh_authkey_fingerprints.py 34VALID_KEY_TYPES = ( 35 "dsa", 36 "rsa", 37 "ecdsa", 38 "ed25519", 39 "ecdsa-sha2-nistp256-cert-v01@openssh.com", 40 "ecdsa-sha2-nistp256", 41 "ecdsa-sha2-nistp384-cert-v01@openssh.com", 42 "ecdsa-sha2-nistp384", 43 "ecdsa-sha2-nistp521-cert-v01@openssh.com", 44 "ecdsa-sha2-nistp521", 45 "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com", 46 "sk-ecdsa-sha2-nistp256@openssh.com", 47 "sk-ssh-ed25519-cert-v01@openssh.com", 48 "sk-ssh-ed25519@openssh.com", 49 "ssh-dss-cert-v01@openssh.com", 50 "ssh-dss", 51 "ssh-ed25519-cert-v01@openssh.com", 52 "ssh-ed25519", 53 "ssh-rsa-cert-v01@openssh.com", 54 "ssh-rsa", 55 "ssh-xmss-cert-v01@openssh.com", 56 "ssh-xmss@openssh.com", 57) 58 59_DISABLE_USER_SSH_EXIT = 142 60 61DISABLE_USER_OPTS = ( 62 "no-port-forwarding,no-agent-forwarding," 63 "no-X11-forwarding,command=\"echo \'Please login as the user \\\"$USER\\\"" 64 " rather than the user \\\"$DISABLE_USER\\\".\';echo;sleep 10;" 65 "exit " + str(_DISABLE_USER_SSH_EXIT) + "\"") 66 67 68class AuthKeyLine(object): 69 def __init__(self, source, keytype=None, base64=None, 70 comment=None, options=None): 71 self.base64 = base64 72 self.comment = comment 73 self.options = options 74 self.keytype = keytype 75 self.source = source 76 77 def valid(self): 78 return (self.base64 and self.keytype) 79 80 def __str__(self): 81 toks = [] 82 if self.options: 83 toks.append(self.options) 84 if self.keytype: 85 toks.append(self.keytype) 86 if self.base64: 87 toks.append(self.base64) 88 if self.comment: 89 toks.append(self.comment) 90 if not toks: 91 return self.source 92 else: 93 return ' '.join(toks) 94 95 96class AuthKeyLineParser(object): 97 """ 98 AUTHORIZED_KEYS FILE FORMAT 99 AuthorizedKeysFile specifies the file containing public keys for public 100 key authentication; if none is specified, the default is 101 ~/.ssh/authorized_keys. Each line of the file contains one key (empty 102 (because of the size of the public key encoding) up to a limit of 8 kilo- 103 bytes, which permits DSA keys up to 8 kilobits and RSA keys up to 16 104 kilobits. You don't want to type them in; instead, copy the 105 identity.pub, id_dsa.pub, or the id_rsa.pub file and edit it. 106 107 sshd enforces a minimum RSA key modulus size for protocol 1 and protocol 108 2 keys of 768 bits. 109 110 The options (if present) consist of comma-separated option specifica- 111 tions. No spaces are permitted, except within double quotes. The fol- 112 lowing option specifications are supported (note that option keywords are 113 case-insensitive): 114 """ 115 116 def _extract_options(self, ent): 117 """ 118 The options (if present) consist of comma-separated option specifica- 119 tions. No spaces are permitted, except within double quotes. 120 Note that option keywords are case-insensitive. 121 """ 122 quoted = False 123 i = 0 124 while (i < len(ent) and 125 ((quoted) or (ent[i] not in (" ", "\t")))): 126 curc = ent[i] 127 if i + 1 >= len(ent): 128 i = i + 1 129 break 130 nextc = ent[i + 1] 131 if curc == "\\" and nextc == '"': 132 i = i + 1 133 elif curc == '"': 134 quoted = not quoted 135 i = i + 1 136 137 options = ent[0:i] 138 139 # Return the rest of the string in 'remain' 140 remain = ent[i:].lstrip() 141 return (options, remain) 142 143 def parse(self, src_line, options=None): 144 # modeled after opensshes auth2-pubkey.c:user_key_allowed2 145 line = src_line.rstrip("\r\n") 146 if line.startswith("#") or line.strip() == '': 147 return AuthKeyLine(src_line) 148 149 def parse_ssh_key(ent): 150 # return ketype, key, [comment] 151 toks = ent.split(None, 2) 152 if len(toks) < 2: 153 raise TypeError("To few fields: %s" % len(toks)) 154 if toks[0] not in VALID_KEY_TYPES: 155 raise TypeError("Invalid keytype %s" % toks[0]) 156 157 # valid key type and 2 or 3 fields: 158 if len(toks) == 2: 159 # no comment in line 160 toks.append("") 161 162 return toks 163 164 ent = line.strip() 165 try: 166 (keytype, base64, comment) = parse_ssh_key(ent) 167 except TypeError: 168 (keyopts, remain) = self._extract_options(ent) 169 if options is None: 170 options = keyopts 171 172 try: 173 (keytype, base64, comment) = parse_ssh_key(remain) 174 except TypeError: 175 return AuthKeyLine(src_line) 176 177 return AuthKeyLine(src_line, keytype=keytype, base64=base64, 178 comment=comment, options=options) 179 180 181def parse_authorized_keys(fnames): 182 lines = [] 183 parser = AuthKeyLineParser() 184 contents = [] 185 for fname in fnames: 186 try: 187 if os.path.isfile(fname): 188 lines = util.load_file(fname).splitlines() 189 for line in lines: 190 contents.append(parser.parse(line)) 191 except (IOError, OSError): 192 util.logexc(LOG, "Error reading lines from %s", fname) 193 194 return contents 195 196 197def update_authorized_keys(old_entries, keys): 198 to_add = list([k for k in keys if k.valid()]) 199 for i in range(0, len(old_entries)): 200 ent = old_entries[i] 201 if not ent.valid(): 202 continue 203 # Replace those with the same base64 204 for k in keys: 205 if k.base64 == ent.base64: 206 # Replace it with our better one 207 ent = k 208 # Don't add it later 209 if k in to_add: 210 to_add.remove(k) 211 old_entries[i] = ent 212 213 # Now append any entries we did not match above 214 for key in to_add: 215 old_entries.append(key) 216 217 # Now format them back to strings... 218 lines = [str(b) for b in old_entries] 219 220 # Ensure it ends with a newline 221 lines.append('') 222 return '\n'.join(lines) 223 224 225def users_ssh_info(username): 226 pw_ent = pwd.getpwnam(username) 227 if not pw_ent or not pw_ent.pw_dir: 228 raise RuntimeError("Unable to get SSH info for user %r" % (username)) 229 return (os.path.join(pw_ent.pw_dir, '.ssh'), pw_ent) 230 231 232def render_authorizedkeysfile_paths(value, homedir, username): 233 # The 'AuthorizedKeysFile' may contain tokens 234 # of the form %T which are substituted during connection set-up. 235 # The following tokens are defined: %% is replaced by a literal 236 # '%', %h is replaced by the home directory of the user being 237 # authenticated and %u is replaced by the username of that user. 238 macros = (("%h", homedir), ("%u", username), ("%%", "%")) 239 if not value: 240 value = "%h/.ssh/authorized_keys" 241 paths = value.split() 242 rendered = [] 243 for path in paths: 244 for macro, field in macros: 245 path = path.replace(macro, field) 246 if not path.startswith("/"): 247 path = os.path.join(homedir, path) 248 rendered.append(path) 249 return rendered 250 251 252# Inspired from safe_path() in openssh source code (misc.c). 253def check_permissions(username, current_path, full_path, is_file, strictmodes): 254 """Check if the file/folder in @current_path has the right permissions. 255 256 We need to check that: 257 1. If StrictMode is enabled, the owner is either root or the user 258 2. the user can access the file/folder, otherwise ssh won't use it 259 3. If StrictMode is enabled, no write permission is given to group 260 and world users (022) 261 """ 262 263 # group/world can only execute the folder (access) 264 minimal_permissions = 0o711 265 if is_file: 266 # group/world can only read the file 267 minimal_permissions = 0o644 268 269 # 1. owner must be either root or the user itself 270 owner = util.get_owner(current_path) 271 if strictmodes and owner != username and owner != "root": 272 LOG.debug("Path %s in %s must be own by user %s or" 273 " by root, but instead is own by %s. Ignoring key.", 274 current_path, full_path, username, owner) 275 return False 276 277 parent_permission = util.get_permissions(current_path) 278 # 2. the user can access the file/folder, otherwise ssh won't use it 279 if owner == username: 280 # need only the owner permissions 281 minimal_permissions &= 0o700 282 else: 283 group_owner = util.get_group(current_path) 284 user_groups = util.get_user_groups(username) 285 286 if group_owner in user_groups: 287 # need only the group permissions 288 minimal_permissions &= 0o070 289 else: 290 # need only the world permissions 291 minimal_permissions &= 0o007 292 293 if parent_permission & minimal_permissions == 0: 294 LOG.debug("Path %s in %s must be accessible by user %s," 295 " check its permissions", 296 current_path, full_path, username) 297 return False 298 299 # 3. no write permission (w) is given to group and world users (022) 300 # Group and world user can still have +rx. 301 if strictmodes and parent_permission & 0o022 != 0: 302 LOG.debug("Path %s in %s must not give write" 303 "permission to group or world users. Ignoring key.", 304 current_path, full_path) 305 return False 306 307 return True 308 309 310def check_create_path(username, filename, strictmodes): 311 user_pwent = users_ssh_info(username)[1] 312 root_pwent = users_ssh_info("root")[1] 313 try: 314 # check the directories first 315 directories = filename.split("/")[1:-1] 316 317 # scan in order, from root to file name 318 parent_folder = "" 319 # this is to comply also with unit tests, and 320 # strange home directories 321 home_folder = os.path.dirname(user_pwent.pw_dir) 322 for directory in directories: 323 parent_folder += "/" + directory 324 325 # security check, disallow symlinks in the AuthorizedKeysFile path. 326 if os.path.islink(parent_folder): 327 LOG.debug( 328 "Invalid directory. Symlink exists in path: %s", 329 parent_folder) 330 return False 331 332 if os.path.isfile(parent_folder): 333 LOG.debug( 334 "Invalid directory. File exists in path: %s", 335 parent_folder) 336 return False 337 338 if (home_folder.startswith(parent_folder) or 339 parent_folder == user_pwent.pw_dir): 340 continue 341 342 if not os.path.exists(parent_folder): 343 # directory does not exist, and permission so far are good: 344 # create the directory, and make it accessible by everyone 345 # but owned by root, as it might be used by many users. 346 with util.SeLinuxGuard(parent_folder): 347 mode = 0o755 348 uid = root_pwent.pw_uid 349 gid = root_pwent.pw_gid 350 if parent_folder.startswith(user_pwent.pw_dir): 351 mode = 0o700 352 uid = user_pwent.pw_uid 353 gid = user_pwent.pw_gid 354 os.makedirs(parent_folder, mode=mode, exist_ok=True) 355 util.chownbyid(parent_folder, uid, gid) 356 357 permissions = check_permissions(username, parent_folder, 358 filename, False, strictmodes) 359 if not permissions: 360 return False 361 362 if os.path.islink(filename) or os.path.isdir(filename): 363 LOG.debug("%s is not a file!", filename) 364 return False 365 366 # check the file 367 if not os.path.exists(filename): 368 # if file does not exist: we need to create it, since the 369 # folders at this point exist and have right permissions 370 util.write_file(filename, '', mode=0o600, ensure_dir_exists=True) 371 util.chownbyid(filename, user_pwent.pw_uid, user_pwent.pw_gid) 372 373 permissions = check_permissions(username, filename, 374 filename, True, strictmodes) 375 if not permissions: 376 return False 377 except (IOError, OSError) as e: 378 util.logexc(LOG, str(e)) 379 return False 380 381 return True 382 383 384def extract_authorized_keys(username, sshd_cfg_file=DEF_SSHD_CFG): 385 (ssh_dir, pw_ent) = users_ssh_info(username) 386 default_authorizedkeys_file = os.path.join(ssh_dir, 'authorized_keys') 387 user_authorizedkeys_file = default_authorizedkeys_file 388 auth_key_fns = [] 389 with util.SeLinuxGuard(ssh_dir, recursive=True): 390 try: 391 ssh_cfg = parse_ssh_config_map(sshd_cfg_file) 392 key_paths = ssh_cfg.get("authorizedkeysfile", 393 "%h/.ssh/authorized_keys") 394 strictmodes = ssh_cfg.get("strictmodes", "yes") 395 auth_key_fns = render_authorizedkeysfile_paths( 396 key_paths, pw_ent.pw_dir, username) 397 398 except (IOError, OSError): 399 # Give up and use a default key filename 400 auth_key_fns[0] = default_authorizedkeys_file 401 util.logexc(LOG, "Failed extracting 'AuthorizedKeysFile' in SSH " 402 "config from %r, using 'AuthorizedKeysFile' file " 403 "%r instead", DEF_SSHD_CFG, auth_key_fns[0]) 404 405 # check if one of the keys is the user's one and has the right permissions 406 for key_path, auth_key_fn in zip(key_paths.split(), auth_key_fns): 407 if any([ 408 '%u' in key_path, 409 '%h' in key_path, 410 auth_key_fn.startswith('{}/'.format(pw_ent.pw_dir)) 411 ]): 412 permissions_ok = check_create_path(username, auth_key_fn, 413 strictmodes == "yes") 414 if permissions_ok: 415 user_authorizedkeys_file = auth_key_fn 416 break 417 418 if user_authorizedkeys_file != default_authorizedkeys_file: 419 LOG.debug( 420 "AuthorizedKeysFile has an user-specific authorized_keys, " 421 "using %s", user_authorizedkeys_file) 422 423 return ( 424 user_authorizedkeys_file, 425 parse_authorized_keys([user_authorizedkeys_file]) 426 ) 427 428 429def setup_user_keys(keys, username, options=None): 430 # Turn the 'update' keys given into actual entries 431 parser = AuthKeyLineParser() 432 key_entries = [] 433 for k in keys: 434 key_entries.append(parser.parse(str(k), options=options)) 435 436 # Extract the old and make the new 437 (auth_key_fn, auth_key_entries) = extract_authorized_keys(username) 438 ssh_dir = os.path.dirname(auth_key_fn) 439 with util.SeLinuxGuard(ssh_dir, recursive=True): 440 content = update_authorized_keys(auth_key_entries, key_entries) 441 util.write_file(auth_key_fn, content, preserve_mode=True) 442 443 444class SshdConfigLine(object): 445 def __init__(self, line, k=None, v=None): 446 self.line = line 447 self._key = k 448 self.value = v 449 450 @property 451 def key(self): 452 if self._key is None: 453 return None 454 # Keywords are case-insensitive 455 return self._key.lower() 456 457 def __str__(self): 458 if self._key is None: 459 return str(self.line) 460 else: 461 v = str(self._key) 462 if self.value: 463 v += " " + str(self.value) 464 return v 465 466 467def parse_ssh_config(fname): 468 if not os.path.isfile(fname): 469 return [] 470 return parse_ssh_config_lines(util.load_file(fname).splitlines()) 471 472 473def parse_ssh_config_lines(lines): 474 # See: man sshd_config 475 # The file contains keyword-argument pairs, one per line. 476 # Lines starting with '#' and empty lines are interpreted as comments. 477 # Note: key-words are case-insensitive and arguments are case-sensitive 478 ret = [] 479 for line in lines: 480 line = line.strip() 481 if not line or line.startswith("#"): 482 ret.append(SshdConfigLine(line)) 483 continue 484 try: 485 key, val = line.split(None, 1) 486 except ValueError: 487 try: 488 key, val = line.split('=', 1) 489 except ValueError: 490 LOG.debug( 491 "sshd_config: option \"%s\" has no key/value pair," 492 " skipping it", line) 493 continue 494 ret.append(SshdConfigLine(line, key, val)) 495 return ret 496 497 498def parse_ssh_config_map(fname): 499 lines = parse_ssh_config(fname) 500 if not lines: 501 return {} 502 ret = {} 503 for line in lines: 504 if not line.key: 505 continue 506 ret[line.key] = line.value 507 return ret 508 509 510def update_ssh_config(updates, fname=DEF_SSHD_CFG): 511 """Read fname, and update if changes are necessary. 512 513 @param updates: dictionary of desired values {Option: value} 514 @return: boolean indicating if an update was done.""" 515 lines = parse_ssh_config(fname) 516 changed = update_ssh_config_lines(lines=lines, updates=updates) 517 if changed: 518 util.write_file( 519 fname, "\n".join( 520 [str(line) for line in lines] 521 ) + "\n", preserve_mode=True) 522 return len(changed) != 0 523 524 525def update_ssh_config_lines(lines, updates): 526 """Update the SSH config lines per updates. 527 528 @param lines: array of SshdConfigLine. This array is updated in place. 529 @param updates: dictionary of desired values {Option: value} 530 @return: A list of keys in updates that were changed.""" 531 found = set() 532 changed = [] 533 534 # Keywords are case-insensitive and arguments are case-sensitive 535 casemap = dict([(k.lower(), k) for k in updates.keys()]) 536 537 for (i, line) in enumerate(lines, start=1): 538 if not line.key: 539 continue 540 if line.key in casemap: 541 key = casemap[line.key] 542 value = updates[key] 543 found.add(key) 544 if line.value == value: 545 LOG.debug("line %d: option %s already set to %s", 546 i, key, value) 547 else: 548 changed.append(key) 549 LOG.debug("line %d: option %s updated %s -> %s", i, 550 key, line.value, value) 551 line.value = value 552 553 if len(found) != len(updates): 554 for key, value in updates.items(): 555 if key in found: 556 continue 557 changed.append(key) 558 lines.append(SshdConfigLine('', key, value)) 559 LOG.debug("line %d: option %s added with %s", 560 len(lines), key, value) 561 return changed 562 563# vi: ts=4 expandtab 564