├── README.md ├── auth_key_cmd.sh ├── ca ├── __init__.py ├── admin.py ├── apps.py ├── attestation-ca-certs │ ├── opgp-attestation-ca.pem │ └── piv-attestation-ca.pem ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── create_ca.py │ │ ├── export_cert.py │ │ ├── import_pubkey.py │ │ ├── krl.py │ │ ├── latest_cert_for_pubkey.py │ │ ├── renew_cert.py │ │ ├── sign_cert.py │ │ ├── trusted_ca_list.py │ │ └── validate_everything.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_certificate_renewal_of.py │ ├── 0003_auto_20211026_1809.py │ ├── 0004_attestation_user.py │ ├── 0005_auto_20220905_1301.py │ ├── 0006_auto_20220905_1305.py │ ├── 0007_sftpdeployment.py │ └── __init__.py ├── models.py ├── templates │ ├── certificates.html │ └── certmail.txt ├── tests.py ├── urls.py └── views.py ├── manage.py ├── requirements.txt └── zsca ├── __init__.py ├── settings.py ├── urls.py └── wsgi.py /README.md: -------------------------------------------------------------------------------- 1 | Zero Trust SSH CA 2 | ================= 3 | 4 | Experimental state, expect bugs, here's a quick demo setup, which already 5 | presumes you have an Ed25519 keypair in the OpenPGP applet of the YubiKey 6 | (see a detailed HOWTO at the end of this README), 7 | `ykman` is installed in `$PATH`, and you are in a Python 3.5+ environment. 8 | 9 | $ git clone https://github.com/silentsignal/zsca 10 | ... 11 | $ cd zsca 12 | $ pip install -r requirements.txt 13 | ... 14 | $ python manage.py migrate 15 | ... 16 | $ ykman openpgp attest sig sig-attest.pem 17 | ... 18 | Enter PIN: <...> 19 | 20 | (it might also ask whether you want to overwrite certificate in that slot, but that's okay, just answer `y`) 21 | 22 | $ ykman openpgp export-certificate att att-attest.pem 23 | ... 24 | $ python manage.py createsuperuser 25 | Username (leave blank to use 'dnet'): 26 | Email address: vsza@silentsignal.hu 27 | Password: 28 | Password (again): 29 | Superuser created successfully. 30 | $ python manage.py import_pubkey --user-email vsza@silentsignal.hu --attested-by {att,sig}-attest.pem 31 | stored successfully, certificates for this key can be signed using the following command 32 | 33 | python manage.py sign_cert 1 34 | 35 | You can also create a CA based on this key by running 36 | 37 | python manage.py create_ca 1 38 | $ python manage.py create_ca 1 39 | $ ssh-keygen -t ed25519 -f test 40 | Generating public/private ed25519 key pair. 41 | Enter passphrase (empty for no passphrase): 42 | Enter same passphrase again: 43 | Your identification has been saved in test 44 | Your public key has been saved in test.pub 45 | The key fingerprint is: 46 | SHA256:JflncFN+lDhJywgAhdBpjWol5ejUxvBzI4VbUfOgEKM dnet@negyhatvan 47 | ... 48 | $ python manage.py import_pubkey test.pub 49 | stored successfully, certificates for this key can be signed using the following command 50 | 51 | python manage.py sign_cert 2 52 | $ python manage.py sign_cert 2 --identity teszt --principal teszt@silentsignal.hu -O force-command=uname -O clear 53 | Password: 54 | Signed user key /tmp/zsca-signcertbezofksj/subject-cert.pub: id "teszt" serial 48 for teszt@silentsignal.hu valid from 2021-11-07T21:31:00 to 2022-02-05T21:32:01 55 | ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2g... 56 | $ python manage.py trusted_ca_list 57 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPc25bfttB6URNpvMB2pvr2mo25ux8rWusU0MWH8begS 58 | 59 | Note: if signing results in the error message 60 | 61 | smartcard.Exceptions.CardConnectionException: Failed to transmit with protocol T1. Transaction failed.: Transaction failed. (0x80100016) 62 | ... 63 | OpenPGPpy.openpgp_card.ConnectionException: Error while communicating with the OpenGPG device. 64 | 65 | on macOS Sonoma, [Ludovic Rousseau's blog post][2] might help by switching from the buggy built-in CCID driver to his IFD CCID driver (also included in the OS). 66 | 67 | Now you can set the following in your `ssh_config` 68 | 69 | Host foo 70 | ... 71 | CertificateFile /path/to/output-of-sign_cert 72 | IdentityFile test 73 | 74 | If the output of `trusted_ca_list` gets added to `TrustedUserCAKeys` on `foo` 75 | issuing the command `ssh foo` will result in the command `uname` being run 76 | on that machine in the next 90 days. 77 | 78 | Generating Ed25519 keypairs with GnuPG 79 | -------------------------------------- 80 | 81 | Although ZSCA makes use of YubiKey-specific attestation mechanics, most of it 82 | (including the instructions below) should work with any OpenPGP hardware token 83 | that has Ed25519 capabilities. 84 | 85 | 1. First, run `gpg --edit-card`, this opens an interactive command prompt 86 | 2. Enter `admin` to enable administrative commands 87 | 3. Enter `key-attr` which allows you to change the key parameters 88 | 4. During the following three times two rounds, answer `ECC` and `25519` to 89 | set all three keys (signature key, encryption key, authentication key) to 90 | 25519 (Curve25519 for encryption, Ed25519 for the other two) 91 | 5. Finally, enter `generate` to actually generate keypairs using the 92 | algorithms set in the previous step. 93 | 94 | Fetching attestation certificates for YubiKey PIV keypairs 95 | ---------------------------------------------------------- 96 | 97 | As described in the example above, OpenPGP keypairs generated on YubiKeys can 98 | have an attestation certificate which is then used by ZSCA to verify it being 99 | eligible for high-privilege usage. 100 | 101 | YubiKeys have another similar applet called PIV (used by [yubikey-agent][1] 102 | et al) that also offers attestation, which ZSCA can consume just 103 | like with OpenPGP keys. In this case, the following commands can be used, 104 | assuming you already have `ykman` installed as described above for OpenPGP. 105 | 106 | $ ykman piv keys attest 9a sig-attest.pem 107 | ... 108 | $ ykman piv certificates export f9 att-attest.pem 109 | ... 110 | $ python manage.py import_pubkey --user-email vsza@silentsignal.hu --attested-by att-attest.pem sig-attest.pem 111 | stored successfully, certificates for this key can be signed using the following command 112 | ... 113 | 114 | 115 | [1]: https://github.com/FiloSottile/yubikey-agent 116 | [2]: https://blog.apdu.fr/posts/2023/11/apple-own-ccid-driver-in-sonoma/ 117 | -------------------------------------------------------------------------------- /auth_key_cmd.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Usage: put the two lines below into the SSHd config 4 | # 5 | # AuthorizedKeysCommand /path/to/this/file %k %u 6 | # AuthorizedKeysCommandUser 7 | # 8 | # Don't forget to chmod it so that all parents are both writable only 9 | # and owned by root, otherwise it won't be executed. 10 | 11 | if [ "special-username-comes-here" = "$2" ]; then 12 | cd /path/to/zsca 13 | . ../venv/bin/activate 14 | python manage.py latest_cert_for_pubkey $1 15 | fi 16 | -------------------------------------------------------------------------------- /ca/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/silentsignal/zsca/3aaec073ded6d5c6025a737d5eeca2b6861e3e28/ca/__init__.py -------------------------------------------------------------------------------- /ca/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /ca/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CaConfig(AppConfig): 5 | name = 'ca' 6 | -------------------------------------------------------------------------------- /ca/attestation-ca-certs/opgp-attestation-ca.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDOTCCAiGgAwIBAgIJAN0XtOvBoi4ZMA0GCSqGSIb3DQEBCwUAMCgxJjAkBgNV 3 | BAMMHVl1YmljbyBPcGVuUEdQIEF0dGVzdGF0aW9uIENBMB4XDTE5MDgwMTAwMDAw 4 | MFoXDTQ2MTIxNzAwMDAwMFowKDEmMCQGA1UEAwwdWXViaWNvIE9wZW5QR1AgQXR0 5 | ZXN0YXRpb24gQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQClkKck 6 | +NEH+iSVLjbOvvreMlvkK4DZ7aETLusDfkEDy5+cv8SHtKSVcYfKhkST1l/5kbyx 7 | WAnxLRr+aYP52830qkDfYY1OE/IQG76BdWaGZJuMU4cdUPQR21Y7JB+ELHNMQHav 8 | 3CmregKVqIRB6vgwWq/6AM37VKqKNTsBUmrAyihX/vY/kS3L1cP/NCPhUC9Gqab2 9 | zohxXansjz92+4/dbN1cKDSGI8kVmoLpLbCf/CqGE4lWen0HxMCo/zIZo0nlGS7G 10 | rEAqN+PRRwiemBZhwBzeYiCLkh7qaqO4O1eWCNLjkJeLwIZ/uyRTESbaFoXOxqFp 11 | FjIyEjMYIdRXfaHVAgMBAAGjZjBkMB0GA1UdDgQWBBT7/MlvyfSnaal2RJH3cc8m 12 | ZS4SSjAfBgNVHSMEGDAWgBT7/MlvyfSnaal2RJH3cc8mZS4SSjASBgNVHRMBAf8E 13 | CDAGAQH/AgEBMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAQEAK+TP 14 | HgYNIFTy+2PXpxmPVnNOcJRcVykAxaLJAAxey2BXy9xmU7lzHbl2x23Lw3kH7Crr 15 | RqG67WGcwSZzvWWEcbq4zmX3vnu3FOFlqKFhU164tod4cXz1JGsTgfXaPRvoKJAo 16 | XMotYH/u2UY/K8jmqycgEyHAFc9wx1v/q0H6p4WgbXLu2oBzRodHokgK/6EbIbR+ 17 | Jok3xJ+5haGcMCCz2A8RBah4dxPDNeaz3tSkAjrtwLANV79hAZv2g9CZX6z0H2Zy 18 | HhK6CLTg2MfwT0NxS3Am76k2opXSqbk8k5nnNFSYFuvgxunQxUOB+3M+gWHmVTh8 19 | 7yaamyNndwmhhIAgeA== 20 | -----END CERTIFICATE----- 21 | -------------------------------------------------------------------------------- /ca/attestation-ca-certs/piv-attestation-ca.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDFzCCAf+gAwIBAgIDBAZHMA0GCSqGSIb3DQEBCwUAMCsxKTAnBgNVBAMMIFl1 3 | YmljbyBQSVYgUm9vdCBDQSBTZXJpYWwgMjYzNzUxMCAXDTE2MDMxNDAwMDAwMFoY 4 | DzIwNTIwNDE3MDAwMDAwWjArMSkwJwYDVQQDDCBZdWJpY28gUElWIFJvb3QgQ0Eg 5 | U2VyaWFsIDI2Mzc1MTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMN2 6 | cMTNR6YCdcTFRxuPy31PabRn5m6pJ+nSE0HRWpoaM8fc8wHC+Tmb98jmNvhWNE2E 7 | ilU85uYKfEFP9d6Q2GmytqBnxZsAa3KqZiCCx2LwQ4iYEOb1llgotVr/whEpdVOq 8 | joU0P5e1j1y7OfwOvky/+AXIN/9Xp0VFlYRk2tQ9GcdYKDmqU+db9iKwpAzid4oH 9 | BVLIhmD3pvkWaRA2H3DA9t7H/HNq5v3OiO1jyLZeKqZoMbPObrxqDg+9fOdShzgf 10 | wCqgT3XVmTeiwvBSTctyi9mHQfYd2DwkaqxRnLbNVyK9zl+DzjSGp9IhVPiVtGet 11 | X02dxhQnGS7K6BO0Qe8CAwEAAaNCMEAwHQYDVR0OBBYEFMpfyvLEojGc6SJf8ez0 12 | 1d8Cv4O/MA8GA1UdEwQIMAYBAf8CAQEwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3 13 | DQEBCwUAA4IBAQBc7Ih8Bc1fkC+FyN1fhjWioBCMr3vjneh7MLbA6kSoyWF70N3s 14 | XhbXvT4eRh0hvxqvMZNjPU/VlRn6gLVtoEikDLrYFXN6Hh6Wmyy1GTnspnOvMvz2 15 | lLKuym9KYdYLDgnj3BeAvzIhVzzYSeU77/Cupofj093OuAswW0jYvXsGTyix6B3d 16 | bW5yWvyS9zNXaqGaUmP3U9/b6DlHdDogMLu3VLpBB9bm5bjaKWWJYgWltCVgUbFq 17 | Fqyi4+JE014cSgR57Jcu3dZiehB6UtAPgad9L5cNvua/IWRmm+ANy3O2LH++Pyl8 18 | SREzU8onbBsjMg9QDiSf5oJLKvd/Ren+zGY7 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /ca/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/silentsignal/zsca/3aaec073ded6d5c6025a737d5eeca2b6861e3e28/ca/management/__init__.py -------------------------------------------------------------------------------- /ca/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/silentsignal/zsca/3aaec073ded6d5c6025a737d5eeca2b6861e3e28/ca/management/commands/__init__.py -------------------------------------------------------------------------------- /ca/management/commands/create_ca.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from io import BytesIO 4 | 5 | from django.core.management.base import BaseCommand 6 | 7 | from ca.models import Attestation, CA, read_ssh_string 8 | 9 | SSH_ED25519 = b'ssh-ed25519' 10 | 11 | class Command(BaseCommand): 12 | help = 'Creates a CA' 13 | 14 | def add_arguments(self, parser): 15 | parser.add_argument('attestation_id', type=int) 16 | 17 | def handle(self, *args, **options): 18 | att = Attestation.objects.get(pk=options['attestation_id']) 19 | inner_type = read_ssh_string(BytesIO(att.pubkey.key)) 20 | if inner_type != SSH_ED25519: 21 | raise ValueError("Only Ed25519 CAs are supported by ZSCA") 22 | CA.objects.create(signer=att) 23 | -------------------------------------------------------------------------------- /ca/management/commands/export_cert.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from ca.models import Certificate 6 | 7 | class Command(BaseCommand): 8 | help = 'Prints a certificate' 9 | 10 | def add_arguments(self, parser): 11 | parser.add_argument('cert_id', type=int) 12 | 13 | def handle(self, cert_id, *args, **options): 14 | print(Certificate.objects.get(pk=cert_id).ssh_string()) 15 | -------------------------------------------------------------------------------- /ca/management/commands/import_pubkey.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from argparse import FileType 4 | from base64 import b64decode 5 | from io import BytesIO 6 | 7 | from django.contrib.auth.models import User 8 | from django.core.management.base import BaseCommand 9 | from django.db import transaction 10 | 11 | from cryptography import x509 12 | from cryptography.hazmat.backends import default_backend 13 | from cryptography.hazmat.primitives import serialization 14 | 15 | from ca.models import Attestation, YubiKey, PublicKey, read_ssh_string, read_yubikey_serial 16 | 17 | class Command(BaseCommand): 18 | help = 'Imports a public key' 19 | 20 | def add_arguments(self, parser): 21 | parser.add_argument('key_or_cert', type=FileType('rb')) 22 | parser.add_argument( 23 | '--attested-by', 24 | type=FileType('rb'), 25 | metavar='att.pem', 26 | help='Provide an attestation certificate chain', 27 | ) 28 | parser.add_argument( 29 | '--user-email', 30 | metavar='user@example.com', 31 | help='Assign user by email address', 32 | ) 33 | 34 | def handle(self, *args, **options): 35 | with transaction.atomic(): 36 | att_cert = options['attested_by'] 37 | leaf_cert = options['key_or_cert'] 38 | be = default_backend() 39 | certdata = leaf_cert.read() 40 | try: 41 | cert = x509.load_pem_x509_certificate(certdata, be) 42 | ssh_str = cert.public_key().public_bytes( 43 | format=serialization.PublicFormat.OpenSSH, 44 | encoding=serialization.Encoding.OpenSSH) 45 | except ValueError: 46 | cert = None 47 | ssh_str = certdata 48 | (ssh_type, ssh_b64) = ssh_str.split(b" ")[:2] 49 | ssh_raw = b64decode(ssh_b64) 50 | inner_type = read_ssh_string(BytesIO(ssh_raw)) 51 | assert ssh_type == inner_type, "{0!r} != {1!r}".format(ssh_type, inner_type) 52 | try: 53 | pk = PublicKey.objects.get(key=ssh_raw) 54 | except PublicKey.DoesNotExist: 55 | pk = PublicKey.objects.create(key=ssh_raw) 56 | if cert is None: 57 | if att_cert: 58 | raise ValueError("The --attested-by option is incompatible " 59 | "with SSH public key format, use the X.509 PEM file instead") 60 | else: 61 | print(repr(pk) + " stored successfully, certificates for " 62 | "this key can be signed using the following command\n\n" 63 | "python manage.py sign_cert " + str(pk.pk)) 64 | else: 65 | if not att_cert: 66 | raise ValueError("Providing an X.509 PEM file doesn't " 67 | "make sense without an attestation chain, " 68 | "did you forget --attested-by?") 69 | icert = x509.load_pem_x509_certificate(att_cert.read(), be) 70 | serial = read_yubikey_serial(cert) 71 | try: 72 | yk = YubiKey.objects.get(serial=serial) 73 | except YubiKey.DoesNotExist: 74 | yk = YubiKey.objects.create(serial=serial) 75 | email = options['user_email'] 76 | if email: 77 | user = User.objects.get(email=email) 78 | else: 79 | user = Attestation.objects.filter(yubikey=yk).order_by('pk').last() 80 | (icert_der, cert_der) = (c.public_bytes( 81 | encoding=serialization.Encoding.DER) for c in [icert, cert]) 82 | att = Attestation.objects.create(pubkey=pk, yubikey=yk, user=user, 83 | intermediate_cert=icert_der, leaf_cert=cert_der) 84 | att.validate() 85 | print(repr(pk) + " stored successfully, certificates for " 86 | "this key can be signed using the following command\n\n" 87 | "python manage.py sign_cert " + str(pk.pk) + "\n\n" 88 | "You can also create a CA based on this key by running\n\n" 89 | "python manage.py create_ca " + str(att.pk)) 90 | -------------------------------------------------------------------------------- /ca/management/commands/krl.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from argparse import FileType 4 | from pathlib import Path 5 | from shutil import rmtree 6 | from subprocess import Popen, check_call 7 | from tempfile import mkdtemp 8 | 9 | from django.core.management.base import BaseCommand 10 | 11 | from ca.models import CA, PublicKey 12 | 13 | class Command(BaseCommand): 14 | help = 'Creates a Key Revocation List (KRL)' 15 | 16 | def add_arguments(self, parser): 17 | parser.add_argument('outfile', type=FileType('wb')) 18 | 19 | def handle(self, outfile, *args, **options): 20 | outfile.write(generate_krl_contents()) 21 | 22 | def generate_krl_contents(): 23 | ca_krl = None 24 | for ca in CA.objects.filter(signer__pubkey__revoked=None): 25 | ca_krl = ca.get_krl(ca_krl) 26 | keys = [k.ssh_string() for k in PublicKey.objects.exclude(revoked=None)] 27 | if not keys: 28 | return ca_krl 29 | tmpdir = Path(mkdtemp(prefix='zsca-pub-krl')) 30 | try: 31 | krl = tmpdir / 'krl' 32 | cmdline = ['ssh-keygen', '-k', '-f', str(krl)] 33 | if ca_krl: 34 | krl.write_bytes(ca_krl) 35 | cmdline.append('-u') 36 | for n, k in enumerate(keys): 37 | entry_file = tmpdir / 'entry{0}'.format(n) 38 | entry_file.write_text(k) 39 | cmdline.append(str(entry_file)) 40 | check_call(cmdline) 41 | return krl.read_bytes() 42 | finally: 43 | rmtree(tmpdir) 44 | -------------------------------------------------------------------------------- /ca/management/commands/latest_cert_for_pubkey.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from base64 import b64decode 4 | from io import BytesIO 5 | 6 | from django.core.management.base import BaseCommand 7 | 8 | from ca.models import PublicKey 9 | 10 | class Command(BaseCommand): 11 | help = 'Fetches the latest certificate for a given Base64-encoded public key' 12 | 13 | def add_arguments(self, parser): 14 | parser.add_argument('pubkey') 15 | 16 | def handle(self, pubkey, *args, **options): 17 | pk = PublicKey.objects.get(key=b64decode(pubkey)) 18 | lc = pk.certificate_set.order_by('pk').last() 19 | if lc: 20 | print(f'command="echo {lc.ssh_string()}",restrict {pk.ssh_string()}') 21 | -------------------------------------------------------------------------------- /ca/management/commands/renew_cert.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from ca.models import Certificate, console_openpgp_init 6 | 7 | class Command(BaseCommand): 8 | help = 'Renews a certificate' 9 | 10 | def add_arguments(self, parser): 11 | parser.add_argument('cert_ids') 12 | 13 | def handle(self, cert_ids, *args, **options): 14 | device = console_openpgp_init() 15 | for cert_id in cert_ids.split(','): 16 | Certificate.objects.get(pk=cert_id).renew(device) 17 | -------------------------------------------------------------------------------- /ca/management/commands/sign_cert.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from ca.models import PublicKey 6 | 7 | class Command(BaseCommand): 8 | help = 'Signs a certificate' 9 | 10 | def add_arguments(self, parser): 11 | parser.add_argument('pubkey_id', type=int) 12 | parser.add_argument('--identity') 13 | parser.add_argument('--principal') 14 | parser.add_argument('-O', dest='ssh_keygen_options', 15 | metavar='ssh-keygen_option', action='append') 16 | 17 | def handle(self, pubkey_id, identity, principal, ssh_keygen_options, *args, **options): 18 | signed = PublicKey.objects.get(pk=pubkey_id, revoked=None).sign_with_ca( 19 | identity, principal, ssh_keygen_options) 20 | print(signed.ssh_string()) 21 | -------------------------------------------------------------------------------- /ca/management/commands/trusted_ca_list.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from ca.models import CA 6 | 7 | class Command(BaseCommand): 8 | help = 'Prints the trusted CA list' 9 | 10 | def handle(self, *args, **options): 11 | for ca in CA.objects.filter(signer__pubkey__revoked=None): 12 | print(ca.signer.pubkey.ssh_string()) 13 | -------------------------------------------------------------------------------- /ca/management/commands/validate_everything.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from ca.models import CA 6 | 7 | class Command(BaseCommand): 8 | help = 'Validates every certificate and attestation' 9 | 10 | def handle(self, *args, **options): 11 | for ca in CA.objects.all(): 12 | ca.validate() 13 | -------------------------------------------------------------------------------- /ca/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2 on 2021-03-23 20:57 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Attestation', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('intermediate_cert', models.BinaryField(verbose_name='Intermediate attestation certificate')), 22 | ('leaf_cert', models.BinaryField(verbose_name='Attestation statement / leaf certificate')), 23 | ], 24 | ), 25 | migrations.CreateModel( 26 | name='CA', 27 | fields=[ 28 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 29 | ('signer', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, to='ca.Attestation')), 30 | ], 31 | ), 32 | migrations.CreateModel( 33 | name='PublicKey', 34 | fields=[ 35 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 36 | ('key', models.BinaryField(verbose_name='Public key')), 37 | ], 38 | ), 39 | migrations.CreateModel( 40 | name='YubiKey', 41 | fields=[ 42 | ('serial', models.PositiveIntegerField(primary_key=True, serialize=False, verbose_name='Serial number')), 43 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), 44 | ], 45 | ), 46 | migrations.CreateModel( 47 | name='Certificate', 48 | fields=[ 49 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 50 | ('cert', models.BinaryField(verbose_name='Certificate')), 51 | ('issuer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='ca.CA')), 52 | ('subject', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='ca.PublicKey')), 53 | ], 54 | ), 55 | migrations.AddField( 56 | model_name='attestation', 57 | name='pubkey', 58 | field=models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, to='ca.PublicKey'), 59 | ), 60 | migrations.AddField( 61 | model_name='attestation', 62 | name='yubikey', 63 | field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='ca.YubiKey'), 64 | ), 65 | ] 66 | -------------------------------------------------------------------------------- /ca/migrations/0002_certificate_renewal_of.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2 on 2021-10-20 20:35 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('ca', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='certificate', 16 | name='renewal_of', 17 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='ca.Certificate'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /ca/migrations/0003_auto_20211026_1809.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2 on 2021-10-26 18:09 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('ca', '0002_certificate_renewal_of'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='certificate', 15 | name='revoked', 16 | field=models.CharField(max_length=100, null=True, verbose_name='Reason for revocation'), 17 | ), 18 | migrations.AddField( 19 | model_name='publickey', 20 | name='revoked', 21 | field=models.CharField(max_length=100, null=True, verbose_name='Reason for revocation'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /ca/migrations/0004_attestation_user.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2 on 2022-09-05 13:01 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('ca', '0003_auto_20211026_1809'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='attestation', 18 | name='user', 19 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /ca/migrations/0005_auto_20220905_1301.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2 on 2022-09-05 13:01 2 | 3 | from django.db import migrations 4 | 5 | def copy_yubikey_users(apps, schema_editor): 6 | YubiKey = apps.get_model('ca', 'YubiKey') 7 | Attestation = apps.get_model('ca', 'Attestation') 8 | for a in Attestation.objects.all(): 9 | a.user = a.yubikey.user 10 | a.save() 11 | 12 | class Migration(migrations.Migration): 13 | 14 | dependencies = [ 15 | ('ca', '0004_attestation_user'), 16 | ] 17 | 18 | operations = [ 19 | migrations.RunPython(copy_yubikey_users), 20 | ] 21 | -------------------------------------------------------------------------------- /ca/migrations/0006_auto_20220905_1305.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2 on 2022-09-05 13:05 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('ca', '0005_auto_20220905_1301'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name='yubikey', 17 | name='user', 18 | ), 19 | migrations.AlterField( 20 | model_name='attestation', 21 | name='user', 22 | field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /ca/migrations/0007_sftpdeployment.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2 on 2024-05-07 09:25 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('ca', '0006_auto_20220905_1305'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='SftpDeployment', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('hostname', models.CharField(max_length=255, verbose_name='Hostname')), 19 | ('path', models.CharField(max_length=255, verbose_name='Path')), 20 | ('certificate', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='ca.Certificate')), 21 | ], 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /ca/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/silentsignal/zsca/3aaec073ded6d5c6025a737d5eeca2b6861e3e28/ca/migrations/__init__.py -------------------------------------------------------------------------------- /ca/models.py: -------------------------------------------------------------------------------- 1 | from base64 import b64decode, b64encode 2 | from datetime import datetime 3 | from functools import partial 4 | from hashlib import sha256 5 | from io import BytesIO 6 | from itertools import islice 7 | from pathlib import Path 8 | from shutil import rmtree 9 | from subprocess import Popen, check_call 10 | from tempfile import mkdtemp 11 | from time import time 12 | import getpass, socket, struct 13 | 14 | from cryptography import x509 15 | from cryptography.hazmat.backends import default_backend 16 | from cryptography.hazmat.primitives import serialization 17 | from cryptography.hazmat.primitives.asymmetric import padding 18 | from cryptography.hazmat._der import DERReader, INTEGER 19 | import OpenPGPpy 20 | 21 | from django.conf import settings 22 | from django.db import models 23 | 24 | from django.contrib.auth.models import User 25 | 26 | # src: https://developers.yubico.com/PGP/Attestation.html 27 | PGP_KEY_SOURCE = x509.ObjectIdentifier("1.3.6.1.4.1.41482.5.2") 28 | PGP_SERIAL_NO = x509.ObjectIdentifier("1.3.6.1.4.1.41482.5.7") 29 | # src: https://developers.yubico.com/PIV/Introduction/PIV_attestation.html 30 | PIV_SERIAL_NO = x509.ObjectIdentifier("1.3.6.1.4.1.41482.3.7") 31 | ON_DEVICE = 0x01 32 | 33 | SSH_ED25519 = b'ssh-ed25519' 34 | SIGNING_KEY = "B600" 35 | SECURITY_SUPPORT_TEMPLATE = '007A' 36 | OPGP_ED25519_PREFIX = b"\x7f\x49\x22\x86\x20" 37 | OPGP_SIG_CTR_PREFIX = b"z\x05\x93\x03" 38 | 39 | SSH_AGENTC_REQUEST_IDENTITIES = 11 40 | SSH_AGENT_IDENTITIES_ANSWER = 12 41 | SSH_AGENTC_SIGN_REQUEST = 13 42 | SSH_AGENT_SIGN_RESPONSE = 14 43 | 44 | class YubiKey(models.Model): 45 | serial = models.PositiveIntegerField('Serial number', primary_key=True) 46 | 47 | def __str__(self): 48 | return str(self.serial) 49 | 50 | 51 | class PublicKey(models.Model): 52 | key = models.BinaryField('Public key') 53 | revoked = models.CharField('Reason for revocation', max_length=100, null=True) 54 | 55 | def ssh_string(self): 56 | return format_ssh_key(self.key) 57 | 58 | def __str__(self): 59 | h = b64encode(sha256(self.key).digest()).decode() 60 | return 'SHA256:{0}...{1}'.format(h[:3], h[-3:]) 61 | 62 | def sign_with_ca(self, identity, principal, ssh_keygen_options, 63 | device=None, renewal_of=None): 64 | force_cmd = False 65 | cmdline_options = [] 66 | for opt in ssh_keygen_options or []: 67 | cmdline_options.append('-O') 68 | cmdline_options.append(opt) 69 | if opt.startswith('force-command='): 70 | force_cmd = True 71 | if hasattr(self, 'attestation'): 72 | if identity: 73 | raise ValueError("identity doesn't make sense for attested keys, would be overwritten") 74 | if principal: 75 | raise ValueError("principal doesn't make sense for attested keys, would be overwritten") 76 | att = self.attestation 77 | email = att.user.email 78 | principal = email 79 | identity = '{0} YK#{1}'.format(email, att.yubikey.serial) 80 | elif not identity: 81 | raise ValueError('identity is mandatory for unattested keys') 82 | elif not principal: 83 | raise ValueError('principal is mandatory for unattested keys') 84 | elif not force_cmd: 85 | raise ValueError('Unattested keys must have forced command') 86 | mydevice = device or console_openpgp_init() 87 | sigctr_blob = bytes(mydevice.get_data(SECURITY_SUPPORT_TEMPLATE)) 88 | if len(sigctr_blob) != 7 or not sigctr_blob.startswith(OPGP_SIG_CTR_PREFIX): 89 | raise ValueError('Invalid reply to signature counter request: ' + 90 | repr(sigctr_blob)) 91 | (counter,) = struct.unpack(">I", b"\0" + sigctr_blob[len(OPGP_SIG_CTR_PREFIX):]) 92 | pk = mydevice.get_public_key(SIGNING_KEY) 93 | if len(pk) != 37 or not pk.startswith(OPGP_ED25519_PREFIX): 94 | raise ValueError('Only Ed25519 keys are supported') 95 | ed25519bytes = pk[len(OPGP_ED25519_PREFIX):] 96 | ssh_bytes = serialize_openssh(SSH_ED25519, ed25519bytes) 97 | ca = CA.objects.get(signer__pubkey__key=ssh_bytes) 98 | tmpdir = Path(mkdtemp(prefix='zsca-signcert')) 99 | try: 100 | tmpfiles = {} 101 | for name, source in [('issuer', ca.signer.pubkey), ('subject', self)]: 102 | tmpfile = tmpdir / (name + '.pub') 103 | tmpfile.write_text(source.ssh_string()) 104 | tmpfiles[name] = tmpfile 105 | ssh_auth_sock = str(tmpdir / 'ssh.sock') 106 | env = {'SSH_AUTH_SOCK': ssh_auth_sock} 107 | cmdline = ['ssh-keygen', '-Us', str(tmpfiles['issuer']), 108 | '-V', '+{0}d'.format(settings.CERT_MAX_DAYS), '-z', str(counter), 109 | '-I', identity, '-n', principal] + cmdline_options 110 | cmdline.append(str(tmpfiles['subject'])) 111 | sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 112 | sock.bind(ssh_auth_sock) 113 | sock.listen(1) 114 | keygen = Popen(cmdline, env=env) 115 | connection, client_address = sock.accept() 116 | while keygen.poll() is None: 117 | msglen = connection.recv(4) 118 | if not msglen: 119 | continue 120 | msg = connection.recv(struct.unpack('>I', msglen)[0]) 121 | cmd = msg[0] 122 | if cmd == SSH_AGENTC_REQUEST_IDENTITIES: 123 | response = serialize_openssh([(ssh_bytes, b'')], 124 | prefix=SSH_AGENT_IDENTITIES_ANSWER) 125 | connection.sendall(struct.pack('>I', len(response)) + response) 126 | elif cmd == SSH_AGENTC_SIGN_REQUEST: 127 | (keylen,) = struct.unpack('>I', msg[1:5]) 128 | (datalen,) = struct.unpack('>I', msg[(1+4+keylen):][:4]) 129 | data = msg[(1+4+keylen+4):][:datalen] 130 | assert 1 + 4 + datalen + 4 + keylen + 4 == len(msg) 131 | ed25519sig = mydevice.sign(data) 132 | signature = serialize_openssh(SSH_ED25519, ed25519sig) 133 | response = serialize_openssh(signature, prefix=SSH_AGENT_SIGN_RESPONSE) 134 | connection.sendall(struct.pack('>I', len(response)) + response) 135 | else: 136 | raise ValueError('Unsupported command {0}'.format(cmd)) 137 | cert = b64decode((tmpdir / 'subject-cert.pub').read_bytes().split(b" ")[1]) 138 | finally: 139 | rmtree(tmpdir) 140 | signed = ca.certificate_set.create(subject=self, cert=cert, 141 | renewal_of=renewal_of) 142 | signed.validate() 143 | return signed 144 | 145 | def console_openpgp_init(): 146 | mydevice = OpenPGPpy.OpenPGPcard() 147 | mydevice.verify_pin(1, getpass.getpass()) 148 | return mydevice 149 | 150 | def serialize_openssh(*args, prefix=None): 151 | payload = b''.join(serialize_openssh_value(arg) for arg in args) 152 | return payload if prefix is None else bytes([prefix]) + payload 153 | 154 | def serialize_openssh_value(value): 155 | if isinstance(value, list): 156 | return serialize_openssh(len(value), *value) 157 | if isinstance(value, tuple): 158 | return serialize_openssh(*value) 159 | elif isinstance(value, int): 160 | return struct.pack('>I', value) 161 | elif isinstance(value, bytes): 162 | return serialize_openssh_value(len(value)) + value 163 | 164 | 165 | class Attestation(models.Model): 166 | pubkey = models.OneToOneField(PublicKey, on_delete=models.PROTECT) 167 | yubikey = models.ForeignKey(YubiKey, on_delete=models.PROTECT) 168 | user = models.ForeignKey(User, on_delete=models.PROTECT) 169 | intermediate_cert = models.BinaryField('Intermediate attestation certificate') 170 | leaf_cert = models.BinaryField('Attestation statement / leaf certificate') 171 | 172 | def validate(self): 173 | be = default_backend() 174 | cert, attn = (x509.load_der_x509_certificate(der, be) for 175 | der in [self.leaf_cert, self.intermediate_cert]) 176 | for certfile in (Path(__file__).parent / 'attestation-ca-certs').glob('*.pem'): 177 | root = x509.load_pem_x509_certificate(certfile.read_bytes(), be) 178 | if root.subject == attn.issuer: 179 | break 180 | else: 181 | raise ValueError('Unknown CA') 182 | for (issuer, subject) in [(root, attn), (attn, cert)]: 183 | issuer.public_key().verify( 184 | subject.signature, 185 | subject.tbs_certificate_bytes, 186 | padding.PKCS1v15(), 187 | subject.signature_hash_algorithm) 188 | csn = read_yubikey_serial(cert) 189 | assert csn == self.yubikey.serial, ( 190 | "serial attested by {0!r} ({1!r}) doesn't match YubiKey {2!r}".format( 191 | self, csn, self.yubikey)) 192 | ossh_pubkey = b64decode(cert.public_key().public_bytes( 193 | format=serialization.PublicFormat.OpenSSH, 194 | encoding=serialization.Encoding.OpenSSH).split(b' ', 1)[-1]) 195 | assert ossh_pubkey == self.pubkey.key, (("public key attested by {0!r} " 196 | "doesn't match linked public key {1!r}").format(self, self.pubkey)) 197 | 198 | def verify(self, signature, data): 199 | cert = x509.load_der_x509_certificate(self.leaf_cert, default_backend()) 200 | cert.public_key().verify(signature['ssh-ed25519'], data) 201 | 202 | 203 | def read_yubikey_serial(cert): 204 | try: 205 | psn = cert.extensions.get_extension_for_oid(PIV_SERIAL_NO) 206 | except x509.ExtensionNotFound: 207 | cks, csn = [DERReader( 208 | cert.extensions.get_extension_for_oid(oid).value.value 209 | ).read_element(INTEGER).as_integer() 210 | for oid in [PGP_KEY_SOURCE, PGP_SERIAL_NO]] 211 | assert cks == ON_DEVICE, repr(self) + " was imported into the YubiKey" 212 | else: 213 | csn = DERReader(psn.value.value).read_element(INTEGER).as_integer() 214 | return csn 215 | 216 | 217 | class CA(models.Model): 218 | signer = models.OneToOneField(Attestation, on_delete=models.PROTECT) 219 | 220 | def validate(self): 221 | self.signer.validate() 222 | certs = [] 223 | for cert in self.certificate_set.all(): 224 | cert.validate() 225 | certs.append(cert.cert) 226 | assert len(set(certs)) == len(certs), ( 227 | "not all certificates signed by {0!r} are unique".format(self)) 228 | 229 | def get_krl(self, update=None): 230 | entries = list(self.get_revocation_entries()) 231 | if not entries: 232 | return update 233 | tmpdir = Path(mkdtemp(prefix='zsca-cert-krl')) 234 | try: 235 | krl = tmpdir / 'krl' 236 | cmdline = ['ssh-keygen', '-k', '-f', str(krl)] 237 | if update: 238 | krl.write_bytes(update) 239 | cmdline.append('-u') 240 | entry_file = tmpdir / 'entries' 241 | entry_file.write_text('\n'.join(entries)) 242 | ca = tmpdir / 'ca' 243 | ca.write_text(self.signer.pubkey.ssh_string()) 244 | check_call(cmdline + ['-s', str(ca), str(entry_file)]) 245 | return krl.read_bytes() 246 | finally: 247 | rmtree(tmpdir) 248 | 249 | def get_revocation_entries(self): 250 | t = time() 251 | for cert in self.certificate_set.exclude(revoked=None): 252 | pc = cert.parse() 253 | if t > pc['valid_before']: 254 | continue # wouldn't be valid anyway 255 | yield 'serial: ' + str(pc['serial']) 256 | 257 | 258 | KEY_PARAMS = { 259 | "ecdsa-sha2-nistp256": 2, 260 | "ecdsa-sha2-nistp384": 2, 261 | "ecdsa-sha2-nistp521": 2, 262 | "ssh-ed25519": 1, 263 | "ssh-rsa": 2, 264 | # DSA is omitted on purpose 265 | } 266 | 267 | CERT_POSTFIX = "-cert-v01@openssh.com" 268 | FORCE_COMMAND = 'force-command' 269 | VERIFY_REQUIRED = 'verify-required' 270 | SOURCE_ADDRESS = 'source-address' 271 | NO_TOUCH_REQUIRED = 'no-touch-required' 272 | SSH_PERMISSIONS = ['X11-forwarding', 'agent-forwarding', 'port-forwarding', 'pty', 'user-rc'] 273 | 274 | 275 | class Certificate(models.Model): 276 | issuer = models.ForeignKey(CA, on_delete=models.PROTECT) 277 | subject = models.ForeignKey(PublicKey, on_delete=models.PROTECT) 278 | cert = models.BinaryField('Certificate') 279 | renewal_of = models.ForeignKey('self', null=True, on_delete=models.PROTECT) 280 | revoked = models.CharField('Reason for revocation', max_length=100, null=True) 281 | 282 | def parse(self): 283 | bio = BytesIO(self.cert) 284 | subject_type = read_ssh_string(bio).decode() 285 | _nonce = read_ssh_string(bio) 286 | if not subject_type.endswith(CERT_POSTFIX): 287 | raise ValueError("unsupported cert type: " + repr(subject_type)) 288 | key_type = subject_type[:-len(CERT_POSTFIX)] 289 | pos1 = bio.tell() 290 | pk_components = tuple(read_ssh_string(bio) 291 | for _ in range(KEY_PARAMS[key_type])) 292 | pos2 = bio.tell() 293 | pubkey = {"type": key_type, "components": pk_components, 294 | "bytes": self.cert[pos1:pos2]} 295 | serial = read_struct(bio, '>Q') 296 | cert_type = read_struct(bio, '>I') 297 | key_id = read_ssh_string(bio).decode() 298 | principals = [p.decode() for p in read_ssh_string_list(bio)] 299 | valid_after = read_struct(bio, '>Q') 300 | valid_before = read_struct(bio, '>Q') 301 | crit_opts = read_dict(bio) 302 | extensions = read_dict(bio) 303 | reserved = read_ssh_string(bio) 304 | signature_key = read_ssh_string(bio) 305 | pos = bio.tell() 306 | signature = read_dict(bio) 307 | assert bio.read() == b'', repr(self) + " has trailing bytes" 308 | tbs = self.cert[:pos] 309 | return {"subject_type": subject_type, "pubkey": pubkey, 310 | "serial": serial, "cert_type": cert_type, "key_id": key_id, 311 | "principals": principals, "crit_opts": crit_opts, 312 | "valid_after": valid_after, "valid_before": valid_before, 313 | "extensions": extensions, "reserved": reserved, "tbs": tbs, 314 | "signature_key": signature_key, "signature": signature, 315 | } 316 | 317 | def status(self): 318 | if self.revoked: 319 | return f'Certificate revoked: {self.revoked}' 320 | if self.subject.revoked: 321 | return f'Key revoked: {self.subject.revoked}' 322 | t = time() 323 | p = self.parse() 324 | if p["valid_after"] > t: 325 | return f'Not yet valid, will be from {datetime.fromtimestamp(p["valid_after"])}' 326 | vb = p["valid_before"] 327 | expiration = datetime.fromtimestamp(vb) 328 | if vb < t: 329 | return f'Expired at {expiration}' 330 | r = self.issuer.signer.pubkey.revoked 331 | if r: 332 | return f'CA revoked: {r}' 333 | return f'Valid until {expiration} ({int((vb-t)//3600//24)} day(s))' 334 | 335 | def renew(self, device=None): 336 | cert = self.parse() 337 | pk = self.subject 338 | if hasattr(pk, 'attestation'): 339 | identity = principal = None 340 | else: 341 | identity = cert['key_id'] 342 | principal = ','.join(cert['principals']) 343 | pk.sign_with_ca(identity, principal, ssh_keygen_options(cert), device, self) 344 | 345 | def validate(self): 346 | parsed = self.parse() 347 | isigner = self.issuer.signer 348 | assert parsed['signature_key'] == isigner.pubkey.key, ( 349 | "{0!r} contains a different issuer key, not {1!r}".format(self, isigner)) 350 | isigner.verify(parsed['signature'], parsed['tbs']) 351 | sub = self.subject 352 | bio = BytesIO(sub.key) 353 | pk = parsed['pubkey'] 354 | assert read_ssh_string(bio).decode() == pk['type'], ( 355 | "{0!r} has a different keytype than {1!r}".format(self, sub)) 356 | assert bio.read() == pk['bytes'], ( 357 | "{0!r} has a different key than {1!r}".format(self,sub)) 358 | max_seconds = settings.CERT_MAX_DAYS * 60 * 60 * 24 + 120 # +2 minutes 359 | valid_seconds = parsed['valid_before'] - parsed['valid_after'] 360 | assert valid_seconds < max_seconds, repr(self) + " is valid for too long" 361 | if hasattr(sub, 'attestation'): 362 | att = sub.attestation 363 | att.validate() 364 | pp = parsed['principals'] 365 | email = att.user.email 366 | assert [email] == pp, ( 367 | "{0!r} has incorrect principals: {1!r}".format(self, pp)) 368 | kid = parsed['key_id'] 369 | assert email in kid, (("{0!r} doesn't contain the email address {1!r} " 370 | "in the key_id {2!r}").format(self, email, kid)) 371 | serial = att.yubikey.serial 372 | assert str(serial) in kid, (("{0!r} doesn't contain the YubiKey " 373 | "serial ({1}) in the key_id {2!r}").format(self, serial, kid)) 374 | else: 375 | assert 'force-command' in parsed['crit_opts'], ( 376 | repr(self) + " had no force-command option set") 377 | 378 | def ssh_string(self): 379 | return format_ssh_key(self.cert) 380 | 381 | def __str__(self): 382 | parsed = self.parse() 383 | return "{0} signed by {1}, serial {2}".format(self.subject, 384 | self.issuer.signer.pubkey, parsed['serial']) 385 | 386 | 387 | class SftpDeployment(models.Model): 388 | certificate = models.ForeignKey(Certificate, on_delete=models.PROTECT) 389 | hostname = models.CharField("Hostname", max_length=255) 390 | path = models.CharField("Path", max_length=255) 391 | 392 | def deploy(self, certificate): 393 | tmpdir = Path(mkdtemp(prefix='zsca-signcert')) 394 | try: 395 | tmpcert = tmpdir / 'cert.pub' 396 | tmpcert.write_text(certificate.ssh_string()) 397 | check_call(['scp', str(tmpcert), f'{self.hostname}:{self.path}']) 398 | finally: 399 | rmtree(tmpdir) 400 | 401 | 402 | def ssh_keygen_options(cert): 403 | crit_opts = cert['crit_opts'] 404 | for key in [FORCE_COMMAND, SOURCE_ADDRESS]: 405 | value = crit_opts.get(key) 406 | if value: 407 | yield '='.join((key, read_ssh_string(BytesIO(value)).decode())) 408 | if VERIFY_REQUIRED in crit_opts: 409 | yield VERIFY_REQUIRED 410 | 411 | extensions = cert['extensions'] 412 | for perm in SSH_PERMISSIONS: 413 | if 'permit-' + perm not in extensions: 414 | yield 'no-' + perm.lower() # X11 -> x11 415 | if NO_TOUCH_REQUIRED in extensions: 416 | yield NO_TOUCH_REQUIRED 417 | 418 | def format_ssh_key(serialized): 419 | return b" ".join([ 420 | read_ssh_string(BytesIO(serialized)), 421 | b64encode(serialized) 422 | ]).decode() 423 | 424 | def read_dict(bio): 425 | return {k.decode(): v for k, v in chunked(read_ssh_string_list(bio), 2)} 426 | 427 | def read_ssh_string_list(bio): 428 | container = read_ssh_string(bio) 429 | return list(iter(partial(try_read_ssh_string, BytesIO(container)), None)) 430 | 431 | def try_read_ssh_string(bio): 432 | try: 433 | return bio.read(read_struct(bio, '>I')) 434 | except struct.error: 435 | return None 436 | 437 | def read_ssh_string(bio): 438 | return bio.read(read_struct(bio, '>I')) 439 | 440 | def read_struct(bio, fmt): 441 | (value,) = struct.unpack(fmt, bio.read(struct.calcsize(fmt))) 442 | return value 443 | 444 | # src: https://github.com/more-itertools/more-itertools, license: MIT 445 | def chunked(iterable, n): 446 | iterator = iter(partial(take, n, iter(iterable)), []) 447 | for chunk in iterator: 448 | if len(chunk) != n: 449 | raise ValueError('iterable is not divisible by n.') 450 | yield chunk 451 | 452 | def take(n, iterable): 453 | return list(islice(iterable, n)) 454 | -------------------------------------------------------------------------------- /ca/templates/certificates.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Zero trust SSH CA 6 | 7 | 8 |

Zero trust SSH CA

9 |

Certificates

10 |
11 | {% csrf_token %} 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | {% for certificate in certificates %} 27 | {% with parsed=certificate.parse %} 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | {% endwith %} 39 | {% endfor %} 40 | 41 |
IDCASrlKey IDSubjectStatus
{{ certificate.pk }}CA{{ certificate.issuer.pk }}{{ parsed.serial }}{{ certificate.subject }}{{ certificate.status }}Export
42 |

Revoke certificates

43 | 44 | 45 | 46 |

Renew certificates

47 | 48 | 49 | 50 |
51 | 52 | 53 | -------------------------------------------------------------------------------- /ca/templates/certmail.txt: -------------------------------------------------------------------------------- 1 | Dear {{ user.first_name }},{% if cert.renewal_of %} 2 | the status of your current SSH certificate: {{ cert.renewal_of.status }}{% endif %} 3 | your new SSH certificate can be found below: 4 | 5 | {{ cert.ssh_string }} 6 | 7 | Regards, 8 | ZSCA -------------------------------------------------------------------------------- /ca/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /ca/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | path('', views.certificates, name='certificates'), 7 | path('certificates/.pub', views.export_certificate, name='export_certificate'), 8 | ] 9 | -------------------------------------------------------------------------------- /ca/views.py: -------------------------------------------------------------------------------- 1 | from django.core.mail import EmailMessage 2 | from django.http import HttpResponse 3 | from django.shortcuts import render, get_object_or_404, redirect 4 | from django.template.loader import render_to_string 5 | 6 | import OpenPGPpy 7 | 8 | from ca.models import Certificate 9 | 10 | def process_revocation(request, certs): 11 | certs.update(revoked=request.POST['reason']) 12 | 13 | def process_renewal(request, certs): 14 | device = OpenPGPpy.OpenPGPcard() 15 | device.verify_pin(1, request.POST['password']) 16 | for cert in certs: 17 | cert.renew(device) 18 | 19 | CERT_ACTION_MAP = {'revoke': process_revocation, 'renew': process_renewal} 20 | 21 | def certificates(request): 22 | for k, v in CERT_ACTION_MAP.items(): 23 | if k in request.POST: 24 | v(request, Certificate.objects.filter( 25 | pk__in=request.POST.getlist('cert_id'))) 26 | return redirect(request.path) 27 | return render(request, 'certificates.html', 28 | {'certificates': Certificate.objects.all()}) 29 | 30 | def export_certificate(request, pk): 31 | return HttpResponse( 32 | get_object_or_404(Certificate, pk=pk).ssh_string().encode('utf-8'), 33 | content_type='text/plain') 34 | 35 | def email_certificate(user, cert): 36 | ctx = {"user": user, "cert": cert} 37 | EmailMessage(to=[user.email], 38 | subject='SSH certificate', 39 | body=render_to_string('certmail.txt', ctx)).send() -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'zsca.settings') 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == '__main__': 21 | main() 22 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | cryptography==3.4.8 2 | Django==2.2 3 | OpenPGPpy==0.5 4 | -------------------------------------------------------------------------------- /zsca/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/silentsignal/zsca/3aaec073ded6d5c6025a737d5eeca2b6861e3e28/zsca/__init__.py -------------------------------------------------------------------------------- /zsca/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for zsca project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.2/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = '&_=nm#fx%klkp&^fj_^)r-izl1@xqg6c2-5)66!&=h2s)d#k+3' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'ca', 41 | ] 42 | 43 | MIDDLEWARE = [ 44 | 'django.middleware.security.SecurityMiddleware', 45 | 'django.contrib.sessions.middleware.SessionMiddleware', 46 | 'django.middleware.common.CommonMiddleware', 47 | 'django.middleware.csrf.CsrfViewMiddleware', 48 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 49 | 'django.contrib.messages.middleware.MessageMiddleware', 50 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 51 | ] 52 | 53 | ROOT_URLCONF = 'zsca.urls' 54 | 55 | TEMPLATES = [ 56 | { 57 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 58 | 'DIRS': [], 59 | 'APP_DIRS': True, 60 | 'OPTIONS': { 61 | 'context_processors': [ 62 | 'django.template.context_processors.debug', 63 | 'django.template.context_processors.request', 64 | 'django.contrib.auth.context_processors.auth', 65 | 'django.contrib.messages.context_processors.messages', 66 | ], 67 | }, 68 | }, 69 | ] 70 | 71 | WSGI_APPLICATION = 'zsca.wsgi.application' 72 | 73 | 74 | # Database 75 | # https://docs.djangoproject.com/en/2.2/ref/settings/#databases 76 | 77 | DATABASES = { 78 | 'default': { 79 | 'ENGINE': 'django.db.backends.sqlite3', 80 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 81 | } 82 | } 83 | 84 | 85 | # Password validation 86 | # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators 87 | 88 | AUTH_PASSWORD_VALIDATORS = [ 89 | { 90 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 91 | }, 92 | { 93 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 94 | }, 95 | { 96 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 97 | }, 98 | { 99 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 100 | }, 101 | ] 102 | 103 | 104 | # Internationalization 105 | # https://docs.djangoproject.com/en/2.2/topics/i18n/ 106 | 107 | LANGUAGE_CODE = 'en-us' 108 | 109 | TIME_ZONE = 'UTC' 110 | 111 | USE_I18N = True 112 | 113 | USE_L10N = True 114 | 115 | USE_TZ = True 116 | 117 | 118 | # Static files (CSS, JavaScript, Images) 119 | # https://docs.djangoproject.com/en/2.2/howto/static-files/ 120 | 121 | STATIC_URL = '/static/' 122 | 123 | CERT_MAX_DAYS = 90 124 | -------------------------------------------------------------------------------- /zsca/urls.py: -------------------------------------------------------------------------------- 1 | """zsca URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | 17 | from django.conf.urls import include 18 | from django.contrib import admin 19 | from django.urls import path 20 | from django.views.generic import RedirectView 21 | 22 | urlpatterns = [ 23 | path('', RedirectView.as_view(url='/ca/')), 24 | path('ca/', include('ca.urls')), 25 | path('admin/', admin.site.urls), 26 | ] 27 | -------------------------------------------------------------------------------- /zsca/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for zsca project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'zsca.settings') 15 | 16 | application = get_wsgi_application() 17 | --------------------------------------------------------------------------------