Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

boot: automatically extract fingerprint from SSH public keys #135

Merged
merged 3 commits into from
Jan 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelogs/fragments/134-boot-fingerprints.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
minor_changes:
- "boot - it is now possible to specify SSH public keys in ``authorized_keys``.
The fingerprint needed by the Robot API will be extracted automatically
(https://github.com/ansible-collections/community.hrobot/pull/134)."
66 changes: 66 additions & 0 deletions plugins/module_utils/ssh.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-

# Copyright (c), Felix Fontein <[email protected]>, 2019
# Simplified BSD License (see LICENSES/BSD-2-Clause.txt or https://opensource.org/licenses/BSD-2-Clause)
# SPDX-License-Identifier: BSD-2-Clause

from __future__ import absolute_import, division, print_function
__metaclass__ = type

from ansible.module_utils.basic import AVAILABLE_HASH_ALGORITHMS as _AVAILABLE_HASH_ALGORITHMS

import base64
import binascii
import re


_SPACE_RE = re.compile(' +')
_FINGERPRINT_PART = re.compile('^[0-9a-f]{2}$')


class FingerprintError(Exception):
pass


def remove_comment(public_key):
return ' '.join(_SPACE_RE.split(public_key.strip())[:2])

Check warning on line 26 in plugins/module_utils/ssh.py

View check run for this annotation

Codecov / codecov/patch

plugins/module_utils/ssh.py#L26

Added line #L26 was not covered by tests


def normalize_fingerprint(fingerprint, size=16):
if ':' in fingerprint:
fingerprint = fingerprint.split(':')

Check warning on line 31 in plugins/module_utils/ssh.py

View check run for this annotation

Codecov / codecov/patch

plugins/module_utils/ssh.py#L31

Added line #L31 was not covered by tests
else:
fingerprint = [fingerprint[i:i + 2] for i in range(0, len(fingerprint), 2)]
if len(fingerprint) != size:
raise FingerprintError(

Check warning on line 35 in plugins/module_utils/ssh.py

View check run for this annotation

Codecov / codecov/patch

plugins/module_utils/ssh.py#L35

Added line #L35 was not covered by tests
'Fingerprint must consist of {0} 8-bit hex numbers: got {1} 8-bit hex numbers instead'.format(size, len(fingerprint)))
for i, part in enumerate(fingerprint):
new_part = part.lower()

Check warning on line 38 in plugins/module_utils/ssh.py

View check run for this annotation

Codecov / codecov/patch

plugins/module_utils/ssh.py#L38

Added line #L38 was not covered by tests
if len(new_part) < 2:
new_part = '0{0}'.format(new_part)

Check warning on line 40 in plugins/module_utils/ssh.py

View check run for this annotation

Codecov / codecov/patch

plugins/module_utils/ssh.py#L40

Added line #L40 was not covered by tests
if not _FINGERPRINT_PART.match(new_part):
raise FingerprintError(

Check warning on line 42 in plugins/module_utils/ssh.py

View check run for this annotation

Codecov / codecov/patch

plugins/module_utils/ssh.py#L42

Added line #L42 was not covered by tests
'Fingerprint must consist of {0} 8-bit hex numbers: number {1} is invalid: "{2}"'.format(size, i + 1, part))
fingerprint[i] = new_part
return ':'.join(fingerprint)

Check warning on line 45 in plugins/module_utils/ssh.py

View check run for this annotation

Codecov / codecov/patch

plugins/module_utils/ssh.py#L44-L45

Added lines #L44 - L45 were not covered by tests


def extract_fingerprint(public_key, alg='md5', size=16):
try:
public_key = _SPACE_RE.split(public_key.strip())[1]
except IndexError:
raise FingerprintError(

Check warning on line 52 in plugins/module_utils/ssh.py

View check run for this annotation

Codecov / codecov/patch

plugins/module_utils/ssh.py#L49-L52

Added lines #L49 - L52 were not covered by tests
'Error while extracting fingerprint from public key data: cannot split public key into at least two parts')
try:
public_key = base64.b64decode(public_key)
except (binascii.Error, TypeError) as exc:
raise FingerprintError(

Check warning on line 57 in plugins/module_utils/ssh.py

View check run for this annotation

Codecov / codecov/patch

plugins/module_utils/ssh.py#L54-L57

Added lines #L54 - L57 were not covered by tests
'Error while extracting fingerprint from public key data: {0}'.format(exc))
try:
algorithm = _AVAILABLE_HASH_ALGORITHMS[alg]
except KeyError:
raise FingerprintError(

Check warning on line 62 in plugins/module_utils/ssh.py

View check run for this annotation

Codecov / codecov/patch

plugins/module_utils/ssh.py#L59-L62

Added lines #L59 - L62 were not covered by tests
'Hash algorithm {0} is not available. Possibly running in FIPS mode.'.format(alg.upper()))
digest = algorithm()
digest.update(public_key)
return normalize_fingerprint(digest.hexdigest(), size=size)

Check warning on line 66 in plugins/module_utils/ssh.py

View check run for this annotation

Codecov / codecov/patch

plugins/module_utils/ssh.py#L64-L66

Added lines #L64 - L66 were not covered by tests
32 changes: 29 additions & 3 deletions plugins/modules/boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@
- 64
authorized_keys:
description:
- One or more SSH key fingerprints to equip the rescue system with.
- One or more SSH key fingerprints to equip the rescue system with. You can also specify the public key itself,
the module will compute its fingerprint and pass it on to the Robot API.
- Only fingerprints for SSH keys deposited in the Robot API can be used.
- You can use the M(community.hrobot.ssh_key_info) module to query the SSH keys you can use, and the M(community.hrobot.ssh_key)
module to add or update SSH keys.
Expand Down Expand Up @@ -113,7 +114,8 @@
required: true
authorized_keys:
description:
- One or more SSH key fingerprints to equip the rescue system with.
- One or more SSH key fingerprints to equip the rescue system with. You can also specify the public key itself,
the module will compute its fingerprint and pass it on to the Robot API.
- Only fingerprints for SSH keys deposited in the Robot API can be used.
- You can use the M(community.hrobot.ssh_key_info) module to query the SSH keys you can use, and the M(community.hrobot.ssh_key)
module to add or update SSH keys.
Expand Down Expand Up @@ -281,6 +283,11 @@
fetch_url_json,
)

from ansible_collections.community.hrobot.plugins.module_utils.ssh import (
FingerprintError,
extract_fingerprint,
)


BOOT_CONFIGURATION_DATA = [
('rescue', 'rescue', {
Expand Down Expand Up @@ -398,8 +405,27 @@
if option is None or option == []:
continue
data[data_key] = option
# Normalize options
option_key = 'authorized_keys'

Check warning on line 409 in plugins/modules/boot.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/boot.py#L409

Added line #L409 was not covered by tests
if module.params[option_name].get(option_key):
should = module.params[option_name][option_key]

Check warning on line 411 in plugins/modules/boot.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/boot.py#L411

Added line #L411 was not covered by tests
for index, key in enumerate(should):
if ' ' in key:
try:
should[index] = extract_fingerprint(key)
except FingerprintError as exc:
module.fail_json(

Check warning on line 417 in plugins/modules/boot.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/boot.py#L414-L417

Added lines #L414 - L417 were not covered by tests
msg="Error while extracting fingerprint of {option_name}.{option_key}[{idx}]'s value {key!r}: {exc}".format(
option_name=option_name,
option_key=option_key,
idx=index + 1,
key=key,
exc=exc,
),
)
module.params[option_name][option_key] = should

Check warning on line 426 in plugins/modules/boot.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/boot.py#L426

Added line #L426 was not covered by tests
# Idempotence check
if existing.get('active'):
# Idempotence check
needs_change = False
for option_key, (result_key, data_key) in options.items():
should = module.params[option_name][option_key]
Expand Down
61 changes: 8 additions & 53 deletions plugins/modules/ssh_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,7 @@
sample: cb:8b:ef:a7:fe:04:87:3f:e5:55:cd:12:e3:e8:9f:99
"""

import base64
import binascii
import re

from ansible.module_utils.basic import AnsibleModule, AVAILABLE_HASH_ALGORITHMS
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.common.text.converters import to_native
from ansible.module_utils.six.moves.urllib.parse import urlencode

Expand All @@ -107,53 +103,12 @@
fetch_url_json,
)


class FingerprintError(Exception):
pass


SPACE_RE = re.compile(' +')
FINGERPRINT_PART = re.compile('^[0-9a-f]{2}$')


def normalize_fingerprint(fingerprint, size=16):
if ':' in fingerprint:
fingerprint = fingerprint.split(':')
else:
fingerprint = [fingerprint[i:i + 2] for i in range(0, len(fingerprint), 2)]
if len(fingerprint) != size:
raise FingerprintError(
'Fingerprint must consist of {0} 8-bit hex numbers: got {1} 8-bit hex numbers instead'.format(size, len(fingerprint)))
for i, part in enumerate(fingerprint):
new_part = part.lower()
if len(new_part) < 2:
new_part = '0{0}'.format(new_part)
if not FINGERPRINT_PART.match(new_part):
raise FingerprintError(
'Fingerprint must consist of {0} 8-bit hex numbers: number {1} is invalid: "{2}"'.format(size, i + 1, part))
fingerprint[i] = new_part
return ':'.join(fingerprint)


def extract_fingerprint(public_key, alg='md5', size=16):
try:
public_key = SPACE_RE.split(public_key.strip())[1]
except IndexError:
raise FingerprintError(
'Error while extracting fingerprint from public key data: cannot split public key into at least two parts')
try:
public_key = base64.b64decode(public_key)
except (binascii.Error, TypeError) as exc:
raise FingerprintError(
'Error while extracting fingerprint from public key data: {0}'.format(exc))
try:
algorithm = AVAILABLE_HASH_ALGORITHMS[alg]
except KeyError:
raise FingerprintError(
'Hash algorithm {0} is not available. Possibly running in FIPS mode.'.format(alg.upper()))
digest = algorithm()
digest.update(public_key)
return normalize_fingerprint(digest.hexdigest(), size=size)
from ansible_collections.community.hrobot.plugins.module_utils.ssh import (
FingerprintError,
normalize_fingerprint,
extract_fingerprint,
remove_comment,
)


def main():
Expand Down Expand Up @@ -225,7 +180,7 @@
}
if not exists:
# Create key
data['data'] = ' '.join(SPACE_RE.split(public_key.strip())[:2])
data['data'] = remove_comment(public_key)

Check warning on line 183 in plugins/modules/ssh_key.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/ssh_key.py#L183

Added line #L183 was not covered by tests
url = "{0}/key".format(BASE_URL)
# Update or create key
headers = {"Content-type": "application/x-www-form-urlencoded"}
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/plugins/data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Copyright (c) 2019 Felix Fontein <[email protected]>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

from __future__ import (absolute_import, division, print_function)
__metaclass__ = type

# Key generated with `ssh-keygen -t rsa -b 4096 -f test`, fingerprint with `ssh-keygen -lf test.pub -E md5``
SSH_PUBLIC_KEY_1 = (
'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC7g+C+gXspRfsNRFXHSeEuQLrUEb+pSV9OUi3zz0DvdxzaXyP4I1vUErnwll5P5'
'8KFdkWp65haqiGteM53zuGJa251c+J41Y69jLEI0jX4mGj4BskB0Cud23lnVzYTktzjkwGz2tGlRjaSYzYdm9lR3Nf6rlWBP1iz6C'
'QasBHVLGWUBuJF+DQ16ztHV9EWtifDprVoMHK5EaGW19W5OCW73sPJfvbdDjolTZC6QZ7lKOGcZjdFBM7nnIyfIHYfjnXPZh9eMnY'
'6KWEAKuhQpPO1SB82PrLvBPlYzNewO1BiOQWoJyJfJBr1vRBfhLzY9VAoNr5fDSUxtn3UmZ2OmcNCx+qb8iUrn+E3K3i4sRn5iYVA'
'dO4pmsjx5SENXlfpj/Mmz6wu3bQGN5k1jYtq+sKxGuIRiX+9sxEQ1KBXIqMfM1zSzitxGQSGUrqEgWpxJKVmDscGnlZBGGTPvPRwX'
'i3VLeiTH+AkGOnWrlVenKpBh/0IWPI8fN/d7GolWHT53Cyi0HQbb3nKMUlfXWFKukbdSb9mvJ0v1Pv8qlWb6+fDZCBi0hz/fmE+hx'
'/+uwnY9Vk8H5CzTDQOmXKx6Gj3Lff9RSWD/WePW8LyukWz0l18GOGWzv/HqNIVtljdfJMa5v2kckhZAFPxQvZBMUIX0wkRTmGJOcQ'
'+A8ZKOVaScMnXXQ=='
)

SSH_FINGERPRINT_1 = 'e4:47:42:71:81:62:bf:06:1c:23:fa:f3:8f:7b:6f:d0'

SSH_TYPE_1 = 'RSA'

SSH_SIZE_1 = 4096
97 changes: 97 additions & 0 deletions tests/unit/plugins/module_utils/test_ssh.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Copyright (c) 2021 Felix Fontein <[email protected]>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

from __future__ import (absolute_import, division, print_function)
__metaclass__ = type


import pytest

from ansible_collections.community.hrobot.plugins.module_utils.ssh import (
FingerprintError,
normalize_fingerprint,
extract_fingerprint,
)

from ..data import (
SSH_PUBLIC_KEY_1,
SSH_FINGERPRINT_1,
)


def test_normalize_fingerprint():
assert normalize_fingerprint(SSH_FINGERPRINT_1) == SSH_FINGERPRINT_1
assert normalize_fingerprint('F5:7e:4f:d8:ab:20:b8:5B:8b:2f:7a:4:47:fd:96:73') == (
'f5:7e:4f:d8:ab:20:b8:5b:8b:2f:7a:04:47:fd:96:73'
)
assert normalize_fingerprint('F57e4fd8ab20b85B8b2f7a0447fd9673') == (
'f5:7e:4f:d8:ab:20:b8:5b:8b:2f:7a:04:47:fd:96:73'
)
assert normalize_fingerprint('Fe:F', size=2) == 'fe:0f'

with pytest.raises(FingerprintError) as exc:
normalize_fingerprint('')
print(exc.value.args[0])
assert exc.value.args[0] == 'Fingerprint must consist of 16 8-bit hex numbers: got 0 8-bit hex numbers instead'
with pytest.raises(FingerprintError) as exc:
normalize_fingerprint('1:2:3')
print(exc.value.args[0])
assert exc.value.args[0] == 'Fingerprint must consist of 16 8-bit hex numbers: got 3 8-bit hex numbers instead'
with pytest.raises(FingerprintError) as exc:
normalize_fingerprint('01023')
print(exc.value.args[0])
assert exc.value.args[0] == 'Fingerprint must consist of 16 8-bit hex numbers: got 3 8-bit hex numbers instead'

with pytest.raises(FingerprintError) as exc:
normalize_fingerprint('A:B:C:D:E:F:G:H:I:J:K:L:M:N:O:P')
print(exc.value.args[0])
assert exc.value.args[0] == 'Fingerprint must consist of 16 8-bit hex numbers: number 7 is invalid: "G"'
with pytest.raises(FingerprintError) as exc:
normalize_fingerprint('fee:B:C:D:E:F:G:H:I:J:K:L:M:N:O:P')
print(exc.value.args[0])
assert exc.value.args[0] == 'Fingerprint must consist of 16 8-bit hex numbers: number 1 is invalid: "fee"'


def test_extract_fingerprint():
assert extract_fingerprint(SSH_PUBLIC_KEY_1) == SSH_FINGERPRINT_1
assert extract_fingerprint(' %s foo@ bar ' % SSH_PUBLIC_KEY_1.replace(' ', ' ')) == SSH_FINGERPRINT_1

key = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGGdztn98LzAZkwHzSNa2HpTERPzBZdrdMt9u++0qQ+U'
assert extract_fingerprint(key) == 'f5:7e:4f:d8:ab:20:b8:5b:8b:2f:7a:04:47:fd:96:73'
print(extract_fingerprint(key, alg='sha256', size=32))
assert extract_fingerprint(key, alg='sha256', size=32) == (
'64:94:70:47:7a:bd:79:99:95:9f:3b:d3:37:8c:2c:fa:33:a7:d1:93:95:56:1b:f7:f6:52:31:34:0b:4a:fc:67'
)

key = (
'ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBDEVarUR'
'tu+DmCvn0OkHC+gCOQ6Bxkolfh9NvWr4f8SPfQJ/yOUO6RZ+m3RhvnDEWAvA1BG/lCNqui6/kuZiyVk='
)
assert extract_fingerprint(key) == 'f4:b7:43:14:fe:8b:43:4b:cc:b3:63:dc:cf:23:bb:cb'
print(extract_fingerprint(key, alg='sha256', size=32))
assert extract_fingerprint(key, alg='sha256', size=32) == (
'88:c2:a3:0f:2a:cf:60:73:7c:52:e0:41:40:25:c3:d4:5d:32:37:a9:46:48:3e:37:34:f1:aa:0d:4d:69:15:d7'
)

with pytest.raises(FingerprintError) as exc:
extract_fingerprint(' adsf ')
print(exc.value.args[0])
assert exc.value.args[0] == 'Error while extracting fingerprint from public key data: cannot split public key into at least two parts'

with pytest.raises(FingerprintError) as exc:
extract_fingerprint('a b')
print(exc.value.args[0])
assert exc.value.args[0] in (
'Error while extracting fingerprint from public key data: Invalid base64-encoded string:'
' number of data characters (1) cannot be 1 more than a multiple of 4',
'Error while extracting fingerprint from public key data: Incorrect padding',
)
with pytest.raises(FingerprintError) as exc:
extract_fingerprint('a ab=f')
print(exc.value.args[0])
assert exc.value.args[0] == 'Error while extracting fingerprint from public key data: Incorrect padding'
with pytest.raises(FingerprintError) as exc:
extract_fingerprint('a ab==', alg='foo bar')
print(exc.value.args[0])
assert exc.value.args[0] == 'Hash algorithm FOO BAR is not available. Possibly running in FIPS mode.'
33 changes: 31 additions & 2 deletions tests/unit/plugins/modules/test_boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
from ansible_collections.community.hrobot.plugins.module_utils.robot import BASE_URL
from ansible_collections.community.hrobot.plugins.modules import boot

from ..data import (
SSH_PUBLIC_KEY_1,
SSH_FINGERPRINT_1,
)


def _amend_server_data(data):
data.update({
Expand Down Expand Up @@ -160,7 +165,7 @@ def test_rescue_idempotent_2(self, mocker):
'os': 'linux',
'arch': 32,
'authorized_keys': [
'e4:47:42:71:81:62:bf:06:1c:23:fa:f3:8f:7b:6f:d0',
SSH_PUBLIC_KEY_1,
'aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99',
'0f:1e:2d:3c:4b:5a:69:78:87:96:a5:b4:c3:d2:e1:f0',
],
Expand All @@ -171,7 +176,7 @@ def test_rescue_idempotent_2(self, mocker):
'rescue': create_rescue_active(os='linux', arch=32, authorized_key=[
{
'key': {
'fingerprint': 'e4:47:42:71:81:62:bf:06:1c:23:fa:f3:8f:7b:6f:d0',
'fingerprint': SSH_FINGERPRINT_1,
'name': 'baz',
'size': 4096,
'type': 'RSA',
Expand Down Expand Up @@ -541,3 +546,27 @@ def test_invalid_input(self, mocker):
.expect_url('{0}/boot/23'.format(BASE_URL)),
])
assert result['msg'] == 'There is no boot configuration available for this server'

def test_invalid_fingerprint(self, mocker):
result = self.run_module_failed(mocker, boot, {
'hetzner_user': '',
'hetzner_password': '',
'server_number': 23,
'rescue': {
'os': 'linux',
'arch': 32,
'authorized_keys': [
'asdf a-b',
],
},
}, [
FetchUrlCall('GET', 200)
.result_json(_amend_boot({
'rescue': create_rescue_active(os='linux', arch=32, authorized_key=[]),
}))
.expect_url('{0}/boot/23'.format(BASE_URL)),
])
assert result['msg'] == (
"Error while extracting fingerprint of rescue.authorized_keys[1]'s value 'asdf a-b':"
" Error while extracting fingerprint from public key data: Incorrect padding"
)
Loading
Loading