├── .dir-locals.el ├── .envrc ├── .gitignore ├── LICENSE ├── README.md ├── flake.lock ├── flake.nix ├── pyproject.toml └── src └── pki ├── __init__.py ├── __main__.py ├── certificate.py ├── dependencies.py └── yubikey.py /.dir-locals.el: -------------------------------------------------------------------------------- 1 | ((python-mode . ((mode . apheleia))) 2 | (nix-mode . ((mode . apheleia)))) 3 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /result 2 | /*.qcow2 3 | /*.pem 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The license below applies to most, but not all content in this project. 2 | Files with different licensing and authorship terms are marked as such. 3 | That information must be considered when ensuring licensing compliance. 4 | 5 | ISC License 6 | 7 | Copyright (c) 2024-2025, Vincent Bernat 8 | 9 | Permission to use, copy, modify, and/or distribute this software for any 10 | purpose with or without fee is hereby granted, provided that the above 11 | copyright notice and this permission notice appear in all copies. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 14 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 15 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 16 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 17 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 18 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 19 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Offline PKI 2 | 3 | This repository contains a simple (and opiniated) offline PKI system that can be 4 | run from an ARM64 SBC, like the [Sweet Potato AML-S905X-CC-V2][potato]. 5 | 6 | [potato]: https://libre.computer/products/aml-s905x-cc-v2/ 7 | 8 | ## Installation 9 | 10 | ### With Nix 11 | 12 | This repository can be used as a Flake. 13 | 14 | - `nix shell` 15 | - `nix run . -- --help` 16 | - `nix run github:vincentbernat/offline-pki -- --help` 17 | 18 | ### Without Nix 19 | 20 | Create a virtualenv and execute `pip install`: 21 | 22 | ```console 23 | $ python -m venv .venv 24 | $ source .venv/bin/active 25 | $ pip install . 26 | ``` 27 | 28 | You may need `libpcsclite-dev` package. 29 | 30 | ### Image creation 31 | 32 | To build the SD card image (the first variant is for the Sweet Potato): 33 | 34 | ```shell 35 | nix build --system aarch64-linux .\#sdcard.potato 36 | nix build --system aarch64-linux .\#sdcard.generic 37 | ``` 38 | 39 | To flash it: 40 | 41 | ```shell 42 | zstdcat result/sd-image/nixos-sd-image-*-aarch64-linux.img.zst | pv --size 2G | sudo dd of=/dev/sdc 43 | ``` 44 | 45 | You can get a serial console using the UART header. See [GPIO Pinout Header 46 | Maps][] for Libre Computer boards. For the AML-S905X-CC-V2, 1 (at the edge) is 47 | GND, 2 is TX and 3 is RX. When using a USB to TTL adapter, you need to swap RX 48 | and TX. 49 | 50 | The default speed is 115200. A good small serial tool is `tio`. 51 | 52 | [gpio pinout header maps]: https://hub.libre.computer/t/gpio-pinout-header-maps-and-wiring-tool-for-libre-computer-boards/28 53 | 54 | ## Operations 55 | 56 | ### CA creation 57 | 58 | You need three YubiKeys. Be sure to label them correctly. 59 | 60 | - Root 1 61 | - Root 2 62 | - Intermediate 63 | 64 | For each YubiKey, run `offline-pki yubikey reset` to wipe them and configure PIN code, 65 | PUK code, and management key. The same management key must be used for all root 66 | keys. You may use more root keys as backups, as it is not possible to duplicate 67 | a root key. 68 | 69 | Then, execute `offline-pki certificate root` to initialize "Root 1" and "Root 2" (or 70 | more of them). Then, use `offline-pki certificate intermediate` to initialize 71 | "Intermediate". 72 | 73 | For the root certificate, you can customize the subject name with the 74 | `--subject-name` flag. The `offline-pki certificate intermediate` command also accepts a 75 | subject-name (`CN=Intermediate CA` by default) and it will merge the missing 76 | attributes from the root certificate. Therefore, by using the following 77 | commands, the intermediate certificate will have `CN=Intermediate CA,O=Your 78 | Organization,OU=Secret Unit,C=FR` as a subject name. 79 | 80 | ```console 81 | $ offline-pki certificate root --subject-name "CN=Root CA,O=Your Organization,OU=Secret Unit,C=FR" 82 | $ offline-pki certificate intermediate 83 | ``` 84 | 85 | It is also possible to add [name constraints][] to the root certificate to restrict its use. 86 | 87 | ```console 88 | $ offline-pki certificate root \ 89 | > --permitted dns:example.com \ 90 | > --excluded dns:www.example.com \ 91 | > --permitted ip:203.0.113.0/24 \ 92 | > --permitted email:@example.com 93 | ``` 94 | 95 | You can check the result with `offline-pki yubikey info`. 96 | 97 | You can do several intermediate CA, one per usage. There is no backup 98 | intermediate as if it is destroyed or lost, you can just generate a new one 99 | (clients have to trust the root certificate, not the intermediate ones). 100 | 101 | [name constraints]: https://www.sysadmins.lv/blog-en/x509-name-constraints-certificate-extension-all-you-should-know.aspx 102 | 103 | ### CSR signature 104 | 105 | The last step is to sign some certificate request with the `offline-pki certificate 106 | sign`. You can override the subject name with `--subject-name` and in this case, 107 | the missing attributes are copied from the intermediate certificate. Otherwise, 108 | the subject name from the CSR is used. Moreover, all extensions from the CSR are 109 | copied over. 110 | 111 | > [!CAUTION] 112 | > As this tool does not display certificate content, it is important to check 113 | > the content of the CSR before signing it: 114 | 115 | ```console 116 | $ openssl req \ 117 | -config /dev/null \ 118 | -newkey ec:<(openssl ecparam -name secp384r1) -sha384 -nodes \ 119 | -subj "/C=FR/O=Example SARL/OU=Network/CN=ipsec-gw1.example.com" \ 120 | -addext "subjectAltName = DNS:ipsec-gw.example.com" \ 121 | -addext "keyUsage = digitalSignature" \ 122 | -keyform PEM -keyout server-key.pem -outform PEM -out server-csr.pem 123 | $ openssl req -noout -text -in server-csr.pem 124 | ``` 125 | 126 | ## Limitations 127 | 128 | There are several limitations with this little PKI: 129 | 130 | - YubiKey 5.4.2 or more recent is needed (AES management key requires 5.4.2, 131 | ability to retrieve metadata requires 5.3.0, ECC-P384 algorithm requires 132 | 5.0.0) 133 | - not everything is configurable, notably the cryptography is hard-coded (NIST 134 | P-384 elliptic curve, most commonly supported EC) 135 | - no CRL support (this is an offline PKI, while not impossible, this would be a pain) 136 | - random serial numbers (no state is kept, except the certificates on the YubiKeys) 137 | 138 | ## Development 139 | 140 | For development, one can either invoke a Nix shell with `nix develop` or spawn a 141 | QEMU VM with `nix run .\#qemu`. 142 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1737885640, 24 | "narHash": "sha256-GFzPxJzTd1rPIVD4IW+GwJlyGwBDV1Tj5FLYwDQQ9sM=", 25 | "owner": "nixos", 26 | "repo": "nixpkgs", 27 | "rev": "4e96537f163fad24ed9eb317798a79afc85b51b7", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "nixos", 32 | "ref": "nixos-24.11", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:nixos/nixpkgs/nixos-24.11"; 4 | flake-utils.url = "github:numtide/flake-utils"; 5 | }; 6 | outputs = { self, nixpkgs, flake-utils }: 7 | let 8 | lib = nixpkgs.lib; 9 | in 10 | flake-utils.lib.eachDefaultSystemPassThrough 11 | (system: { 12 | nixosModules.default = { pkgs, ... }: 13 | let 14 | shellInitScript = pkgs.writeShellScriptBin "shell-init" '' 15 | # Shell version of xterm's resize tool. 16 | if [ -e /dev/tty ]; then 17 | old=$(stty -g) 18 | stty raw -echo min 0 time 5 19 | printf '\033[18t' > /dev/tty 20 | IFS=';t' read -r _ rows cols _ < /dev/tty 21 | stty "$old" 22 | [ -z "$cols" ] || [ -z "$rows" ] || stty cols "$cols" rows "$rows" 23 | fi 24 | 25 | # Set date/time 26 | echo "Current date and time: $(date +"%Y-%m-%d %H:%M:%S")" 27 | echo -n "New date/time (YYYY-MM-DD HH:MM:SS): " 28 | read datetime 29 | [ -z "$datetime" ] || doas date -s "$datetime" 30 | ''; 31 | in 32 | { 33 | system.stateVersion = "24.11"; 34 | networking.hostName = "offline-pki"; 35 | services.pcscd.enable = true; 36 | environment.systemPackages = [ 37 | pkgs.yubikey-manager 38 | pkgs.openssl 39 | self.packages.${pkgs.system}.offline-pki 40 | ]; 41 | 42 | # PKI user and autologin 43 | users.allowNoPasswordLogin = true; 44 | users.mutableUsers = false; 45 | users.users.pki = { 46 | isNormalUser = true; 47 | description = "PKI user"; 48 | }; 49 | services.getty.autologinUser = "pki"; 50 | environment.loginShellInit = "${shellInitScript}/bin/shell-init"; 51 | security.doas = { 52 | enable = true; 53 | extraRules = [ 54 | { users = [ "pki" ]; cmd = "date"; noPass = true; } 55 | ]; 56 | }; 57 | 58 | # Lustrate the system at every boot 59 | system.activationScripts.lustrate = '' 60 | # Lustrate on next boot 61 | touch /etc/NIXOS_LUSTRATE 62 | ''; 63 | }; 64 | }) 65 | // flake-utils.lib.eachDefaultSystem (system: 66 | let 67 | pkgs = import nixpkgs { 68 | inherit system; 69 | }; 70 | pyproject = 71 | let 72 | toml = pkgs.lib.importTOML ./pyproject.toml; 73 | in 74 | { 75 | pname = toml.project.name; 76 | version = toml.project.version; 77 | build-system = pkgs.lib.map (p: python.pkgs.${p}) toml.build-system.requires; 78 | dependencies = pkgs.lib.map (p: python.pkgs.${p}) toml.project.dependencies; 79 | scripts = toml.project.scripts; 80 | }; 81 | python = pkgs.python3.override { 82 | self = python; 83 | packageOverrides = pyfinal: pyprev: { 84 | yubikey-manager = pkgs.yubikey-manager; 85 | offline-pki-editable = pyfinal.mkPythonEditablePackage { 86 | inherit (pyproject) pname version build-system dependencies scripts; 87 | root = "$OFFLINE_PKI_ROOT/src"; 88 | }; 89 | }; 90 | }; 91 | in 92 | { 93 | packages = (lib.optionalAttrs (system == "aarch64-linux") { 94 | sdcard = { 95 | # sdcard for Libre Computer Amlogic card. This may not work with other 96 | # devices, notably Raspberry Pi. 97 | potato = 98 | let 99 | image = lib.nixosSystem { 100 | inherit system; 101 | modules = [ 102 | "${nixpkgs}/nixos/modules/profiles/minimal.nix" 103 | "${nixpkgs}/nixos/modules/installer/sd-card/sd-image.nix" 104 | ({ config, ... }: { 105 | # This is a reduced version of sd-image-aarch64 106 | boot.loader.grub.enable = false; 107 | boot.loader.generic-extlinux-compatible.enable = true; 108 | boot.consoleLogLevel = lib.mkDefault 7; 109 | sdImage = { 110 | populateFirmwareCommands = ""; 111 | populateRootCommands = '' 112 | mkdir -p ./files/boot 113 | ${config.boot.loader.generic-extlinux-compatible.populateCmd} \ 114 | -c ${config.system.build.toplevel} \ 115 | -d ./files/boot 116 | ''; 117 | }; 118 | # For Amlogic boards, the console is on ttyAML0. 119 | boot.kernelParams = [ "console=ttyAML0,115200n8" "console=ttyS0,115200n8" "console=tty0" ]; 120 | # No need for firmwares (enabled by sd-image.nix) 121 | hardware.enableRedistributableFirmware = lib.mkForce false; 122 | # Do not embed nixpkgs to make the system smaller 123 | nixpkgs.flake = { 124 | setFlakeRegistry = false; 125 | setNixPath = false; 126 | }; 127 | }) 128 | self.nixosModules.default 129 | ]; 130 | }; 131 | in 132 | image.config.system.build.sdImage; 133 | # This one is more likely to work with other SBCs. 134 | generic = 135 | let 136 | image = lib.nixosSystem { 137 | inherit system; 138 | modules = [ 139 | "${nixpkgs}/nixos/modules/installer/sd-card/sd-image-aarch64.nix" 140 | self.nixosModules.default 141 | ]; 142 | }; 143 | in 144 | image.config.system.build.sdImage; 145 | }; 146 | }) // rec { 147 | offline-pki = python.pkgs.buildPythonApplication { 148 | inherit (pyproject) pname version build-system dependencies; 149 | pyproject = true; 150 | src = ./.; 151 | nativeBuildInputs = [ 152 | pkgs.installShellFiles 153 | ]; 154 | 155 | postInstall = '' 156 | installShellCompletion --cmd offline-pki \ 157 | --bash <(_OFFLINE_PKI_COMPLETE=bash_source "$out/bin/offline-pki") \ 158 | --zsh <(_OFFLINE_PKI_COMPLETE=zsh_source "$out/bin/offline-pki") \ 159 | --fish <(_OFFLINE_PKI_COMPLETE=fish_source "$out/bin/offline-pki") \ 160 | ''; 161 | }; 162 | default = offline-pki; 163 | 164 | # QEMU image for development (and only for that!) 165 | qemu = 166 | let 167 | image = lib.nixosSystem { 168 | inherit system; 169 | modules = [ 170 | "${nixpkgs}/nixos/modules/virtualisation/qemu-vm.nix" 171 | "${nixpkgs}/nixos/modules/profiles/qemu-guest.nix" 172 | "${nixpkgs}/nixos/modules/profiles/minimal.nix" 173 | ({ pkgs, ... }: { 174 | virtualisation = { 175 | graphics = false; 176 | qemu.options = [ 177 | "-serial mon:stdio" 178 | "-usb" 179 | ] ++ ( 180 | # YubiKey passthrough 181 | lib.map 182 | (id: "-device usb-host,vendorid=0x1050,productid=0x040${toString id}") 183 | (lib.range 1 8) 184 | ); 185 | }; 186 | users.users.root.password = ".Linux."; 187 | }) 188 | self.nixosModules.default 189 | ]; 190 | }; 191 | in 192 | image.config.system.build.vm; 193 | }; 194 | 195 | # Development shell 196 | devShells.default = pkgs.mkShell { 197 | name = "offline-pki"; 198 | nativeBuildInputs = [ 199 | pkgs.tio 200 | pkgs.openssl 201 | pkgs.yubikey-manager 202 | (python.withPackages 203 | (python-pkgs: with python-pkgs; [ offline-pki-editable ])) 204 | ]; 205 | shellHook = '' 206 | export OFFLINE_PKI_ROOT=$PWD 207 | source <(_OFFLINE_PKI_COMPLETE=bash_source offline-pki) 208 | ''; 209 | }; 210 | }); 211 | } 212 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "offline-pki" 3 | version = "0.1.0" 4 | description = "Offline PKI using YubiKeys as HSM" 5 | readme = "README.md" 6 | requires-python = ">=3.8" 7 | dependencies = [ 8 | "click", 9 | "cryptography", 10 | "yubikey-manager", 11 | ] 12 | 13 | [project.scripts] 14 | offline-pki = "pki.__main__:main" 15 | 16 | [build-system] 17 | requires = ["hatchling"] 18 | build-backend = "hatchling.build" 19 | 20 | [tool.hatch.build.targets.wheel] 21 | packages = ["src/pki"] 22 | -------------------------------------------------------------------------------- /src/pki/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vincentbernat/offline-pki/d89166be407bac617fe7bc88352971eec96a08d6/src/pki/__init__.py -------------------------------------------------------------------------------- /src/pki/__main__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import logging.handlers 3 | import sys 4 | import traceback 5 | import click 6 | from pathlib import Path 7 | 8 | from .certificate import certificate 9 | from .yubikey import yubikey 10 | 11 | logger = logging.getLogger("offline-pki") 12 | 13 | 14 | class CustomFormatter(logging.Formatter): 15 | def formatException(self, exc_info): 16 | exc_type, exc_value, tb = exc_info 17 | 18 | # Find the frame from our code 19 | relevant_frame = None 20 | for frame in traceback.extract_tb(tb): 21 | if Path(frame.filename).parent == Path(__file__).parent: 22 | relevant_frame = frame 23 | 24 | if relevant_frame is None: 25 | return super().formatException(self, exc_info) 26 | 27 | relative_filename = Path(relevant_frame.filename).relative_to( 28 | Path(__file__).parent 29 | ) 30 | return f" At {relative_filename}:{relevant_frame.lineno}: {relevant_frame.line}" 31 | 32 | 33 | @click.group() 34 | @click.option("--debug", is_flag=True, default=False) 35 | def cli(debug: bool) -> int: 36 | """Simple offline PKI using YubiKeys as HSM. 37 | 38 | This program is a very barebone PKI for offline certificates. It requires at 39 | least three YubiKeys as HSM to store the root certificate (on "ROOT1" and 40 | "ROOT2", as a backup) and the intermediate certificate (on "INTERMEDIATE"). 41 | 42 | The features are quite limited as it only provides root CA and intermediate 43 | CA creation, CRL gene ration for each of them, and certificate signing. 44 | """ 45 | root = logging.getLogger("") 46 | root.setLevel(logging.INFO) 47 | logger.setLevel(debug and logging.DEBUG or logging.INFO) 48 | ch = logging.StreamHandler() 49 | ch.setFormatter(CustomFormatter("%(levelname)s[%(name)s] %(message)s")) 50 | root.addHandler(ch) 51 | 52 | 53 | cli.add_command(yubikey) 54 | cli.add_command(certificate) 55 | 56 | 57 | def main(): 58 | try: 59 | return cli(prog_name="offline-pki") 60 | except Exception as e: 61 | logger.exception("%s", e) 62 | sys.exit(1) 63 | 64 | 65 | if __name__ == "__main__": 66 | sys.exit(main()) 67 | -------------------------------------------------------------------------------- /src/pki/certificate.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import logging 3 | import click 4 | import warnings 5 | import typing 6 | import ipaddress 7 | from datetime import datetime, timedelta, timezone 8 | from cryptography.utils import CryptographyDeprecationWarning 9 | from cryptography.x509.general_name import GeneralName 10 | 11 | from .yubikey import click_management_key, click_pin, yubikey_one, YUBIKEY 12 | 13 | 14 | logger = logging.getLogger("offline-pki.certificate") 15 | warnings.filterwarnings( 16 | "ignore", category=CryptographyDeprecationWarning, message=".*TripleDES.*" 17 | ) 18 | 19 | 20 | def validate_constraint(value: str) -> GeneralName: 21 | """Parse a constraint string and return the appropriate GeneralName object.""" 22 | from . import dependencies as d 23 | 24 | if not value or ":" not in value: 25 | raise click.BadParameter("Invalid constraint, use prefix:value") 26 | prefix, constraint = value.split(":", 1) 27 | prefix = prefix.lower() 28 | if prefix == "dns": 29 | return d.x509.general_name.DNSName(constraint) 30 | if prefix == "ip": 31 | try: 32 | network = ipaddress.ip_network(constraint, strict=False) 33 | return d.x509.general_name.IPAddress(network) 34 | except ValueError: 35 | raise click.BadParameter(f"Invalid IP address/network: {constraint}") 36 | if prefix == "email": 37 | return d.x509.general_name.RFC822Name(constraint) 38 | if prefix == "dn": 39 | try: 40 | return d.x509.general_name.DirectoryName( 41 | d.x509.Name.from_rfc4514_string(constraint) 42 | ) 43 | except ValueError: 44 | raise click.BadParameter(f"Invalid DN: {constraint}") 45 | raise click.BadParameter(f"Unsupported constraint type: {prefix}") 46 | 47 | 48 | def validate_constraints(ctx, param, values) -> typing.Optional[list[GeneralName]]: 49 | """Parse constraint strings and return GeneralName objects.""" 50 | if not values: 51 | return None 52 | 53 | result = [] 54 | for value in values: 55 | result.append(validate_constraint(value)) 56 | 57 | return result 58 | 59 | 60 | @click.group() 61 | def certificate() -> None: 62 | """Certificate management.""" 63 | 64 | 65 | @certificate.command("root") 66 | @click_management_key(YUBIKEY.ROOT) 67 | @click.option( 68 | "--subject-name", default="CN=Root CA", help="Subject name", type=click.STRING 69 | ) 70 | @click.option( 71 | "--permitted", 72 | multiple=True, 73 | default=[], 74 | help="Permitted value for name constraints", 75 | callback=validate_constraints, 76 | ) 77 | @click.option( 78 | "--excluded", 79 | multiple=True, 80 | default=[], 81 | help="Excluded value for name constraints", 82 | callback=validate_constraints, 83 | ) 84 | @click.option( 85 | "--days", 86 | default=365 * 20, 87 | help="Root certificate validity in days", 88 | type=click.IntRange(min=1), 89 | ) 90 | def certificate_root( 91 | management_key: bytes, 92 | subject_name: str, 93 | permitted: typing.Optional[list[GeneralName]], 94 | excluded: typing.Optional[list[GeneralName]], 95 | days: int, 96 | ) -> None: 97 | """Initialize a new root certificate. 98 | 99 | When specifying constraints, the format is either: 100 | - DNS:example.com 101 | - EMAIL:example.com 102 | - IP:203.0.113.0/24 103 | - DN:"C=FR,O=Example Corp" 104 | """ 105 | from . import dependencies as d 106 | 107 | logger.debug("Generate a new private key") 108 | private_key = d.ec.generate_private_key(d.ec.SECP384R1()) 109 | subject = d.x509.Name.from_rfc4514_string(subject_name) 110 | logger.debug("Generate a new certificate") 111 | cert_builder = ( 112 | ( 113 | d.x509.CertificateBuilder() 114 | .subject_name(subject) 115 | .issuer_name(subject) 116 | .public_key(private_key.public_key()) 117 | .serial_number(1) 118 | .not_valid_before(datetime.now(timezone.utc)) 119 | .not_valid_after(datetime.now(timezone.utc) + timedelta(days=days)) 120 | ) 121 | .add_extension( 122 | d.x509.BasicConstraints(ca=True, path_length=None), 123 | critical=True, 124 | ) 125 | .add_extension( 126 | d.x509.KeyUsage( 127 | digital_signature=True, 128 | content_commitment=False, 129 | key_encipherment=False, 130 | data_encipherment=False, 131 | key_agreement=False, 132 | key_cert_sign=True, 133 | crl_sign=True, 134 | encipher_only=False, 135 | decipher_only=False, 136 | ), 137 | critical=True, 138 | ) 139 | ) 140 | if permitted or excluded: 141 | cert_builder = cert_builder.add_extension( 142 | d.x509.NameConstraints( 143 | permitted_subtrees=permitted or None, excluded_subtrees=excluded or None 144 | ), 145 | critical=True, 146 | ) 147 | cert = cert_builder.sign(private_key, d.hashes.SHA384()) 148 | logger.debug( 149 | "Certificate: %s", 150 | cert.public_bytes(encoding=d.serialization.Encoding.PEM).decode("utf-8"), 151 | ) 152 | while True: 153 | with yubikey_one(YUBIKEY.ROOT).open_connection(d.SmartCardConnection) as conn: 154 | piv = d.PivSession(conn) 155 | piv.authenticate(management_key) 156 | piv.put_certificate(d.SLOT.SIGNATURE, cert, compress=True) 157 | piv.put_key( 158 | d.SLOT.SIGNATURE, 159 | private_key, 160 | d.PIN_POLICY.ONCE, 161 | d.TOUCH_POLICY.NEVER, 162 | ) 163 | if not click.confirm("Copy root certificate to another YubiKey?"): 164 | break 165 | 166 | 167 | @certificate.command("intermediate") 168 | @click_management_key(YUBIKEY.INTERMEDIATE) 169 | @click_pin(YUBIKEY.ROOT) 170 | @click.option( 171 | "--subject-name", 172 | default="CN=Intermediate CA", 173 | help="Subject name", 174 | type=click.STRING, 175 | ) 176 | @click.option( 177 | "--days", 178 | default=365 * 4, 179 | help="Intermediate certificate validity in days", 180 | type=click.IntRange(min=1), 181 | ) 182 | def certificate_intermediate( 183 | management_key: bytes, pin: str, subject_name: str, days: int 184 | ) -> None: 185 | """Initialize a new intermediate certificate. 186 | 187 | If the subject name is missing an attribute compared to the root certificate, 188 | they are copied over. 189 | """ 190 | from . import dependencies as d 191 | 192 | with yubikey_one(YUBIKEY.INTERMEDIATE).open_connection( 193 | d.SmartCardConnection 194 | ) as conn: 195 | logger.debug("Generate private key for intermediate certificate") 196 | piv = d.PivSession(conn) 197 | piv.authenticate(management_key) 198 | public_key = piv.generate_key( 199 | d.SLOT.SIGNATURE, 200 | d.KEY_TYPE.ECCP384, 201 | d.PIN_POLICY.ONCE, 202 | d.TOUCH_POLICY.NEVER, 203 | ) 204 | with yubikey_one(YUBIKEY.ROOT).open_connection(d.SmartCardConnection) as conn: 205 | logger.debug("Create intermediate certificate") 206 | piv = d.PivSession(conn) 207 | root = piv.get_certificate(d.SLOT.SIGNATURE) 208 | if root.issuer.rfc4514_string() != root.subject.rfc4514_string(): 209 | raise RuntimeError("The inserted key does not look like a root YubiKey!") 210 | issuer = root.subject 211 | subject = d.x509.Name.from_rfc4514_string(subject_name) 212 | missing = [ 213 | attribute 214 | for attribute in issuer 215 | if attribute.oid not in [attr.oid for attr in subject] 216 | ] 217 | subject = d.x509.Name(missing + [attr for attr in subject]) 218 | logger.debug(f"Subject name is {subject.rfc4514_string()}") 219 | cert = ( 220 | ( 221 | d.x509.CertificateBuilder() 222 | .subject_name(subject) 223 | .issuer_name(issuer) 224 | .public_key(public_key) 225 | .serial_number(d.x509.random_serial_number()) 226 | .not_valid_before(datetime.now(timezone.utc)) 227 | .not_valid_after(datetime.now(timezone.utc) + timedelta(days=days)) 228 | ) 229 | .add_extension( 230 | d.x509.BasicConstraints(ca=True, path_length=None), 231 | critical=True, 232 | ) 233 | .add_extension( 234 | d.x509.KeyUsage( 235 | digital_signature=True, 236 | content_commitment=False, 237 | key_encipherment=False, 238 | data_encipherment=False, 239 | key_agreement=False, 240 | key_cert_sign=True, 241 | crl_sign=True, 242 | encipher_only=False, 243 | decipher_only=False, 244 | ), 245 | critical=True, 246 | ) 247 | ) 248 | piv.verify_pin(pin) 249 | signed_cert = d.sign_certificate_builder( 250 | piv, 251 | slot=d.SLOT.SIGNATURE, 252 | key_type=d.KEY_TYPE.ECCP384, 253 | builder=cert, 254 | hash_algorithm=d.hashes.SHA384, 255 | ) 256 | with yubikey_one(YUBIKEY.INTERMEDIATE).open_connection( 257 | d.SmartCardConnection 258 | ) as conn: 259 | logger.debug("Store certificate") 260 | piv = d.PivSession(conn) 261 | piv.authenticate(management_key) 262 | piv.put_certificate(d.SLOT.SIGNATURE, signed_cert, compress=True) 263 | 264 | 265 | @certificate.command("sign") 266 | @click_pin(YUBIKEY.INTERMEDIATE) 267 | @click.option( 268 | "--subject-name", 269 | help="Subject name", 270 | type=click.STRING, 271 | ) 272 | @click.option( 273 | "--days", 274 | default=365, 275 | help="Certificate validity in days", 276 | type=click.IntRange(min=1), 277 | ) 278 | @click.option( 279 | "--csr-file", help="CSR file to sign", type=click.File("rt"), default=sys.stdin 280 | ) 281 | @click.option( 282 | "--out-file", 283 | help="Output file for signed certificate", 284 | type=click.File("wt"), 285 | default=sys.stdout, 286 | ) 287 | def certificate_sign( 288 | pin: str, 289 | subject_name: str, 290 | days: int, 291 | csr_file: typing.TextIO, 292 | out_file: typing.TextIO, 293 | ) -> None: 294 | """Sign a certificate request with the intermediate certificate. 295 | 296 | If no subject name is provided, the one from the CSR is used. 297 | """ 298 | from . import dependencies as d 299 | 300 | logger.debug("load CSR file and check signature") 301 | csr = d.x509.load_pem_x509_csr(csr_file.read().encode("ascii")) 302 | public_key = csr.public_key() 303 | if ( 304 | isinstance(public_key, d.rsa.RSAPublicKey) 305 | and csr.signature_hash_algorithm is not None 306 | ): 307 | public_key.verify( 308 | csr.signature, 309 | csr.tbs_certrequest_bytes, 310 | d.padding.PKCS1v15(), 311 | csr.signature_hash_algorithm, 312 | ) 313 | elif isinstance(public_key, d.ec.EllipticCurvePublicKey): 314 | if csr.signature_hash_algorithm is None: 315 | raise ValueError("No hash algorithm in CSR") 316 | public_key.verify( 317 | csr.signature, 318 | csr.tbs_certrequest_bytes, 319 | d.ec.ECDSA(csr.signature_hash_algorithm), 320 | ) 321 | else: 322 | raise ValueError(f"unsupported public key {public_key}") 323 | 324 | with yubikey_one(YUBIKEY.INTERMEDIATE).open_connection( 325 | d.SmartCardConnection 326 | ) as conn: 327 | piv = d.PivSession(conn) 328 | intermediate = piv.get_certificate(d.SLOT.SIGNATURE) 329 | if ( 330 | intermediate.issuer.rfc4514_string() 331 | == intermediate.subject.rfc4514_string() 332 | ): 333 | raise RuntimeError( 334 | "The inserted key does not look like an intermediate YubiKey!" 335 | ) 336 | 337 | logger.debug("build certificate") 338 | issuer = intermediate.subject 339 | if not subject_name: 340 | subject = csr.subject 341 | else: 342 | subject = d.x509.Name.from_rfc4514_string(subject_name) 343 | missing = [ 344 | attribute 345 | for attribute in issuer 346 | if attribute.oid not in [attr.oid for attr in subject] 347 | ] 348 | subject = d.x509.Name(missing + [attr for attr in subject]) 349 | logger.info(f"Subject name is {subject.rfc4514_string()}") 350 | cert = ( 351 | d.x509.CertificateBuilder() 352 | .subject_name(subject) 353 | .issuer_name(issuer) 354 | .public_key(public_key) 355 | .serial_number(d.x509.random_serial_number()) 356 | .not_valid_before(datetime.now(timezone.utc)) 357 | .not_valid_after(datetime.now(timezone.utc) + timedelta(days=days)) 358 | ) 359 | for extension in csr.extensions: 360 | logger.debug(f"Add extension {extension.value}") 361 | cert = cert.add_extension(extension.value, extension.critical) 362 | # TODO: it would be useful to display the certificate, but there seems 363 | # to be no method for that. 364 | click.confirm("Sign this certificate?", abort=True) 365 | 366 | piv.verify_pin(pin) 367 | signed_cert = d.sign_certificate_builder( 368 | piv, 369 | slot=d.SLOT.SIGNATURE, 370 | key_type=d.KEY_TYPE.ECCP384, 371 | builder=cert, 372 | hash_algorithm=d.hashes.SHA384, 373 | ) 374 | out_file.write( 375 | signed_cert.public_bytes(encoding=d.serialization.Encoding.PEM).decode( 376 | "ascii" 377 | ) 378 | ) 379 | -------------------------------------------------------------------------------- /src/pki/dependencies.py: -------------------------------------------------------------------------------- 1 | from ykman.device import list_all_devices 2 | from ykman.piv import ( 3 | pivman_change_pin, 4 | pivman_set_mgm_key, 5 | sign_certificate_builder, 6 | ) 7 | from yubikit.core import TRANSPORT 8 | from yubikit.core.smartcard import SmartCardConnection, ApduError, SW 9 | from yubikit.management import ManagementSession, DeviceConfig, CAPABILITY 10 | from yubikit.piv import ( 11 | PivSession, 12 | SLOT, 13 | MANAGEMENT_KEY_TYPE, 14 | PIN_POLICY, 15 | TOUCH_POLICY, 16 | KEY_TYPE, 17 | ) 18 | 19 | from cryptography import x509 20 | from cryptography.x509.oid import NameOID 21 | from cryptography.hazmat.primitives import hashes, serialization 22 | from cryptography.hazmat.primitives.asymmetric import ec, rsa, padding 23 | -------------------------------------------------------------------------------- /src/pki/yubikey.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import warnings 3 | import re 4 | import secrets 5 | import time 6 | import click 7 | from enum import StrEnum, unique 8 | 9 | 10 | DEFAULT_PIN = "123456" 11 | DEFAULT_PUK = "12345678" 12 | DEFAULT_MANAGEMENT = bytes.fromhex("010203040506070801020304050607080102030405060708") 13 | 14 | logger = logging.getLogger("offline-pki.yubikey") 15 | 16 | 17 | @unique 18 | class YUBIKEY(StrEnum): 19 | ROOT = "Root X" 20 | INTERMEDIATE = "Intermediate" 21 | 22 | 23 | def validate_pin(ctx, param, value): 24 | if not re.match(r"[0-9]{6,8}", value): 25 | raise click.BadParameter("PIN should be 6 to 8 numeric string") 26 | return value 27 | 28 | 29 | def validate_puk(ctx, param, value): 30 | if not re.match(r"[a-zA-Z0-9]{6,8}", value): 31 | raise click.BadParameter("PUK should be 6 to 8 alphanumeric string") 32 | return value 33 | 34 | 35 | def validate_management_key(ctx, param, val): 36 | try: 37 | if val == ".": 38 | val = secrets.token_bytes(32) 39 | logger.warning(f"Using random management key: {val.hex()}") 40 | return val 41 | if type(val) is str: 42 | val = bytes.fromhex(val) 43 | if len(val) == 32: 44 | return val 45 | except ValueError: 46 | pass 47 | raise click.BadParameter( 48 | "Management key must be exactly 32 (64 hexadecimal digits) long." 49 | ) 50 | 51 | 52 | def yubikey_one(yk: YUBIKEY): 53 | """Get exactly one YubiKey.""" 54 | from . import dependencies as d 55 | 56 | click.pause(f'Plug YubiKey "{yk}"...') 57 | devices = list(d.list_all_devices()) 58 | for nb, di in enumerate(devices): 59 | device, info = di 60 | logger.info(f"{nb: >2}: {device.fingerprint}") 61 | logger.info(f"SN: {info.serial}") 62 | if len(devices) == 1: 63 | return device 64 | if len(devices) == 0: 65 | raise RuntimeError("No YubiKey found!") 66 | raise RuntimeError("Too many YubiKeys found!") 67 | 68 | 69 | def click_pin(yk: str): 70 | return click.option( 71 | "--pin", 72 | prompt=f"PIN code for {yk}", 73 | help=f"PIN code for {yk}", 74 | hide_input=True, 75 | type=click.STRING, 76 | callback=validate_pin, 77 | ) 78 | 79 | 80 | def click_management_key(yk: str): 81 | return click.option( 82 | "--management-key", 83 | prompt=f"Management key for {yk}", 84 | help=f"Management key for {yk}", 85 | hide_input=True, 86 | type=click.UNPROCESSED, 87 | callback=validate_management_key, 88 | ) 89 | 90 | 91 | @click.group() 92 | def yubikey() -> None: 93 | """YubiKey management.""" 94 | 95 | 96 | @yubikey.command("info") 97 | def yubikey_info() -> None: 98 | """Display information about the inserted YubiKeys.""" 99 | from . import dependencies as d 100 | 101 | for nb, di in enumerate(d.list_all_devices()): 102 | device, info = di 103 | logger.info(f"{nb: >2}: {device.fingerprint}") 104 | logger.info(f"SN: {info.serial}") 105 | logger.info(f"Version: {info.version}") 106 | with device.open_connection(d.SmartCardConnection) as conn: 107 | piv = d.PivSession(conn) 108 | logger.info(f"Slot {d.SLOT.SIGNATURE}:") 109 | try: 110 | data = piv.get_slot_metadata(d.SLOT.SIGNATURE) 111 | except d.ApduError as e: 112 | if e.sw == d.SW.REFERENCE_DATA_NOT_FOUND: 113 | logger.info(" Empty") 114 | continue 115 | logger.info(f" Private key type: {data.key_type}") 116 | cert = piv.get_certificate(d.SLOT.SIGNATURE) 117 | pkey = data.public_key 118 | algorithm = pkey.curve.name 119 | issuer = cert.issuer.rfc4514_string() 120 | subject = cert.subject.rfc4514_string() 121 | serial = cert.serial_number 122 | not_before = cert.not_valid_before_utc.isoformat() 123 | not_after = cert.not_valid_after_utc.isoformat() 124 | pem_data = cert.public_bytes(encoding=d.serialization.Encoding.PEM).decode( 125 | "utf-8" 126 | ) 127 | logger.info(" Public key: ") 128 | logger.info(f" Algorithm: {algorithm}") 129 | logger.info(f" Issuer: {issuer}") 130 | logger.info(f" Subject: {subject}") 131 | logger.info(f" Serial: {serial}") 132 | logger.info(f" Not before: {not_before}") 133 | logger.info(f" Not after: {not_after}") 134 | logger.info(f" PEM:\n{pem_data}") 135 | 136 | 137 | @yubikey.command("reset") 138 | @click.confirmation_option( 139 | prompt="This will reset the connected YubiKey. Are you sure?" 140 | ) 141 | @click.option( 142 | "--new-pin", 143 | prompt="New PIN code", 144 | help="PIN code", 145 | hide_input=True, 146 | confirmation_prompt=True, 147 | type=click.STRING, 148 | callback=validate_pin, 149 | ) 150 | @click.option( 151 | "--new-puk", 152 | prompt="New PUK code", 153 | help="PUK code", 154 | hide_input=True, 155 | confirmation_prompt=True, 156 | type=click.STRING, 157 | callback=validate_puk, 158 | ) 159 | @click.option( 160 | "--new-management-key", 161 | prompt="New management key ('.' to generate a random one)", 162 | help="Management key", 163 | hide_input=True, 164 | type=click.UNPROCESSED, 165 | callback=validate_management_key, 166 | ) 167 | def yubikey_reset(new_pin: str, new_puk: str, new_management_key: bytes) -> None: 168 | """Reset the inserted YubiKeys.""" 169 | from . import dependencies as d 170 | 171 | found = False 172 | for nb, di in enumerate(d.list_all_devices()): 173 | found = True 174 | device, info = di 175 | logger.info(f"{nb: >2}: {device.fingerprint}") 176 | logger.info(f"SN: {info.serial}") 177 | logger.info(f"Version: {info.version}") 178 | with device.open_connection(d.SmartCardConnection) as conn: 179 | mgt = d.ManagementSession(conn) 180 | logger.debug("Only enable PIV application") 181 | config = d.DeviceConfig({}, None, None, None) 182 | config.enabled_capabilities = { 183 | d.TRANSPORT.NFC: d.CAPABILITY(0), 184 | d.TRANSPORT.USB: d.CAPABILITY.PIV, 185 | } 186 | mgt.write_device_config(config, True, None, None) 187 | logger.debug("Wait a bit for YubiKey to reboot") 188 | for retry in reversed(range(5)): 189 | with warnings.catch_warnings(): 190 | warnings.filterwarnings("ignore", message="Failed opening device") 191 | time.sleep(1) 192 | for _, di2 in enumerate(d.list_all_devices()): 193 | device2, info2 = di2 194 | if info2.serial == info.serial: 195 | device = device2 196 | break 197 | else: 198 | if retry > 0: 199 | continue 200 | raise RuntimeError("no YubiKey found") 201 | break 202 | with device.open_connection(d.SmartCardConnection) as conn: 203 | piv = d.PivSession(conn) 204 | logger.debug("Reset PIV application") 205 | piv.reset() 206 | logger.debug("Set management key") 207 | piv.authenticate(DEFAULT_MANAGEMENT) 208 | d.pivman_set_mgm_key(piv, new_management_key, d.MANAGEMENT_KEY_TYPE.AES256) 209 | logger.debug("Set PIN and PUK code") 210 | piv.change_puk(DEFAULT_PUK, new_puk) 211 | d.pivman_change_pin(piv, DEFAULT_PIN, new_pin) 212 | logger.info("YubiKey reset successful!") 213 | if not found: 214 | raise RuntimeError("No YubiKey found!") 215 | --------------------------------------------------------------------------------