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