From 51cf852c3a019fa21955b9c92d530649140b3747 Mon Sep 17 00:00:00 2001 From: Jarno Elonen Date: Sun, 28 Jul 2024 21:55:23 +0300 Subject: [PATCH] Add SSH host cert support --- .github/workflows/python-tests.yml | 8 +- hsm_secrets/main.py | 2 +- hsm_secrets/ssh/__init__.py | 112 ++++++++++++++---- hsm_secrets/ssh/openssh/ssh_certificate.py | 27 +++-- .../ssh/openssh/ssh_certificate_test.py | 44 +++++-- run-tests.sh | 62 +++++++--- 6 files changed, 189 insertions(+), 66 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 638f254..f9dc35f 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -9,20 +9,20 @@ on: jobs: test: runs-on: ubuntu-latest - + steps: - uses: actions/checkout@v2 - + - name: Set up Python uses: actions/setup-python@v2 with: python-version: '3.12' - + - name: Install system dependencies run: | sudo apt-get update sudo apt-get install -y make openssh-client openssl libpcsclite-dev - + - name: Run tests run: | make test diff --git a/hsm_secrets/main.py b/hsm_secrets/main.py index e81673a..d6e1efe 100644 --- a/hsm_secrets/main.py +++ b/hsm_secrets/main.py @@ -65,7 +65,7 @@ def cli(ctx: click.Context, config: str|None, quiet: bool, yklabel: str|None, hs # Get first Yubikey HSM auth key label from device if not specified yubikey_label = yklabel - if not yubikey_label: + if not yubikey_label and not (auth_default_admin or auth_password_id or mock): creds = list_yubikey_hsm_creds() if not creds: if not (quiet or auth_default_admin or auth_password_id): diff --git a/hsm_secrets/ssh/__init__.py b/hsm_secrets/ssh/__init__.py index 9431905..4efa8f4 100644 --- a/hsm_secrets/ssh/__init__.py +++ b/hsm_secrets/ssh/__init__.py @@ -7,6 +7,7 @@ from hsm_secrets.config import HSMAsymmetricKey, HSMConfig from hsm_secrets.utils import HsmSecretsCtx, cli_code_info, cli_result, cli_warn, open_hsm_session, pass_common_args from cryptography.hazmat.primitives import _serialization +from cryptography.hazmat.primitives.serialization import ssh import yubihsm.defs # type: ignore [import] from yubihsm.objects import AsymmetricKey # type: ignore [import] @@ -44,17 +45,18 @@ def get_ca(ctx: HsmSecretsCtx, get_all: bool, cert_ids: Sequence[str]): cli_result(f"{pubkey} {key.label}") -@cmd_ssh.command('sign') + +@cmd_ssh.command('sign-user') @click.option('--out', '-o', type=click.Path(exists=False, dir_okay=False, resolve_path=True, allow_dash=True), help="Output file (default: deduce from input)", default=None) @click.option('--ca', '-c', required=False, help="CA key ID (hex) or label to sign with. Default: read from config", default=None) @click.option('--username', '-u', required=False, help="Key owner's name (for auditing)", default=None) @click.option('--certid', '-n', required=False, help="Explicit certificate ID (default: auto-generated)", default=None) @click.option('--validity', '-t', required=False, default=365*24*60*60, help="Validity period in seconds (default: 1 year)") @click.option('--principals', '-p', required=False, help="Comma-separated list of principals", default='') -@click.option('--extentions', '-e', help="Comma-separated list of SSH extensions", default='permit-X11-forwarding,permit-agent-forwarding,permit-port-forwarding,permit-pty,permit-user-rc') +@click.option('--extensions', '-e', help="Comma-separated list of SSH extensions", default='permit-X11-forwarding,permit-agent-forwarding,permit-port-forwarding,permit-pty,permit-user-rc') @click.argument('keyfile', type=click.Path(exists=False, dir_okay=False, resolve_path=True, allow_dash=True), default='-') @pass_common_args -def sign_ssh_key(ctx: HsmSecretsCtx, out: str, ca: str|None, username: str|None, certid: str|None, validity: int, principals: str, extentions: str, keyfile: str): +def sign_ssh_user_key(ctx: HsmSecretsCtx, out: str, ca: str|None, username: str|None, certid: str|None, validity: int, principals: str, extensions: str, keyfile: str): """Make and sign an SSH user certificate [keyfile]: file containing the public key to sign (default: stdin) @@ -62,12 +64,68 @@ def sign_ssh_key(ctx: HsmSecretsCtx, out: str, ca: str|None, username: str|None, If --ca is not specified, the default CA key is used (as specified in the config file). Either --username or explicit --certid must be specified. If --certid is not specified, - a certificate ID is auto-generated key owner name, current time and principal list. + a certificate ID is auto-generated using the key owner name, current time, and principal list. + Unique and clear certificate IDs are important for auditing and revocation. Output file is deduced from input file if not specified with --out (or '-' for stdout). For example, 'id_rsa.pub' will be signed to 'id_rsa-cert.pub'. """ + if (not username and not certid) or (username and certid): + raise click.ClickException("Either --username or --certid must be specified, but not both") + timestamp = int(time.time()) + certid = certid or (f"{username}-{timestamp}-{'+'.join(principals.split(','))}").strip().lower().replace(' ', '_') + _sign_ssh_key(ctx, out, ca, certid, validity, principals, extensions, keyfile, ssh.SSHCertificateType.USER, timestamp) + + +@cmd_ssh.command('sign-host') +@click.option('--out', '-o', type=click.Path(exists=False, dir_okay=False, resolve_path=True, allow_dash=True), help="Output file (default: deduce from input)", default=None) +@click.option('--ca', '-c', required=False, help="CA key ID (hex) or label to sign with. Default: read from config", default=None) +@click.option('--hostname', '-H', required=True, help="Primary hostname of the server") +@click.option('--validity', '-t', required=False, default=365*24*60*60, help="Validity period in seconds (default: 1 year)") +@click.option('--principals', '-p', required=False, help="Comma-separated list of additional hostnames, IP addresses, or wildcards this certificate is valid for", default=None) +@click.argument('keyfile', type=click.Path(exists=False, dir_okay=False, resolve_path=True, allow_dash=True), default='-') +@pass_common_args +def sign_ssh_host_key(ctx: HsmSecretsCtx, out: str, ca: str|None, hostname: str, validity: int, principals: str|None, keyfile: str): + """Make and sign an SSH host certificate + + [keyfile]: file containing the public key to sign (default: stdin) + + If --ca is not specified, the default CA key is used (as specified in the config file). + + The --hostname is used as the primary principal and is included in the certificate ID. + + --principals can be used to specify additional hostnames, IP addresses, or wildcards that this certificate is valid for. + This is useful for servers with multiple names, IP addresses, or for covering entire subdomains or services. + + Wildcards are supported in principals and can be used as prefixes or suffixes. For example: + - wiki.* would match any hostname starting with "wiki." + - *.example.com would match any subdomain of example.com + - 192.168.1.* would match any IP in the 192.168.1.0/24 subnet + + Output file is deduced from input file if not specified with --out (or '-' for stdout). + For example, 'ssh_host_rsa_key.pub' will be signed to 'ssh_host_rsa_key-cert.pub'. + + Example usage: + sign-host --hostname wiki.example.com --principals "wiki.*,*.example.com,10.0.0.*" ssh_host_rsa_key.pub + """ + principal_list = [hostname] + if principals: + principal_list.extend([p.strip() for p in principals.split(',') if p.strip() != hostname]) + principals_str = ','.join(principal_list) + + timestamp = int(time.time()) + certid = f"host-{hostname}-{timestamp}+{len(principal_list)-1}-principals".strip().lower().replace(' ', '_') + + cli_code_info(f"Creating host certificate for {hostname} with certid: {certid}") + cli_code_info(f"Principals: {principals_str}") + + timestamp = int(time.time()) + _sign_ssh_key(ctx, out, ca, certid, validity, principals_str, '', keyfile, ssh.SSHCertificateType.HOST, timestamp) + + + +def _sign_ssh_key(ctx: HsmSecretsCtx, out: str, ca: str|None, certid: str, validity: int, principals: str, extensions: str, keyfile: str, cert_type: ssh.SSHCertificateType, timestamp: int): from hsm_secrets.ssh.openssh.signing import sign_ssh_cert from hsm_secrets.ssh.openssh.ssh_certificate import cert_for_ssh_pub_id, str_to_extension from hsm_secrets.key_adapters import make_private_key_adapter @@ -79,11 +137,6 @@ def sign_ssh_key(ctx: HsmSecretsCtx, out: str, ca: str|None, username: str|None, if not ca_def: raise click.ClickException(f"CA key 0x{ca_key_def.id:04x} not found in config") - if not username and not certid: - raise click.ClickException("Either --username or --certid must be specified") - elif username and certid: - raise click.ClickException("Only one of --username or --certid must be specified") - # Read public key key_str = "" if keyfile == '-': @@ -100,21 +153,22 @@ def sign_ssh_key(ctx: HsmSecretsCtx, out: str, ca: str|None, username: str|None, if len(parts) >= 3: key_comment = "__"+parts[2] - timestamp = int(time.time()) princ_list = [s.strip() for s in principals.split(',')] if principals else [] - certid = certid or (f"{username}-{timestamp}-{'+'.join(principals.split(','))}").strip().lower().replace(' ', '_') - cli_code_info(f"Signing key with CA `{ca_def[0].label}` as cert ID `{certid}` with principals: `{princ_list}`") + + cert_type_str = "user" if cert_type == ssh.SSHCertificateType.USER else "host" + cli_code_info(f"Signing {cert_type_str} key with CA `{ca_def[0].label}` as cert ID `{certid}` with principals: `{princ_list}`") # Create certificate from public key cert = cert_for_ssh_pub_id( key_str, certid, - valid_seconds = validity, - principals = princ_list, - serial = timestamp, - extensions = {str_to_extension(s.strip()): b'' for s in extentions.split(',')}) + cert_type=cert_type, + valid_seconds=validity, + principals=princ_list, + serial=timestamp, + extensions={str_to_extension(s.strip()): b'' for s in extensions.split(',')} if cert_type == ssh.SSHCertificateType.USER else {}) - # Figre out output file + # Figure out output file out_fp = None path = None if out == '-' or (keyfile == '-' and not out): @@ -138,10 +192,20 @@ def sign_ssh_key(ctx: HsmSecretsCtx, out: str, ca: str|None, username: str|None, out_fp.close() if str(path) != '-': ca_pub_key = ca_priv_key.public_key().public_bytes(encoding=_serialization.Encoding.OpenSSH, format=_serialization.PublicFormat.OpenSSH) - cli_code_info(dedent(f""" - Certificate written to: {path} - - Send it to the user and ask them to put it in `~/.ssh/` along with the private key - - To view it, run: `ssh-keygen -L -f {path}` - - To allow access (adapt principals as neede), add this to your server authorized_keys file(s): - `cert-authority,principals="{','.join(cert.valid_principals)}" {ca_pub_key.decode()} HSM_{ca_def[0].label}` - """).strip()) + if cert_type == ssh.SSHCertificateType.USER: + cli_code_info(dedent(f""" + User certificate written to: {path} + - Send it to the user and ask them to put it in `~/.ssh/` along with the private key + - To view it, run: `ssh-keygen -L -f {path}` + - To allow access (adapt principals as needed), add this to your server authorized_keys file(s): + `cert-authority,principals="{','.join(cert.valid_principals)}" {ca_pub_key.decode()} HSM_{ca_def[0].label}` + """).strip()) + else: + cli_code_info(dedent(f""" + Host certificate written to: {path} + - Install it on the host machine, typically in `/etc/ssh/` + - Update the SSH server config to use this certificate (e.g., `HostCertificate /etc/ssh/ssh_host_rsa_key-cert.pub`) + - To view it, run: `ssh-keygen -L -f {path}` + - To trust this CA for host certificates, add this to your client's `~/.ssh/known_hosts` file: + `@cert-authority * {ca_pub_key.decode()} HSM_{ca_def[0].label}` + """).strip()) diff --git a/hsm_secrets/ssh/openssh/ssh_certificate.py b/hsm_secrets/ssh/openssh/ssh_certificate.py index 62bb7d5..1f3141b 100644 --- a/hsm_secrets/ssh/openssh/ssh_certificate.py +++ b/hsm_secrets/ssh/openssh/ssh_certificate.py @@ -366,27 +366,27 @@ def str_to_extension(label: str) -> ExtensionLabelType: def cert_for_ssh_pub_id( encoded_public_key: str, # in the format " " - cert_id: str, # E.g. "user.name-1234567-principal1+principal2" - cert_type: ssh.SSHCertificateType = ssh.SSHCertificateType.USER, + cert_id: str, # E.g. "user.name-1234567-principal1+principal2" or "host.example.com" + cert_type: ssh.SSHCertificateType, nonce: Optional[bytes] = None, # Random nonce (32 bytes), if not provided, it is generated serial: Optional[int] = None, # Serial number of the certificate. If None, current timestamp is used. principals: List[str] = [], valid_seconds: int = 60*60*24*31, # 31 days critical_options: Dict[str, bytes] = {}, - extensions: Dict[ExtensionLabelType, bytes] = {'permit-X11-forwarding': b'', 'permit-agent-forwarding': b'', 'permit-port-forwarding': b'', 'permit-pty': b'', 'permit-user-rc': b''}, + extensions: Dict[ExtensionLabelType, bytes]|None = None, ) -> Union[RSACertificate, ECDSACertificate, ED25519Certificate, SKECDSACertificate, SKEd25519Certificate]: """ - Build an SSH certificate object fom an encoded public key / ID (in the format " "). + Build an SSH certificate object from an encoded public key / ID (in the format " "). :param encoded_public_key: Encoded public key data. :param cert_type: The certificate type (USER or HOST). - :param cert_id: The certificate ID (e.g. "user.name-1234567-principal1+principal2"). + :param cert_id: The certificate ID (e.g. "user.name-1234567-principal1+principal2" or "host.example.com"). :param nonce: Random nonce (32 bytes). Generated if not provided. :param serial: Serial number of the certificate. If None, current timestamp is used. :param principals: List of principals that the certificate is valid for. :param valid_seconds: Number of seconds the certificate is valid for (starting from now -1 min). :param critical_options: Dictionary of critical options. - :param extensions: Dictionary of extensions. + :param extensions: Dictionary of extensions. Use None for default extensions. :return: The SSH certificate object. """ parts = encoded_public_key.strip().split(' ') @@ -416,7 +416,20 @@ def cert_for_ssh_pub_id( cert.valid_after = int(datetime.datetime.now().timestamp() - 60) # 1 minute ago, to allow for clock skew cert.valid_before = cert.valid_after + valid_seconds cert.critical_options = critical_options - cert.extensions = {str(k): v for k, v in extensions.items()} + + # Set default extensions based on certificate type + if cert_type == ssh.SSHCertificateType.USER: + default_extensions = { + 'permit-X11-forwarding': b'', + 'permit-agent-forwarding': b'', + 'permit-port-forwarding': b'', + 'permit-pty': b'', + 'permit-user-rc': b'' + } + else: # HOST certificate + default_extensions = {} + + cert.extensions = {k: v for k, v in (extensions.items() if extensions is not None else default_extensions.items())} cert.decode_public_key(key_data) assert isinstance(cert, (RSACertificate, ECDSACertificate, ED25519Certificate, SKECDSACertificate, SKEd25519Certificate)) diff --git a/hsm_secrets/ssh/openssh/ssh_certificate_test.py b/hsm_secrets/ssh/openssh/ssh_certificate_test.py index 5dd9370..6c9edd3 100644 --- a/hsm_secrets/ssh/openssh/ssh_certificate_test.py +++ b/hsm_secrets/ssh/openssh/ssh_certificate_test.py @@ -79,17 +79,27 @@ def parsecert(args: argparse.Namespace) -> None: def parsepub(args: argparse.Namespace) -> None: file_contents = read_file_str(args.pub_file) - cert = cert_for_ssh_pub_id( + # Create a user certificate + user_cert = cert_for_ssh_pub_id( file_contents, - cert_id = args.pub_file, + cert_id = "user@example.com", cert_type = ssh.SSHCertificateType.USER, principals=["basic_users", "admins"]) - print("Parsed public key into a certificate:") - print_certificate_details(cert) + print("Parsed public key into a user certificate:") + print_certificate_details(user_cert) + + # Create a host certificate + host_cert = cert_for_ssh_pub_id( + file_contents, + cert_id = "host.example.com", + cert_type = ssh.SSHCertificateType.HOST, + principals=["host.example.com", "*.example.com"]) + + print("\nParsed public key into a host certificate:") + print_certificate_details(host_cert) - print("") - print("Testing signing & verification with different issuers:") + print("\nTesting signing & verification with different issuers:") issuers = [ ed25519.Ed25519PrivateKey.generate(), @@ -98,13 +108,23 @@ def parsepub(args: argparse.Namespace) -> None: ] for ca in issuers: assert isinstance(ca, (rsa.RSAPrivateKey, ed25519.Ed25519PrivateKey, ec.EllipticCurvePrivateKey)) - sign_ssh_cert(cert, ca) - print(f" - Signed ok with {ca.__class__.__name__}") - #print_certificate_details(cert) - if verify_ssh_cert(cert): - print(f" - Verified OK") + + # Sign and verify user certificate + sign_ssh_cert(user_cert, ca) + print(f" - Signed user certificate ok with {ca.__class__.__name__}") + if verify_ssh_cert(user_cert): + print(f" - User certificate verified OK") + else: + print(f" - User certificate verification FAILED!") + + # Sign and verify host certificate + sign_ssh_cert(host_cert, ca) + print(f" - Signed host certificate ok with {ca.__class__.__name__}") + if verify_ssh_cert(host_cert): + print(f" - Host certificate verified OK") else: - print(f" - Verification FAILED!") + print(f" - Host certificate verification FAILED!") + def checksig(args: argparse.Namespace) -> None: cert_contents = read_file_str(args.cert_file) diff --git a/run-tests.sh b/run-tests.sh index 15fff4d..c232815 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -76,23 +76,6 @@ test_create_all() { [ "$count" -eq 35 ] || { echo "Expected 35 objects, but found $count"; return 1; } } -test_ssh_certificates() { - setup - run_cmd ssh get-ca --all | ssh-keygen -l -f /dev/stdin - assert_success - - ssh-keygen -t ed25519 -f $TEMPDIR/testkey -N '' -C 'testkey' - run_cmd ssh sign -u test.user -p users,admins $TEMPDIR/testkey.pub - assert_success - - local output=$(ssh-keygen -L -f $TEMPDIR/testkey-cert.pub) - assert_success - assert_grep "Public key: ED25519" "$output" - assert_grep "^[[:space:]]*users$" "$output" - assert_grep "^[[:space:]]*admins$" "$output" - assert_grep 'Key ID: "test.user-[0-9]*-users+admins"' "$output" -} - test_tls_certificates() { setup run_cmd -q x509 get --all | openssl x509 -text -noout @@ -143,6 +126,48 @@ test_wrapped_backup() { run_cmd -q hsm compare | grep -q '[x].*ca-root-key-rsa' || { echo "ERROR: Key not restored"; return 1; } } +test_ssh_user_certificates() { + setup + run_cmd ssh get-ca --all | ssh-keygen -l -f /dev/stdin + assert_success + + ssh-keygen -t ed25519 -f $TEMPDIR/testkey -N '' -C 'testkey' + run_cmd ssh sign-user -u test.user -p users,admins $TEMPDIR/testkey.pub + assert_success + + local output=$(ssh-keygen -L -f $TEMPDIR/testkey-cert.pub) + assert_success + assert_grep "Public key: ED25519" "$output" + assert_grep "^[[:space:]]*users$" "$output" + assert_grep "^[[:space:]]*admins$" "$output" + assert_grep 'Key ID: "test.user-[0-9]*-users+admins"' "$output" +} + +test_ssh_host_certificates() { + setup + + # Generate a test host key + ssh-keygen -t ed25519 -f $TEMPDIR/test_host_key -N '' -C 'test_host' + + # Sign the host key with wildcard principals + run_cmd ssh sign-host --hostname wiki.example.com --principals "wiki.*,10.0.0.*" $TEMPDIR/test_host_key.pub + assert_success + + local output=$(ssh-keygen -L -f $TEMPDIR/test_host_key-cert.pub) + echo "Cert contents:" + echo "$output" + + assert_success + assert_grep "Public key: ED25519" "$output" + assert_grep "Type: ssh-ed25519-cert-v01@openssh.com host certificate" "$output" + assert_grep "^[[:space:]]*wiki.example.com$" "$output" + assert_grep "^[[:space:]]*wiki.*$" "$output" + assert_grep "^[[:space:]]*10.0.0.*$" "$output" + assert_grep 'Key ID.*host-wiki.example.com-.*' "$output" + +} + + # ------------------------------------------------------ function run_test_quiet() { @@ -173,9 +198,10 @@ run_test() { echo "Running tests:" run_test test_fresh_device run_test test_create_all -run_test test_ssh_certificates run_test test_tls_certificates run_test test_password_derivation run_test test_wrapped_backup +run_test test_ssh_user_certificates +run_test test_ssh_host_certificates echo "All tests passed successfully!"