1"""
2Control of entries in SSH authorized_key files
3==============================================
4
5The information stored in a user's SSH authorized key file can be easily
6controlled via the ssh_auth state. Defaults can be set by the enc, options,
7and comment keys. These defaults can be overridden by including them in the
8name.
9
10Since the YAML specification limits the length of simple keys to 1024
11characters, and since SSH keys are often longer than that, you may have
12to use a YAML 'explicit key', as demonstrated in the second example below.
13
14.. code-block:: yaml
15
16    AAAAB3NzaC1kc3MAAACBAL0sQ9fJ5bYTEyY==:
17      ssh_auth.present:
18        - user: root
19        - enc: ssh-dss
20
21    ? AAAAB3NzaC1kc3MAAACBAL0sQ9fJ5bYTEyY==...
22    :
23      ssh_auth.present:
24        - user: root
25        - enc: ssh-dss
26
27    thatch:
28      ssh_auth.present:
29        - user: root
30        - source: salt://ssh_keys/thatch.id_rsa.pub
31        - config: '%h/.ssh/authorized_keys'
32
33    sshkeys:
34      ssh_auth.present:
35        - user: root
36        - enc: ssh-rsa
37        - options:
38          - option1="value1"
39          - option2="value2 flag2"
40        - comment: myuser
41        - names:
42          - AAAAB3NzaC1kc3MAAACBAL0sQ9fJ5bYTEyY==
43          - ssh-dss AAAAB3NzaCL0sQ9fJ5bYTEyY== user@domain
44          - option3="value3" ssh-dss AAAAB3NzaC1kcQ9J5bYTEyY== other@testdomain
45          - AAAAB3NzaC1kcQ9fJFF435bYTEyY== newcomment
46
47    sshkeys:
48      ssh_auth.manage:
49        - user: root
50        - enc: ssh-rsa
51        - options:
52          - option1="value1"
53          - option2="value2 flag2"
54        - comment: myuser
55        - ssh_keys:
56          - AAAAB3NzaC1kc3MAAACBAL0sQ9fJ5bYTEyY==
57          - ssh-dss AAAAB3NzaCL0sQ9fJ5bYTEyY== user@domain
58          - option3="value3" ssh-dss AAAAB3NzaC1kcQ9J5bYTEyY== other@testdomain
59          - AAAAB3NzaC1kcQ9fJFF435bYTEyY== newcomment
60"""
61
62
63import re
64import sys
65
66
67def _present_test(
68    user, name, enc, comment, options, source, config, fingerprint_hash_type
69):
70    """
71    Run checks for "present"
72    """
73    result = None
74    if source:
75        keys = __salt__["ssh.check_key_file"](
76            user,
77            source,
78            config,
79            saltenv=__env__,
80            fingerprint_hash_type=fingerprint_hash_type,
81        )
82        if keys:
83            comment = ""
84            for key, status in keys.items():
85                if status == "exists":
86                    continue
87                comment += "Set to {}: {}\n".format(status, key)
88            if comment:
89                return result, comment
90        err = sys.modules[__salt__["test.ping"].__module__].__context__.pop(
91            "ssh_auth.error", None
92        )
93        if err:
94            return False, err
95        else:
96            return (
97                True,
98                "All host keys in file {} are already present".format(source),
99            )
100    else:
101        # check if this is of form {options} {enc} {key} {comment}
102        sshre = re.compile(r"^(.*?)\s?((?:ssh\-|ecds)[\w-]+\s.+)$")
103        fullkey = sshre.search(name)
104        # if it is {key} [comment]
105        if not fullkey:
106            key_and_comment = name.split()
107            name = key_and_comment[0]
108            if len(key_and_comment) == 2:
109                comment = key_and_comment[1]
110        else:
111            # if there are options, set them
112            if fullkey.group(1):
113                options = fullkey.group(1).split(",")
114            # key is of format: {enc} {key} [comment]
115            comps = fullkey.group(2).split()
116            enc = comps[0]
117            name = comps[1]
118            if len(comps) == 3:
119                comment = comps[2]
120
121    check = __salt__["ssh.check_key"](
122        user,
123        name,
124        enc,
125        comment,
126        options,
127        config=config,
128        fingerprint_hash_type=fingerprint_hash_type,
129    )
130    if check == "update":
131        comment = "Key {} for user {} is set to be updated".format(name, user)
132    elif check == "add":
133        comment = "Key {} for user {} is set to be added".format(name, user)
134    elif check == "exists":
135        result = True
136        comment = "The authorized host key {} is already present for user {}".format(
137            name, user
138        )
139
140    return result, comment
141
142
143def _absent_test(
144    user, name, enc, comment, options, source, config, fingerprint_hash_type
145):
146    """
147    Run checks for "absent"
148    """
149    result = None
150    if source:
151        keys = __salt__["ssh.check_key_file"](
152            user,
153            source,
154            config,
155            saltenv=__env__,
156            fingerprint_hash_type=fingerprint_hash_type,
157        )
158        if keys:
159            comment = ""
160            for key, status in list(keys.items()):
161                if status == "add":
162                    continue
163                comment += "Set to remove: {}\n".format(key)
164            if comment:
165                return result, comment
166        err = sys.modules[__salt__["test.ping"].__module__].__context__.pop(
167            "ssh_auth.error", None
168        )
169        if err:
170            return False, err
171        else:
172            return (True, "All host keys in file {} are already absent".format(source))
173    else:
174        # check if this is of form {options} {enc} {key} {comment}
175        sshre = re.compile(r"^(.*?)\s?((?:ssh\-|ecds)[\w-]+\s.+)$")
176        fullkey = sshre.search(name)
177        # if it is {key} [comment]
178        if not fullkey:
179            key_and_comment = name.split()
180            name = key_and_comment[0]
181            if len(key_and_comment) == 2:
182                comment = key_and_comment[1]
183        else:
184            # if there are options, set them
185            if fullkey.group(1):
186                options = fullkey.group(1).split(",")
187            # key is of format: {enc} {key} [comment]
188            comps = fullkey.group(2).split()
189            enc = comps[0]
190            name = comps[1]
191            if len(comps) == 3:
192                comment = comps[2]
193
194    check = __salt__["ssh.check_key"](
195        user,
196        name,
197        enc,
198        comment,
199        options,
200        config=config,
201        fingerprint_hash_type=fingerprint_hash_type,
202    )
203    if check == "update" or check == "exists":
204        comment = "Key {} for user {} is set for removal".format(name, user)
205    else:
206        comment = "Key is already absent"
207        result = True
208
209    return result, comment
210
211
212def present(
213    name,
214    user,
215    enc="ssh-rsa",
216    comment="",
217    source="",
218    options=None,
219    config=".ssh/authorized_keys",
220    fingerprint_hash_type=None,
221    **kwargs
222):
223    """
224    Verifies that the specified SSH key is present for the specified user
225
226    name
227        The SSH key to manage
228
229    user
230        The user who owns the SSH authorized keys file to modify
231
232    enc
233        Defines what type of key is being used; can be ed25519, ecdsa, ssh-rsa
234        or ssh-dss
235
236    comment
237        The comment to be placed with the SSH public key
238
239    source
240        The source file for the key(s). Can contain any number of public keys,
241        in standard "authorized_keys" format. If this is set, comment and enc
242        will be ignored.
243
244    .. note::
245        The source file must contain keys in the format ``<enc> <key>
246        <comment>``. If you have generated a keypair using PuTTYgen, then you
247        will need to do the following to retrieve an OpenSSH-compatible public
248        key.
249
250        1. In PuTTYgen, click ``Load``, and select the *private* key file (not
251           the public key), and click ``Open``.
252        2. Copy the public key from the box labeled ``Public key for pasting
253           into OpenSSH authorized_keys file``.
254        3. Paste it into a new file.
255
256    options
257        The options passed to the key, pass a list object
258
259    config
260        The location of the authorized keys file relative to the user's home
261        directory, defaults to ".ssh/authorized_keys". Token expansion %u and
262        %h for username and home path supported.
263
264    fingerprint_hash_type
265        The public key fingerprint hash type that the public key fingerprint
266        was originally hashed with. This defaults to ``sha256`` if not specified.
267    """
268    ret = {"name": name, "changes": {}, "result": True, "comment": ""}
269
270    if source == "":
271        # check if this is of form {options} {enc} {key} {comment}
272        sshre = re.compile(r"^(.*?)\s?((?:ssh\-|ecds)[\w-]+\s.+)$")
273        fullkey = sshre.search(name)
274        # if it is {key} [comment]
275        if not fullkey:
276            key_and_comment = name.split(None, 1)
277            name = key_and_comment[0]
278            if len(key_and_comment) == 2:
279                comment = key_and_comment[1]
280        else:
281            # if there are options, set them
282            if fullkey.group(1):
283                options = fullkey.group(1).split(",")
284            # key is of format: {enc} {key} [comment]
285            comps = fullkey.group(2).split(None, 2)
286            enc = comps[0]
287            name = comps[1]
288            if len(comps) == 3:
289                comment = comps[2]
290
291    if __opts__["test"]:
292        ret["result"], ret["comment"] = _present_test(
293            user,
294            name,
295            enc,
296            comment,
297            options or [],
298            source,
299            config,
300            fingerprint_hash_type,
301        )
302        return ret
303
304    # Get only the path to the file without env referrences to check if exists
305    if source != "":
306        source_path = __salt__["cp.get_url"](source, None, saltenv=__env__)
307
308    if source != "" and not source_path:
309        data = "no key"
310    elif source != "" and source_path:
311        key = __salt__["cp.get_file_str"](source, saltenv=__env__)
312        filehasoptions = False
313        # check if this is of form {options} {enc} {key} {comment}
314        sshre = re.compile(r"^(ssh\-|ecds).*")
315        key = key.rstrip().split("\n")
316        for keyline in key:
317            filehasoptions = sshre.match(keyline)
318            if not filehasoptions:
319                data = __salt__["ssh.set_auth_key_from_file"](
320                    user,
321                    source,
322                    config=config,
323                    saltenv=__env__,
324                    fingerprint_hash_type=fingerprint_hash_type,
325                )
326            else:
327                # Split keyline to get key and comment
328                keyline = keyline.split(" ")
329                key_type = keyline[0]
330                key_value = keyline[1]
331                key_comment = keyline[2] if len(keyline) > 2 else ""
332                data = __salt__["ssh.set_auth_key"](
333                    user,
334                    key_value,
335                    enc=key_type,
336                    comment=key_comment,
337                    options=options or [],
338                    config=config,
339                    fingerprint_hash_type=fingerprint_hash_type,
340                )
341    else:
342        data = __salt__["ssh.set_auth_key"](
343            user,
344            name,
345            enc=enc,
346            comment=comment,
347            options=options or [],
348            config=config,
349            fingerprint_hash_type=fingerprint_hash_type,
350        )
351
352    if data == "replace":
353        ret["changes"][name] = "Updated"
354        ret["comment"] = "The authorized host key {} for user {} was updated".format(
355            name, user
356        )
357        return ret
358    elif data == "no change":
359        ret[
360            "comment"
361        ] = "The authorized host key {} is already present for user {}".format(
362            name, user
363        )
364    elif data == "new":
365        ret["changes"][name] = "New"
366        ret["comment"] = "The authorized host key {} for user {} was added".format(
367            name, user
368        )
369    elif data == "no key":
370        ret["result"] = False
371        ret["comment"] = "Failed to add the ssh key. Source file {} is missing".format(
372            source
373        )
374    elif data == "fail":
375        ret["result"] = False
376        err = sys.modules[__salt__["test.ping"].__module__].__context__.pop(
377            "ssh_auth.error", None
378        )
379        if err:
380            ret["comment"] = err
381        else:
382            ret["comment"] = (
383                "Failed to add the ssh key. Is the home "
384                "directory available, and/or does the key file "
385                "exist?"
386            )
387    elif data == "invalid" or data == "Invalid public key":
388        ret["result"] = False
389        ret[
390            "comment"
391        ] = "Invalid public ssh key, most likely has spaces or invalid syntax"
392
393    return ret
394
395
396def absent(
397    name,
398    user,
399    enc="ssh-rsa",
400    comment="",
401    source="",
402    options=None,
403    config=".ssh/authorized_keys",
404    fingerprint_hash_type=None,
405):
406    """
407    Verifies that the specified SSH key is absent
408
409    name
410        The SSH key to manage
411
412    user
413        The user who owns the SSH authorized keys file to modify
414
415    enc
416        Defines what type of key is being used; can be ed25519, ecdsa, ssh-rsa
417        or ssh-dss
418
419    comment
420        The comment to be placed with the SSH public key
421
422    options
423        The options passed to the key, pass a list object
424
425    source
426        The source file for the key(s). Can contain any number of public keys,
427        in standard "authorized_keys" format. If this is set, comment, enc and
428        options will be ignored.
429
430        .. versionadded:: 2015.8.0
431
432    config
433        The location of the authorized keys file relative to the user's home
434        directory, defaults to ".ssh/authorized_keys". Token expansion %u and
435        %h for username and home path supported.
436
437    fingerprint_hash_type
438        The public key fingerprint hash type that the public key fingerprint
439        was originally hashed with. This defaults to ``sha256`` if not specified.
440
441        .. versionadded:: 2016.11.7
442    """
443    ret = {"name": name, "changes": {}, "result": True, "comment": ""}
444
445    if __opts__["test"]:
446        ret["result"], ret["comment"] = _absent_test(
447            user,
448            name,
449            enc,
450            comment,
451            options or [],
452            source,
453            config,
454            fingerprint_hash_type,
455        )
456        return ret
457
458    # Extract Key from file if source is present
459    if source != "":
460        key = __salt__["cp.get_file_str"](source, saltenv=__env__)
461        filehasoptions = False
462        # check if this is of form {options} {enc} {key} {comment}
463        sshre = re.compile(r"^(ssh\-|ecds).*")
464        key = key.rstrip().split("\n")
465        for keyline in key:
466            filehasoptions = sshre.match(keyline)
467            if not filehasoptions:
468                ret["comment"] = __salt__["ssh.rm_auth_key_from_file"](
469                    user,
470                    source,
471                    config,
472                    saltenv=__env__,
473                    fingerprint_hash_type=fingerprint_hash_type,
474                )
475            else:
476                # Split keyline to get key
477                keyline = keyline.split(" ")
478                ret["comment"] = __salt__["ssh.rm_auth_key"](
479                    user,
480                    keyline[1],
481                    config=config,
482                    fingerprint_hash_type=fingerprint_hash_type,
483                )
484    else:
485        # Get just the key
486        sshre = re.compile(r"^(.*?)\s?((?:ssh\-|ecds)[\w-]+\s.+)$")
487        fullkey = sshre.search(name)
488        # if it is {key} [comment]
489        if not fullkey:
490            key_and_comment = name.split(None, 1)
491            name = key_and_comment[0]
492            if len(key_and_comment) == 2:
493                comment = key_and_comment[1]
494        else:
495            # if there are options, set them
496            if fullkey.group(1):
497                options = fullkey.group(1).split(",")
498            # key is of format: {enc} {key} [comment]
499            comps = fullkey.group(2).split()
500            enc = comps[0]
501            name = comps[1]
502            if len(comps) == 3:
503                comment = comps[2]
504        ret["comment"] = __salt__["ssh.rm_auth_key"](
505            user, name, config=config, fingerprint_hash_type=fingerprint_hash_type
506        )
507
508    if ret["comment"] == "User authorized keys file not present":
509        ret["result"] = False
510        return ret
511    elif ret["comment"] == "Key removed":
512        ret["changes"][name] = "Removed"
513
514    return ret
515
516
517def manage(
518    name,
519    ssh_keys,
520    user,
521    enc="ssh-rsa",
522    comment="",
523    source="",
524    options=None,
525    config=".ssh/authorized_keys",
526    fingerprint_hash_type=None,
527    **kwargs
528):
529    """
530    .. versionadded:: 3000
531
532    Ensures that only the specified ssh_keys are present for the specified user
533
534    ssh_keys
535        The SSH key to manage
536
537    user
538        The user who owns the SSH authorized keys file to modify
539
540    enc
541        Defines what type of key is being used; can be ed25519, ecdsa, ssh-rsa
542        or ssh-dss
543
544    comment
545        The comment to be placed with the SSH public key
546
547    source
548        The source file for the key(s). Can contain any number of public keys,
549        in standard "authorized_keys" format. If this is set, comment and enc
550        will be ignored.
551
552    .. note::
553        The source file must contain keys in the format ``<enc> <key>
554        <comment>``. If you have generated a keypair using PuTTYgen, then you
555        will need to do the following to retrieve an OpenSSH-compatible public
556        key.
557
558        1. In PuTTYgen, click ``Load``, and select the *private* key file (not
559           the public key), and click ``Open``.
560        2. Copy the public key from the box labeled ``Public key for pasting
561           into OpenSSH authorized_keys file``.
562        3. Paste it into a new file.
563
564    options
565        The options passed to the keys, pass a list object
566
567    config
568        The location of the authorized keys file relative to the user's home
569        directory, defaults to ".ssh/authorized_keys". Token expansion %u and
570        %h for username and home path supported.
571
572    fingerprint_hash_type
573        The public key fingerprint hash type that the public key fingerprint
574        was originally hashed with. This defaults to ``sha256`` if not specified.
575    """
576    ret = {"name": "", "changes": {}, "result": True, "comment": ""}
577
578    all_potential_keys = []
579    for ssh_key in ssh_keys:
580        # gather list potential ssh keys for removal comparison
581        # options, enc, and comments could be in the mix
582        all_potential_keys.extend(ssh_key.split(" "))
583    existing_keys = __salt__["ssh.auth_keys"](user=user).keys()
584    remove_keys = set(existing_keys).difference(all_potential_keys)
585    for remove_key in remove_keys:
586        if __opts__["test"]:
587            remove_comment = "{} Key set for removal".format(remove_key)
588            ret["comment"] = remove_comment
589            ret["result"] = None
590        else:
591            remove_comment = absent(remove_key, user)["comment"]
592            ret["changes"][remove_key] = remove_comment
593
594    for ssh_key in ssh_keys:
595        run_return = present(
596            ssh_key,
597            user,
598            enc,
599            comment,
600            source,
601            options,
602            config,
603            fingerprint_hash_type,
604            **kwargs
605        )
606        if run_return["changes"]:
607            ret["changes"].update(run_return["changes"])
608        else:
609            ret["comment"] += "\n" + run_return["comment"]
610            ret["comment"] = ret["comment"].strip()
611
612        if run_return["result"] is None:
613            ret["result"] = None
614        elif not run_return["result"]:
615            ret["result"] = False
616
617    return ret
618