diff options
author | Christian Poessinger <christian@poessinger.com> | 2021-07-01 20:50:57 +0200 |
---|---|---|
committer | Christian Poessinger <christian@poessinger.com> | 2021-07-01 20:50:57 +0200 |
commit | 469e57398f3a9700fee210a94e57601f51466f43 (patch) | |
tree | 4b0b4e7e8ea68938511a62e990a7d1b24de1d7ee | |
parent | d565d4baffb930462f1a913d6f8a80111958a6f8 (diff) | |
parent | 30e4f083c98f93058c59f89e140819f7a3151f43 (diff) | |
download | vyos-1x-469e57398f3a9700fee210a94e57601f51466f43.tar.gz vyos-1x-469e57398f3a9700fee210a94e57601f51466f43.zip |
Merge branch 'pki_ipsec' of https://github.com/sarthurdev/vyos-1x into pki-cli
* 'pki_ipsec' of https://github.com/sarthurdev/vyos-1x:
pki: ipsec: T3642: Update migration script to account for file permission issues
pki: ipsec: T3642: Migrate IPSec to use PKI configuration
pki: T3642: New PKI config and management
-rw-r--r-- | data/configd-include.json | 1 | ||||
-rw-r--r-- | data/templates/ipsec/swanctl.conf.tmpl | 6 | ||||
-rw-r--r-- | data/templates/ipsec/swanctl/peer.tmpl | 2 | ||||
-rw-r--r-- | debian/control | 1 | ||||
-rw-r--r-- | interface-definitions/include/pki/ca-certificate.xml.i | 14 | ||||
-rw-r--r-- | interface-definitions/include/pki/certificate-key.xml.i | 12 | ||||
-rw-r--r-- | interface-definitions/include/pki/certificate.xml.i | 14 | ||||
-rw-r--r-- | interface-definitions/include/pki/private-key.xml.i | 30 | ||||
-rw-r--r-- | interface-definitions/include/pki/public-key.xml.i | 14 | ||||
-rw-r--r-- | interface-definitions/pki.xml.in | 203 | ||||
-rw-r--r-- | interface-definitions/vpn_ipsec.xml.in | 38 | ||||
-rw-r--r-- | op-mode-definitions/pki.xml.in | 281 | ||||
-rw-r--r-- | python/vyos/pki.py | 305 | ||||
-rw-r--r-- | python/vyos/util.py | 19 | ||||
-rw-r--r-- | smoketest/configs/pki-ipsec | 95 | ||||
-rwxr-xr-x | smoketest/scripts/cli/test_pki.py | 95 | ||||
-rwxr-xr-x | src/conf_mode/pki.py | 167 | ||||
-rwxr-xr-x | src/conf_mode/vpn_ipsec.py | 90 | ||||
-rwxr-xr-x | src/migration-scripts/ipsec/6-to-7 | 169 | ||||
-rwxr-xr-x | src/op_mode/pki.py | 662 |
20 files changed, 2149 insertions, 69 deletions
diff --git a/data/configd-include.json b/data/configd-include.json index ee939decd..2e6226097 100644 --- a/data/configd-include.json +++ b/data/configd-include.json @@ -32,6 +32,7 @@ "nat.py", "nat66.py", "ntp.py", +"pki.py", "policy.py", "policy-local-route.py", "protocols_bfd.py", diff --git a/data/templates/ipsec/swanctl.conf.tmpl b/data/templates/ipsec/swanctl.conf.tmpl index d082729cb..0ff08ee15 100644 --- a/data/templates/ipsec/swanctl.conf.tmpl +++ b/data/templates/ipsec/swanctl.conf.tmpl @@ -55,9 +55,9 @@ secrets { } {% elif peer_conf.authentication.mode == 'x509' %} private_{{ peer_conn_name }} { - file = {{ peer_conf.authentication.x509.key.file }} -{% if "password" in peer_conf.authentication.x509.key and peer_conf.authentication.x509.key.password %} - secret = "{{ peer_conf.authentication.x509.key.password}}" + file = {{ peer_conf.authentication.x509.certificate }}.pem +{% if peer_conf.authentication.x509.passphrase is defined %} + secret = "{{ peer_conf.authentication.x509.passphrase }}" {% endif %} } {% elif peer_conf.authentication.mode == 'rsa' and not ns.local_key_set %} diff --git a/data/templates/ipsec/swanctl/peer.tmpl b/data/templates/ipsec/swanctl/peer.tmpl index 68284d7d9..c97ac1f67 100644 --- a/data/templates/ipsec/swanctl/peer.tmpl +++ b/data/templates/ipsec/swanctl/peer.tmpl @@ -35,7 +35,7 @@ auth = {{ auth_type }} {% endif %} {% if peer_conf.authentication.mode == 'x509' %} - certs = {{ peer_conf.authentication.x509.cert_file }} + certs = {{ peer_conf.authentication.x509.certificate }}.pem {% elif peer_conf.authentication.mode == 'rsa' %} pubkeys = localhost.pub {% endif %} diff --git a/debian/control b/debian/control index 75e12ebf4..c0805804e 100644 --- a/debian/control +++ b/debian/control @@ -111,6 +111,7 @@ Depends: python3, python3-certbot-nginx, python3-crypt | python3-pycryptodome, + python3-cryptography, python3-flask, python3-hurry.filesize, python3-isc-dhcp-leases, diff --git a/interface-definitions/include/pki/ca-certificate.xml.i b/interface-definitions/include/pki/ca-certificate.xml.i new file mode 100644 index 000000000..14295a281 --- /dev/null +++ b/interface-definitions/include/pki/ca-certificate.xml.i @@ -0,0 +1,14 @@ +<!-- include start from pki/ca-certificate.xml.i --> +<leafNode name="ca-certificate"> + <properties> + <help>Certificate Authority in PKI configuration</help> + <valueHelp> + <format>CA name</format> + <description>Name of CA in PKI configuration</description> + </valueHelp> + <completionHelp> + <path>pki ca</path> + </completionHelp> + </properties> +</leafNode> +<!-- include end --> diff --git a/interface-definitions/include/pki/certificate-key.xml.i b/interface-definitions/include/pki/certificate-key.xml.i new file mode 100644 index 000000000..7f26d25c1 --- /dev/null +++ b/interface-definitions/include/pki/certificate-key.xml.i @@ -0,0 +1,12 @@ +<!-- include start from pki/certificate-key.xml.i --> +#include <include/pki/certificate.xml.i> +<leafNode name="passphrase"> + <properties> + <help>Private key passphrase</help> + <valueHelp> + <format>txt</format> + <description>Passphrase to decrypt the private key</description> + </valueHelp> + </properties> +</leafNode> +<!-- include end --> diff --git a/interface-definitions/include/pki/certificate.xml.i b/interface-definitions/include/pki/certificate.xml.i new file mode 100644 index 000000000..436aa90ba --- /dev/null +++ b/interface-definitions/include/pki/certificate.xml.i @@ -0,0 +1,14 @@ +<!-- include start from pki/certificate.xml.i --> +<leafNode name="certificate"> + <properties> + <help>Certificate in PKI configuration</help> + <valueHelp> + <format>cert name</format> + <description>Name of certificate in PKI configuration</description> + </valueHelp> + <completionHelp> + <path>pki certificate</path> + </completionHelp> + </properties> +</leafNode> +<!-- include end --> diff --git a/interface-definitions/include/pki/private-key.xml.i b/interface-definitions/include/pki/private-key.xml.i new file mode 100644 index 000000000..6099daa89 --- /dev/null +++ b/interface-definitions/include/pki/private-key.xml.i @@ -0,0 +1,30 @@ +<!-- include start from pki/private-key.xml.i --> +<node name="private"> + <properties> + <help>Private key</help> + </properties> + <children> + <leafNode name="key"> + <properties> + <help>Private key in PKI configuration</help> + <valueHelp> + <format>key name</format> + <description>Name of private key in PKI configuration</description> + </valueHelp> + <completionHelp> + <path>pki key-pair</path> + </completionHelp> + </properties> + </leafNode> + <leafNode name="passphrase"> + <properties> + <help>Private key passphrase</help> + <valueHelp> + <format>txt</format> + <description>Passphrase to decrypt the private key</description> + </valueHelp> + </properties> + </leafNode> + </children> +</node> +<!-- include end --> diff --git a/interface-definitions/include/pki/public-key.xml.i b/interface-definitions/include/pki/public-key.xml.i new file mode 100644 index 000000000..dfc6979fd --- /dev/null +++ b/interface-definitions/include/pki/public-key.xml.i @@ -0,0 +1,14 @@ +<!-- include start from pki/public-key.xml.i --> +<leafNode name="public-key"> + <properties> + <help>Public key in PKI configuration</help> + <valueHelp> + <format>key name</format> + <description>Name of public key in PKI configuration</description> + </valueHelp> + <completionHelp> + <path>pki key-pair</path> + </completionHelp> + </properties> +</leafNode> +<!-- include end --> diff --git a/interface-definitions/pki.xml.in b/interface-definitions/pki.xml.in new file mode 100644 index 000000000..4b082cbc4 --- /dev/null +++ b/interface-definitions/pki.xml.in @@ -0,0 +1,203 @@ +<?xml version="1.0"?> +<interfaceDefinition> + <node name="pki" owner="${vyos_conf_scripts_dir}/pki.py"> + <properties> + <help>VyOS PKI configuration</help> + </properties> + <children> + <tagNode name="ca"> + <properties> + <help>Certificate Authority</help> + </properties> + <children> + <leafNode name="certificate"> + <properties> + <help>CA certificate in PEM format</help> + </properties> + </leafNode> + <leafNode name="description"> + <properties> + <help>Description</help> + </properties> + </leafNode> + <node name="private"> + <properties> + <help>CA private key in PEM format</help> + </properties> + <children> + <leafNode name="key"> + <properties> + <help>CA private key in PEM format</help> + </properties> + </leafNode> + <leafNode name="password-protected"> + <properties> + <help>CA private key is password protected</help> + <valueless/> + </properties> + </leafNode> + </children> + </node> + <leafNode name="crl"> + <properties> + <help>Certificate revocation list in PEM format</help> + <multi/> + </properties> + </leafNode> + </children> + </tagNode> + <tagNode name="certificate"> + <properties> + <help>Certificate</help> + </properties> + <children> + <leafNode name="certificate"> + <properties> + <help>Certificate in PEM format</help> + </properties> + </leafNode> + <leafNode name="description"> + <properties> + <help>Description</help> + </properties> + </leafNode> + <node name="private"> + <properties> + <help>Certificate private key</help> + </properties> + <children> + <leafNode name="key"> + <properties> + <help>Certificate private key in PEM format</help> + </properties> + </leafNode> + <leafNode name="password-protected"> + <properties> + <help>Certificate private key is password protected</help> + <valueless/> + </properties> + </leafNode> + </children> + </node> + <leafNode name="revoke"> + <properties> + <help>If CA is present, this certificate will be included in generated CRLs</help> + <valueless/> + </properties> + </leafNode> + </children> + </tagNode> + <tagNode name="dh"> + <properties> + <help>Diffie-Hellman parameters</help> + </properties> + <children> + <leafNode name="parameters"> + <properties> + <help>DH parameters in PEM format</help> + </properties> + </leafNode> + </children> + </tagNode> + <tagNode name="key-pair"> + <properties> + <help>Public and private keys</help> + </properties> + <children> + <node name="public"> + <properties> + <help>Public key</help> + </properties> + <children> + <leafNode name="key"> + <properties> + <help>Public key in PEM format</help> + </properties> + </leafNode> + </children> + </node> + <node name="private"> + <properties> + <help>Private key</help> + </properties> + <children> + <leafNode name="key"> + <properties> + <help>Private key in PEM format</help> + </properties> + </leafNode> + <leafNode name="password-protected"> + <properties> + <help>Private key is password protected</help> + <valueless/> + </properties> + </leafNode> + </children> + </node> + </children> + </tagNode> + <node name="openvpn"> + <properties> + <help>OpenVPN keys</help> + </properties> + <children> + <tagNode name="shared-secret"> + <properties> + <help>OpenVPN shared secret key</help> + </properties> + <children> + <leafNode name="key"> + <properties> + <help>OpenVPN shared secret key data</help> + </properties> + </leafNode> + <leafNode name="version"> + <properties> + <help>OpenVPN shared secret key version</help> + </properties> + </leafNode> + </children> + </tagNode> + </children> + </node> + <node name="x509"> + <properties> + <help>X509 Settings</help> + </properties> + <children> + <node name="default"> + <properties> + <help>X509 Default Values</help> + </properties> + <children> + <leafNode name="country"> + <properties> + <help>Default country</help> + </properties> + <defaultValue>GB</defaultValue> + </leafNode> + <leafNode name="state"> + <properties> + <help>Default state</help> + </properties> + <defaultValue>Some-State</defaultValue> + </leafNode> + <leafNode name="locality"> + <properties> + <help>Default locality</help> + </properties> + <defaultValue>Some-City</defaultValue> + </leafNode> + <leafNode name="organization"> + <properties> + <help>Default organization</help> + </properties> + <defaultValue>VyOS</defaultValue> + </leafNode> + </children> + </node> + </children> + </node> + </children> + </node> +</interfaceDefinition> diff --git a/interface-definitions/vpn_ipsec.xml.in b/interface-definitions/vpn_ipsec.xml.in index 2031217ba..7b1b3a595 100644 --- a/interface-definitions/vpn_ipsec.xml.in +++ b/interface-definitions/vpn_ipsec.xml.in @@ -804,42 +804,8 @@ <help>X.509 certificate</help> </properties> <children> - #include <include/certificate.xml.i> - #include <include/certificate-ca.xml.i> - <leafNode name="crl-file"> - <properties> - <help>File containing the X.509 Certificate Revocation List (CRL)</help> - <valueHelp> - <format>txt</format> - <description>File in /config/auth</description> - </valueHelp> - </properties> - </leafNode> - <node name="key"> - <properties> - <help>Key file and password to open it</help> - </properties> - <children> - <leafNode name="file"> - <properties> - <help>File containing the private key for the X.509 certificate for this host</help> - <valueHelp> - <format>txt</format> - <description>File in /config/auth</description> - </valueHelp> - </properties> - </leafNode> - <leafNode name="password"> - <properties> - <help>Password that protects the private key</help> - <valueHelp> - <format>txt</format> - <description>Password that protects the private key</description> - </valueHelp> - </properties> - </leafNode> - </children> - </node> + #include <include/pki/certificate-key.xml.i> + #include <include/pki/ca-certificate.xml.i> </children> </node> </children> diff --git a/op-mode-definitions/pki.xml.in b/op-mode-definitions/pki.xml.in new file mode 100644 index 000000000..06b15eed4 --- /dev/null +++ b/op-mode-definitions/pki.xml.in @@ -0,0 +1,281 @@ +<?xml version="1.0"?> +<interfaceDefinition> + <node name="generate"> + <children> + <node name="pki"> + <properties> + <help>Generate PKI certificates and keys</help> + </properties> + <children> + <node name="ca"> + <properties> + <help>Generate CA certificate</help> + </properties> + <children> + <tagNode name="install"> + <properties> + <help>Commands for installing generated certificate into running configuration</help> + <completionHelp> + <list><CA name></list> + </completionHelp> + </properties> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action generate --ca "$5" --install</command> + </tagNode> + </children> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action generate --ca "noname"</command> + </node> + <node name="certificate"> + <properties> + <help>Generate certificate request</help> + </properties> + <children> + <node name="self-signed"> + <properties> + <help>Generate self-signed certificate</help> + </properties> + <children> + <tagNode name="install"> + <properties> + <help>Commands for installing generated self-signed certificate into running configuration</help> + <completionHelp> + <list><certificate name></list> + </completionHelp> + </properties> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action generate --certificate "$6" --self-sign --install</command> + </tagNode> + </children> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action generate --certificate "noname" --self-sign</command> + </node> + <tagNode name="sign"> + <properties> + <help>Sign generated certificate with specified CA certificate</help> + <completionHelp> + <path>pki ca</path> + </completionHelp> + </properties> + <children> + <tagNode name="install"> + <properties> + <help>Commands for installing generated certificate into running configuration</help> + <completionHelp> + <list><certificate name></list> + </completionHelp> + </properties> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action generate --certificate "$7" --sign "$5" --install</command> + </tagNode> + </children> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action generate --certificate "noname" --sign "$5"</command> + </tagNode> + <tagNode name="install"> + <properties> + <help>Commands for installing generated certificate private key into running configuration</help> + <completionHelp> + <list><certificate name></list> + </completionHelp> + </properties> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action generate --certificate "$5" --install</command> + </tagNode> + </children> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action generate --certificate "noname"</command> + </node> + <tagNode name="crl"> + <properties> + <help>Generate CRL for specified CA certificate</help> + <completionHelp> + <path>pki ca</path> + </completionHelp> + </properties> + <children> + <leafNode name="install"> + <properties> + <help>Commands for installing generated CRL into running configuration</help> + </properties> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action generate --crl "$4" --install</command> + </leafNode> + </children> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action generate --crl "$4"</command> + </tagNode> + <node name="dh"> + <properties> + <help>Generate DH parameters</help> + </properties> + <children> + <tagNode name="install"> + <properties> + <help>Commands for installing generated DH parameters into running configuration</help> + <completionHelp> + <list><DH name></list> + </completionHelp> + </properties> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action generate --dh "$5" --install</command> + </tagNode> + </children> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action generate --dh "noname"</command> + </node> + <node name="key-pair"> + <properties> + <help>Generate a key pair</help> + </properties> + <children> + <tagNode name="install"> + <properties> + <help>Commands for installing generated key pair into running configuration</help> + <completionHelp> + <list><key name></list> + </completionHelp> + </properties> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action generate --keypair "$5" --install</command> + </tagNode> + </children> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action generate --keypair "noname"</command> + </node> + <node name="openvpn"> + <properties> + <help>Generate OpenVPN keys</help> + </properties> + <children> + <node name="shared-secret"> + <properties> + <help>Generate OpenVPN shared secret key</help> + </properties> + <children> + <tagNode name="install"> + <properties> + <help>Commands for installing generated OpenVPN shared secret key into running configuration</help> + <completionHelp> + <list><key name></list> + </completionHelp> + </properties> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action generate --openvpn "$6" --install</command> + </tagNode> + </children> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action generate --openvpn "noname"</command> + </node> + </children> + </node> + <node name="ssh-key"> + <properties> + <help>Generate SSH key</help> + </properties> + <children> + <tagNode name="install"> + <properties> + <help>Commands for installing generated SSH key into running configuration</help> + <completionHelp> + <list><key name></list> + </completionHelp> + </properties> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action generate --ssh "$5" --install</command> + </tagNode> + </children> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action generate --ssh "noname"</command> + </node> + <node name="wireguard"> + <properties> + <help>Generate Wireguard keys</help> + </properties> + <children> + <node name="key-pair"> + <properties> + <help>Generate Wireguard key pair for use with server or peer</help> + </properties> + <children> + <tagNode name="install"> + <properties> + <help>Commands for installing generated Wireguard key into running configuration</help> + <completionHelp> + <list><interface> <peer></list> + </completionHelp> + </properties> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action generate --wireguard --key "$6" --install</command> + </tagNode> + </children> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action generate --wireguard --key "noname"</command> + </node> + <node name="pre-shared-key"> + <properties> + <help>Generate pre-shared key for use with a Wireguard peer</help> + </properties> + <children> + <tagNode name="install"> + <properties> + <help>Commands for installing generated Wireguard psk on specified peer into running configuration</help> + <completionHelp> + <list><peer></list> + </completionHelp> + </properties> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action generate --wireguard --psk "$6" --install</command> + </tagNode> + </children> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action generate --wireguard --psk "noname"</command> + </node> + </children> + </node> + </children> + </node> + </children> + </node> + <node name="show"> + <children> + <node name="pki"> + <properties> + <help>Show PKI certificates</help> + </properties> + <children> + <node name="ca"> + <properties> + <help>Show CA certificates</help> + </properties> + <children> + <leafNode name="name"> + <properties> + <help>Show CA certificate by name</help> + <completionHelp> + <path>pki ca</path> + </completionHelp> + </properties> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action show --ca "$5"</command> + </leafNode> + </children> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action show --ca "all"</command> + </node> + <node name="certificate"> + <properties> + <help>Show certificates</help> + </properties> + <children> + <leafNode name="name"> + <properties> + <help>Show certificate by name</help> + <completionHelp> + <path>pki certificate</path> + </completionHelp> + </properties> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action show --certificate "$5"</command> + </leafNode> + </children> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action show --certificate "all"</command> + </node> + <node name="crl"> + <properties> + <help>Show certificate revocation lists</help> + </properties> + <children> + <leafNode name="name"> + <properties> + <help>Show certificate revocation lists from specified CA</help> + <completionHelp> + <path>pki ca</path> + </completionHelp> + </properties> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action show --crl "$5"</command> + </leafNode> + </children> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action show --crl "all"</command> + </node> + </children> + <command>sudo ${vyos_op_scripts_dir}/pki.py --action show</command> + </node> + </children> + </node> +</interfaceDefinition> diff --git a/python/vyos/pki.py b/python/vyos/pki.py new file mode 100644 index 000000000..80efe26b2 --- /dev/null +++ b/python/vyos/pki.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import datetime + +from cryptography import x509 +from cryptography.exceptions import InvalidSignature +from cryptography.x509.extensions import ExtensionNotFound +from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID, ExtensionOID +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import dh +from cryptography.hazmat.primitives.asymmetric import dsa +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.asymmetric import rsa + +CERT_BEGIN='-----BEGIN CERTIFICATE-----\n' +CERT_END='\n-----END CERTIFICATE-----' +KEY_BEGIN='-----BEGIN PRIVATE KEY-----\n' +KEY_END='\n-----END PRIVATE KEY-----' +KEY_ENC_BEGIN='-----BEGIN ENCRYPTED PRIVATE KEY-----\n' +KEY_ENC_END='\n-----END ENCRYPTED PRIVATE KEY-----' +KEY_PUB_BEGIN='-----BEGIN PUBLIC KEY-----\n' +KEY_PUB_END='\n-----END PUBLIC KEY-----' +CRL_BEGIN='-----BEGIN X509 CRL-----\n' +CRL_END='\n-----END X509 CRL-----' +CSR_BEGIN='-----BEGIN CERTIFICATE REQUEST-----\n' +CSR_END='\n-----END CERTIFICATE REQUEST-----' +DH_BEGIN='-----BEGIN DH PARAMETERS-----\n' +DH_END='\n-----END DH PARAMETERS-----' + +# Print functions + +encoding_map = { + 'PEM': serialization.Encoding.PEM, + 'OpenSSH': serialization.Encoding.OpenSSH +} + +public_format_map = { + 'SubjectPublicKeyInfo': serialization.PublicFormat.SubjectPublicKeyInfo, + 'OpenSSH': serialization.PublicFormat.OpenSSH +} + +private_format_map = { + 'PKCS8': serialization.PrivateFormat.PKCS8, + 'OpenSSH': serialization.PrivateFormat.OpenSSH +} + +def encode_certificate(cert): + return cert.public_bytes(encoding=serialization.Encoding.PEM).decode('utf-8') + +def encode_public_key(cert, encoding='PEM', key_format='SubjectPublicKeyInfo'): + if encoding not in encoding_map: + encoding = 'PEM' + if key_format not in public_format_map: + key_format = 'SubjectPublicKeyInfo' + return cert.public_bytes( + encoding=encoding_map[encoding], + format=public_format_map[key_format]).decode('utf-8') + +def encode_private_key(private_key, encoding='PEM', key_format='PKCS8', passphrase=None): + if encoding not in encoding_map: + encoding = 'PEM' + if key_format not in private_format_map: + key_format = 'PKCS8' + encryption = serialization.NoEncryption() if not passphrase else serialization.BestAvailableEncryption(bytes(passphrase, 'utf-8')) + return private_key.private_bytes( + encoding=encoding_map[encoding], + format=private_format_map[key_format], + encryption_algorithm=encryption).decode('utf-8') + +def encode_dh_parameters(dh_parameters): + return dh_parameters.parameter_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.ParameterFormat.PKCS3).decode('utf-8') + +# EC Helper + +def get_elliptic_curve(size): + curve_func = None + name = f'SECP{size}R1' + if hasattr(ec, name): + curve_func = getattr(ec, name) + else: + curve_func = ec.SECP256R1() # Default to SECP256R1 + return curve_func() + +# Creation functions + +def create_private_key(key_type, key_size=None): + private_key = None + if key_type == 'rsa': + private_key = rsa.generate_private_key(public_exponent=65537, key_size=key_size) + elif key_type == 'dsa': + private_key = dsa.generate_private_key(key_size=key_size) + elif key_type == 'ec': + curve = get_elliptic_curve(key_size) + private_key = ec.generate_private_key(curve) + return private_key + +def create_certificate_request(subject, private_key): + subject_obj = x509.Name([ + x509.NameAttribute(NameOID.COUNTRY_NAME, subject['country']), + x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, subject['state']), + x509.NameAttribute(NameOID.LOCALITY_NAME, subject['locality']), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, subject['organization']), + x509.NameAttribute(NameOID.COMMON_NAME, subject['common_name'])]) + + return x509.CertificateSigningRequestBuilder() \ + .subject_name(subject_obj) \ + .sign(private_key, hashes.SHA256()) + +def create_certificate(cert_req, ca_cert, ca_private_key, valid_days=365, cert_type='server', is_ca=False): + ext_key_usage = [] + if is_ca: + ext_key_usage = [ExtendedKeyUsageOID.CLIENT_AUTH, ExtendedKeyUsageOID.SERVER_AUTH] + elif cert_type == 'client': + ext_key_usage = [ExtendedKeyUsageOID.CLIENT_AUTH] + elif cert_type == 'server': + ext_key_usage = [ExtendedKeyUsageOID.SERVER_AUTH] + + builder = x509.CertificateBuilder() \ + .subject_name(cert_req.subject) \ + .issuer_name(ca_cert.subject) \ + .public_key(cert_req.public_key()) \ + .serial_number(x509.random_serial_number()) \ + .not_valid_before(datetime.datetime.utcnow()) \ + .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=int(valid_days))) + + builder = builder.add_extension(x509.BasicConstraints(ca=is_ca, path_length=None), critical=True) + builder = builder.add_extension(x509.ExtendedKeyUsage(ext_key_usage), critical=True) + builder = builder.add_extension(x509.KeyUsage( + digital_signature=True, + content_commitment=False, + key_encipherment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=is_ca, + crl_sign=is_ca, + encipher_only=False, + decipher_only=False), critical=True) + + for ext in cert_req.extensions: + builder = builder.add_extension(ext, critical=False) + + return builder.sign(ca_private_key, hashes.SHA256()) + +def create_certificate_revocation_list(ca_cert, ca_private_key, serial_numbers=[]): + if not serial_numbers: + return False + + builder = x509.CertificateRevocationListBuilder() \ + .issuer_name(ca_cert.subject) \ + .last_update(datetime.datetime.today()) \ + .next_update(datetime.datetime.today() + datetime.timedelta(1, 0, 0)) + + for serial_number in serial_numbers: + revoked_cert = x509.RevokedCertificateBuilder() \ + .serial_number(serial_number) \ + .revocation_date(datetime.datetime.today()) \ + .build() + builder = builder.add_revoked_certificate(revoked_cert) + + return builder.sign(private_key=ca_private_key, algorithm=hashes.SHA256()) + +def create_dh_parameters(bits=2048): + if not bits or bits < 512: + print("Invalid DH parameter key size") + return False + + return dh.generate_parameters(generator=2, key_size=int(bits)) + +# Wrap functions + +def wrap_public_key(raw_data): + return KEY_PUB_BEGIN + raw_data + KEY_PUB_END + +def wrap_private_key(raw_data, passphrase=None): + return (KEY_ENC_BEGIN if passphrase else KEY_BEGIN) + raw_data + (KEY_ENC_END if passphrase else KEY_END) + +def wrap_certificate_request(raw_data): + return CSR_BEGIN + raw_data + CSR_END + +def wrap_certificate(raw_data): + return CERT_BEGIN + raw_data + CERT_END + +def wrap_crl(raw_data): + return CRL_BEGIN + raw_data + CRL_END + +def wrap_dh_parameters(raw_data): + return DH_BEGIN + raw_data + DH_END + +# Load functions + +def load_public_key(raw_data, wrap_tags=True): + if wrap_tags: + raw_data = wrap_public_key(raw_data) + + try: + return serialization.load_pem_public_key(bytes(raw_data, 'utf-8')) + except ValueError: + return False + +def load_private_key(raw_data, passphrase=None, wrap_tags=True): + if wrap_tags: + raw_data = wrap_private_key(raw_data, passphrase) + + if passphrase: + passphrase = bytes(passphrase, 'utf-8') + + try: + return serialization.load_pem_private_key(bytes(raw_data, 'utf-8'), password=passphrase) + except ValueError: + return False + +def load_certificate_request(raw_data, wrap_tags=True): + if wrap_tags: + raw_data = wrap_certificate_request(raw_data) + + try: + return x509.load_pem_x509_csr(bytes(raw_data, 'utf-8')) + except ValueError: + return False + +def load_certificate(raw_data, wrap_tags=True): + if wrap_tags: + raw_data = wrap_certificate(raw_data) + + try: + return x509.load_pem_x509_certificate(bytes(raw_data, 'utf-8')) + except ValueError: + return False + +def load_crl(raw_data, wrap_tags=True): + if wrap_tags: + raw_data = wrap_crl(raw_data) + + try: + return x509.load_pem_x509_crl(bytes(raw_data, 'utf-8')) + except ValueError: + return False + +def load_dh_parameters(raw_data, wrap_tags=True): + if wrap_tags: + raw_data = wrap_dh_parameters(raw_data) + + try: + return serialization.load_pem_parameters(bytes(raw_data, 'utf-8')) + except ValueError: + return False + +# Verify + +def is_ca_certificate(cert): + if not cert: + return False + + try: + ext = cert.extensions.get_extension_for_oid(ExtensionOID.BASIC_CONSTRAINTS) + return ext.value.ca + except ExtensionNotFound: + return False + +def verify_certificate(cert, ca_cert): + # Verify certificate was signed by specified CA + if ca_cert.subject != cert.issuer: + return False + + ca_public_key = ca_cert.public_key() + try: + if isinstance(ca_public_key, rsa.RSAPublicKeyWithSerialization): + ca_public_key.verify( + cert.signature, + cert.tbs_certificate_bytes, + padding=padding.PKCS1v15(), + algorithm=cert.signature_hash_algorithm) + elif isinstance(ca_public_key, dsa.DSAPublicKeyWithSerialization): + ca_public_key.verify( + cert.signature, + cert.tbs_certificate_bytes, + algorithm=cert.signature_hash_algorithm) + elif isinstance(ca_public_key, ec.EllipticCurvePublicKeyWithSerialization): + ca_public_key.verify( + cert.signature, + cert.tbs_certificate_bytes, + signature_algorithm=ec.ECDSA(cert.signature_hash_algorithm)) + else: + return False # We cannot verify it + return True + except InvalidSignature: + return False diff --git a/python/vyos/util.py b/python/vyos/util.py index 446285a1b..65c9c2f45 100644 --- a/python/vyos/util.py +++ b/python/vyos/util.py @@ -572,6 +572,25 @@ def wait_for_commit_lock(): while commit_in_progress(): sleep(1) +def ask_input(question, default='', numeric_only=False, valid_responses=[]): + question_out = question + if default: + question_out += f' (Default: {default})' + response = '' + while True: + response = input(question_out + ' ').strip() + if not response and default: + return default + if numeric_only: + if not response.isnumeric(): + print("Invalid value, try again.") + continue + response = int(response) + if valid_responses and response not in valid_responses: + print("Invalid value, try again.") + continue + break + return response def ask_yes_no(question, default=False) -> bool: """Ask a yes/no question via input() and return their answer.""" diff --git a/smoketest/configs/pki-ipsec b/smoketest/configs/pki-ipsec new file mode 100644 index 000000000..7708a3cdd --- /dev/null +++ b/smoketest/configs/pki-ipsec @@ -0,0 +1,95 @@ +interfaces { + dummy dum0 { + address 172.20.0.1/30 + } + ethernet eth0 { + address 192.168.150.1/24 + } +} +system { + config-management { + commit-revisions 100 + } + console { + device ttyS0 { + speed 115200 + } + } + host-name vyos + login { + user vyos { + authentication { + encrypted-password $6$2Ta6TWHd/U$NmrX0x9kexCimeOcYK1MfhMpITF9ELxHcaBU/znBq.X2ukQOj61fVI2UYP/xBzP4QtiTcdkgs7WOQMHWsRymO/ + plaintext-password "" + } + } + } + ntp { + server time1.vyos.net { + } + server time2.vyos.net { + } + server time3.vyos.net { + } + } + syslog { + global { + facility all { + level info + } + facility protocols { + level debug + } + } + } +} +vpn { + ipsec { + esp-group MyESPGroup { + proposal 1 { + encryption aes128 + hash sha1 + } + } + ike-group MyIKEGroup { + proposal 1 { + dh-group 2 + encryption aes128 + hash sha1 + } + } + ipsec-interfaces { + interface eth0 + } + site-to-site { + peer 192.168.150.2 { + authentication { + mode x509 + x509 { + ca-cert-file ovpn_test_ca.pem + cert-file ovpn_test_server.pem + key { + file ovpn_test_server.key + } + } + } + default-esp-group MyESPGroup + ike-group MyIKEGroup + local-address 192.168.150.1 + tunnel 0 { + local { + prefix 172.20.0.0/24 + } + remote { + prefix 172.21.0.0/24 + } + } + } + } + } +} + + +// Warning: Do not remove the following line. +// vyos-config-version: "bgp@1:broadcast-relay@1:cluster@1:config-management@1:conntrack@2:conntrack-sync@2:dhcp-relay@2:dhcp-server@5:dhcpv6-server@1:dns-forwarding@3:firewall@5:https@2:interfaces@22:ipoe-server@1:ipsec@6:isis@1:l2tp@3:lldp@1:mdns@1:nat@5:nat66@1:ntp@1:policy@1:pppoe-server@5:pptp@2:qos@1:quagga@9:rpki@1:salt@1:snmp@2:ssh@2:sstp@3:system@21:vrf@2:vrrp@2:vyos-accel-ppp@2:wanloadbalance@3:webproxy@2:zone-policy@1" +// Release version: 1.4-rolling-202106290839 diff --git a/smoketest/scripts/cli/test_pki.py b/smoketest/scripts/cli/test_pki.py new file mode 100755 index 000000000..60287a0b4 --- /dev/null +++ b/smoketest/scripts/cli/test_pki.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import unittest + +from base_vyostest_shim import VyOSUnitTestSHIM +from vyos.configsession import ConfigSession +from vyos.configsession import ConfigSessionError + +base_path = ['pki'] + +valid_ca_cert = 'MIIDgTCCAmmgAwIBAgIUeM0mATGs+sKF7ViBM6DEf9fQ19swDQYJKoZIhvcNAQELBQAwVzELMAkGA1UEBhMCR0IxEzARBgNVBAgMClNvbWUtU3RhdGUxEjAQBgNVBAcMCVNvbWUtQ2l0eTENMAsGA1UECgwEVnlPUzEQMA4GA1UEAwwHVnlPUyBDQTAeFw0yMTA2MjgxMzE2NDZaFw0yNjA2MjcxMzE2NDZaMFcxCzAJBgNVBAYTAkdCMRMwEQYDVQQIDApTb21lLVN0YXRlMRIwEAYDVQQHDAlTb21lLUNpdHkxDTALBgNVBAoMBFZ5T1MxEDAOBgNVBAMMB1Z5T1MgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDK98WwZIqgC6teHPSsyKLLRtboy55aisJN0D3iHJ8WGKkDmIrdCR2LI4J5C82ErfPOzl4Ck4vTmqh8wnuK/dhUxxzNdFJBMPHAe/E+UawYrubtJj5g8iHYowZJT5HQKnZbcqlPvl6EizA+etO48WGljKhpimj9/LVTp81+BtFNP4tJ/vOl+iqyJ0+PxiqQNDJgAF18meQRKaT9CcXycsciG9snMlB1tdOR7KDbi8lJ86lOi5ukPJaiMgWEu4UlyFVyHJ/68NvtwRhYerMoQquqDs21OXkOd8spZL6qEsxMeK8InedA7abPaxgxORpHguPQV4Ib5HBH9Chdb9zBMheZAgMBAAGjRTBDMA8GA1UdEwEB/wQFMAMBAf8wIAYDVR0lAQH/BBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOCAQEAbwJZifMEDbrKPQfGLp7ZA1muM728o4EYmmE79eWwH22wGMSZI7T2xr5zRlFLs+Jha917yQK4b5xBMjQRAJlHKjzNLJ+3XaGlnWjaTBJ2SC5YktrmXRAIS7PxTRk/r1bHs/D00+sEWewbFYr8Js4a1Cv4TksTNyjHx8pvphA+KIx/4qdojTslz+oH/cakUz0M9fh2B2xsO4bab5vX+LGLCK7jjeAL4Zyjf1hDyx+Ri79L5N8h4Q69fER4cIkW7KVKUOyjEg3N4ST56urdycmyq9bXFz5pRxuZLInA6RRToJrL8i0aPLJ6SyMujfREfjqOxdW5vyNF5/RkY+5Nz8JMgQ==' +valid_ca_private_key = 'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDK98WwZIqgC6teHPSsyKLLRtboy55aisJN0D3iHJ8WGKkDmIrdCR2LI4J5C82ErfPOzl4Ck4vTmqh8wnuK/dhUxxzNdFJBMPHAe/E+UawYrubtJj5g8iHYowZJT5HQKnZbcqlPvl6EizA+etO48WGljKhpimj9/LVTp81+BtFNP4tJ/vOl+iqyJ0+PxiqQNDJgAF18meQRKaT9CcXycsciG9snMlB1tdOR7KDbi8lJ86lOi5ukPJaiMgWEu4UlyFVyHJ/68NvtwRhYerMoQquqDs21OXkOd8spZL6qEsxMeK8InedA7abPaxgxORpHguPQV4Ib5HBH9Chdb9zBMheZAgMBAAECggEAa/CK5L0DcAvkrd9OS9lDokFhJ1qqM1KZ9NHrJyW7gP/KWow0RUqEuKtAxuj8+jOcdn4PRuV6tiUIt5iiJQ/MjYF6ktTqrZq+5nPDnzXGBTZ2vuXYxKvgThqczD4RuJfsa8O1wR/nmit/k6q0kCVmnakJI1+laHWNZRjXUs+DXcWbrUN5D4/5kyjvFilH1c8arfrO2O4DcwfX1zNbxicgYrGmjE5m6WCZKWdcgpBcIQShZfNATfXIEZ16WmDIFZnuOEUtFAzweR2ataLQNoyaTUeEe6g+ZDtUQIGKR/f0+Z4T/JMJfPX/vRn0l3nRJWWC7Okpa2xb0hVdBmS/op+TNQKBgQDvNGAkS4uUx8xw724kzCKQJRnzR80AQ6b2FoqRbAevWm+i0ntsCMyvCItAQS8Bw+9fgITvsmd9SdYPncMQZ1oQYPk5yso/SPUyuNPXtygDxUP1xS1yja5MObqyrq2O2EzcxiVxEHGlZMLTNxNA1tE8nF4c0nQpV/EfLtkQFnnUSwKBgQDZOA2hiLaiDlPj03S4UXDu6aUD2o07782CUKl6A331ZhH/8zGEiUvBKg8IG/2FyCHQDC0C6rbfoarAhrRGbDHKkDTKNmThTj+IYBkLt/5OATvqkEw8eL0nB+PY5JKH04/jE0F/YM/StUsgxvMCVhtp0u/d2Hq4V9skxah6oFbtKwKBgGEvs3wroWtyffLIpMSYl9Ze7Js2aekYk4ZahDQvYzPwl3jc8b5kGN1oqEMT+MhL1j7EFb7ZikiSLkGsBGvuwd3zuG6toNxzhQP1qkRzqvNVO5ZoZV2siMt5jQw6AlQON7RfYSj92F6tgKaWMuFeJibtFSO6se12SIY134U0zIzfAoGAQWF7yNkrj4+cdICbKzdoNKEiyAwqYpYFV2oL+OvAJ/L3DAEZMHla0eNk7t3t6yyX8NUZXz1imeFBUf25mVDLk9rf6NWCe8ZfnR6/qyVQaA47CJkyOSlmVa8sR4ZVDIkDUCflmP98zkE/QbhgQJ3GVo3lIPMdzQq0rVbJJU/Jmk0CgYEAtHRNaoKBsxKfb7N7ewlaMzwcULIORODjWM8MUXM+R50F/2uYMiTvpz6eIUVfXoFyQoioYI8kcDZ8NamiQIS7uZsHfKpgMDJkV3kOoZQusoDhasGQ0SOnxbz/y0XmNUtAePipH0jPY1SYUvWbvm2ya4aWVhBFly9hi2ZeHiVxVhk=' +valid_cert = 'MIIB9zCCAZygAwIBAgIUQ5G1nyASL/YsKGyLNGhRPPQyo4kwCgYIKoZIzj0EAwIwXjELMAkGA1UEBhMCR0IxEzARBgNVBAgMClNvbWUtU3RhdGUxEjAQBgNVBAcMCVNvbWUtQ2l0eTENMAsGA1UECgwEVnlPUzEXMBUGA1UEAwwOVnlPUyBUZXN0IENlcnQwHhcNMjEwNjI4MTMyNjIyWhcNMjIwNjI4MTMyNjIyWjBeMQswCQYDVQQGEwJHQjETMBEGA1UECAwKU29tZS1TdGF0ZTESMBAGA1UEBwwJU29tZS1DaXR5MQ0wCwYDVQQKDARWeU9TMRcwFQYDVQQDDA5WeU9TIFRlc3QgQ2VydDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABBsebIt+8rr2UysTpL8NnYUtmt47e3sC3H9IO8iI/N4uFrmGVgTLE2G+RDGzZgG/r7LviJSTuE9HX7wHLcIr0SmjODA2MAwGA1UdEwEB/wQCMAAwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwEwDgYDVR0PAQH/BAQDAgeAMAoGCCqGSM49BAMCA0kAMEYCIQD5xK5kdC3TJ7SZrBGvzIM7E7Cil/KZJUyQDR9eFNNZVQIhALg8DTfrwAawf8L+Ncjn/l2gd5cB0nGij0D7uYnm3zf/' +valid_dh_params = 'MIIBCAKCAQEAnNldZCrJk5MxhFoUlvvaYmUO+TmtL0uL62H2RIHJ+O0R+8vzdGPh6zDAzo46EJK735haUgu8+A1RTsXDOXcwBqDlVe0hYj9KaPHz1HpfNKntpoPCJAYJwiH8dd5zVMH+iBwEKlrfteV9vWHn0HUxgLJFSLp5o6y0qpKPREJu6k0XguGScrPaIw6RUwsoDy3unHfk+YeC0o040R18F75V1mXWTjQlEgM7ZO2JZkLGkhW30jB0vSHrkrFqOvtPUiyG7r3+j18IUYLTN0s+5FOCfCjvSVKibNlB1vUz5y/9Ve8roctpkRM/5R5FA0mtbl7U/yMSX4FRIQ/A9BlHiu4bowIBAg==' +valid_public_ec_key = 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAoInInwjlu/3+wDqvRa/Eyg3EMvBpPyq2v4jqEtEh2n4lOCi7ZgNjr+1sQSvrn8mccpALYl3/RKOougC5oQzCg==' +valid_private_rsa_key = 'MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDDoAVyJPpcLBFs2NdS1qMOSj7mwKBKVZiBN3nqbLiOvEHbVe22UMNvUFU3sGs2Ta2zXwhPF3d6vPPsGlYTkO3XAffMSNXhjCsvWHiIOR4JrWf598Bpt+txBsxsa12kM3/HM7RDf3zdN2gTtwzrcWzu+zOTXlqJ2OSq/BRRZO9IMbQLQ1/h42GJHEr4THnY4zDqUjmMmIuiBXn4xoE4KFLH1+xPTVleeKvPPeJ1wsshoUjlXYOgcsrXasDUt5gtkkXsVQwR9Lvbh+RcBhT+tJmrX9Cwq4YAd3tLSNJARS9HanRZ8uV0RTyZsImdw1Fr5ySpG2oEp/Z5mbL6QYqDmQ+DAgMBAAECggEAGu7qMQf0TEJo98J3CtmwQ2Rnep+ksfdM8uVvbJ4hXs1+h7Mx8jr2XVoDEZLBgA17z8lSvIjvkz92mdgaZ8E5bbPAqSiSAeapf3A/0AmFIDH2scyxehyvVrVn6blygAvzGLr+o5hm2ZIqSySVq8jHBbQiKrT/5CCvgvcH2Rj7dMXdT5lL73tCRJZsgvFNlxyj4Omj9Lh7SjL+tIwEQaLFbvANXrZ/BPyw4OlK8daBNg9b5GvJSDitAVMgDEEApGYu1iNwMM4UJSQAC27eJdr+qJO6DDqktWOyWcyXrxJ9mDVKFNbb9QNQZDj7bFfm6rCuSdH9yYe3vly+SNJqtyCiwQKBgQDvemt/57KiwQffmoKR65NAZsQvmA4PtELYOV8NPeYH1BZN/EPmCc74iELJdQPFDYy903aRJEPGt7jfqprdPexLwt73P/XiUjPrsbqgJqfF/EMiczxAktyW3xBt2lIWU1MUUmO1ps+ZZEg8Ks4eK/3+FWqbwZ8drDBUT9BthUA0oQKBgQDRHxU6bu938PGweFJcIG6U21nsYaWiwCiTLXA5vWZ+UEqz81BUye6tIcCDgeku3HvC/0ycvrBM9F4AZCjnnEvrAJHKl6e4j+C4IpghGQvRvQ9ihDs9JIHnaoUC1i8dE3ISbbp1r7CN+J/HnAC2OeECMJuffXdnkVWaxRdxU+9towKBgCwFVeNyJO00DI126o+GPVA2U9Pn4JXUbgEvMqDNgw5nVx5Iw/ZyUSBwc85yexnq7rcqOv5dKzRJK2u6AbOvoVMf5DqRAFL1B2RJDGRKFscXIwQfKLE6DeCR6oQ3AKXn9TqkFn4axsiMnZapy6/SKGNfbnRpOCWNNGkbLtYjC3VhAoGAN0kOZapaaM0sOEk3DOAOHBB5j4KpNYOztmU23Cz0YcR8W2KiBCh2jxLzQFEiAp+LoJu59156YX3hNB1GqySo9XHrGTJKxwJSmJucuHNUqphe7t6igqGaLkH89CkHv5oaeEDGIMLX3FC0fSMDFSnsEJYlLl8PKDRF+2rLrcxQ6h0CgYAZllNu8a7tE6cM6QsCILQnNjuLuZRX8/KYWRqBJxatwZXCcMe2jti1HKTVVVCyYffOFa1QcAjCPknAmAz80l3eg6a75NnEXo0J6YLAOOxd8fD2/HidhbceCmTF+3msidIzCsBidBkgn6V5TXx2IyMSxGsJxVHfSKeooUQn6q76sg==' + +class TestPKI(VyOSUnitTestSHIM.TestCase): + def setUp(self): + self.cli_delete(base_path) + + def tearDown(self): + self.cli_delete(base_path) + self.cli_commit() + + def test_valid_pki(self): + # Valid CA + self.cli_set(base_path + ['ca', 'smoketest', 'certificate', valid_ca_cert]) + self.cli_set(base_path + ['ca', 'smoketest', 'private', 'key', valid_ca_private_key]) + self.cli_set(base_path + ['ca', 'smoketest', 'private', 'type', 'rsa']) + + # Valid cert + self.cli_set(base_path + ['certificate', 'smoketest', 'certificate', valid_cert]) + + # Valid DH + self.cli_set(base_path + ['dh', 'smoketest', 'parameters', valid_dh_params]) + + # Valid public key + self.cli_set(base_path + ['key-pair', 'smoketest', 'public', 'key', valid_public_ec_key]) + self.cli_set(base_path + ['key-pair', 'smoketest', 'type', 'ec']) + + # Valid private key + self.cli_set(base_path + ['key-pair', 'smoketest1', 'private', 'key', valid_private_rsa_key]) + self.cli_set(base_path + ['key-pair', 'smoketest1', 'type', 'rsa']) + + self.cli_commit() + + def test_invalid_ca_valid_certificate(self): + self.cli_set(base_path + ['ca', 'smoketest', 'certificate', valid_cert]) + + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + def test_invalid_certificate(self): + self.cli_set(base_path + ['certificate', 'smoketest', 'certificate', 'invalidcertdata']) + + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + def test_invalid_public_key(self): + self.cli_set(base_path + ['key-pair', 'smoketest', 'public', 'key', 'invalidkeydata']) + self.cli_set(base_path + ['key-pair', 'smoketest', 'type', 'rsa']) + + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + def test_invalid_private_key(self): + self.cli_set(base_path + ['key-pair', 'smoketest', 'private', 'key', 'invalidkeydata']) + self.cli_set(base_path + ['key-pair', 'smoketest', 'type', 'rsa']) + + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + def test_invalid_dh_parameters(self): + self.cli_set(base_path + ['dh', 'smoketest', 'parameters', 'thisisinvalid']) + + with self.assertRaises(ConfigSessionError): + self.cli_commit() + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/src/conf_mode/pki.py b/src/conf_mode/pki.py new file mode 100755 index 000000000..ef1b57650 --- /dev/null +++ b/src/conf_mode/pki.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from sys import exit + +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.pki import is_ca_certificate +from vyos.pki import load_certificate +from vyos.pki import load_certificate_request +from vyos.pki import load_public_key +from vyos.pki import load_private_key +from vyos.pki import load_crl +from vyos.pki import load_dh_parameters +from vyos.util import ask_input +from vyos.xml import defaults +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['pki'] + if not conf.exists(base): + return None + + pki = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + + default_values = defaults(base) + pki = dict_merge(default_values, pki) + return pki + +def is_valid_certificate(raw_data): + # If it loads correctly we're good, or return False + return load_certificate(raw_data, wrap_tags=True) + +def is_valid_ca_certificate(raw_data): + # Check if this is a valid certificate with CA attributes + cert = load_certificate(raw_data, wrap_tags=True) + if not cert: + return False + return is_ca_certificate(cert) + +def is_valid_public_key(raw_data): + # If it loads correctly we're good, or return False + return load_public_key(raw_data, wrap_tags=True) + +def is_valid_private_key(raw_data, protected=False): + # If it loads correctly we're good, or return False + # With encrypted private keys, we always return true as we cannot ask for password to verify + if protected: + return True + return load_private_key(raw_data, passphrase=None, wrap_tags=True) + +def is_valid_crl(raw_data): + # If it loads correctly we're good, or return False + return load_crl(raw_data, wrap_tags=True) + +def is_valid_dh_parameters(raw_data): + # If it loads correctly we're good, or return False + return load_dh_parameters(raw_data, wrap_tags=True) + +def verify(pki): + if not pki: + return None + + if 'ca' in pki: + for name, ca_conf in pki['ca'].items(): + if 'certificate' in ca_conf: + if not is_valid_ca_certificate(ca_conf['certificate']): + raise ConfigError(f'Invalid certificate on CA certificate "{name}"') + + if 'private' in ca_conf and 'key' in ca_conf['private']: + private = ca_conf['private'] + protected = 'password_protected' in private + + if not is_valid_private_key(private['key'], protected): + raise ConfigError(f'Invalid private key on CA certificate "{name}"') + + if 'crl' in ca_conf: + ca_crls = ca_conf['crl'] + if isinstance(ca_crls, str): + ca_crls = [ca_crls] + + for crl in ca_crls: + if not is_valid_crl(crl): + raise ConfigError(f'Invalid CRL on CA certificate "{name}"') + + if 'certificate' in pki: + for name, cert_conf in pki['certificate'].items(): + if 'certificate' in cert_conf: + if not is_valid_certificate(cert_conf['certificate']): + raise ConfigError(f'Invalid certificate on certificate "{name}"') + + if 'private' in cert_conf and 'key' in cert_conf['private']: + private = cert_conf['private'] + protected = 'password_protected' in private + + if not is_valid_private_key(private['key'], protected): + raise ConfigError(f'Invalid private key on certificate "{name}"') + + if 'dh' in pki: + for name, dh_conf in pki['dh'].items(): + if 'parameters' in dh_conf: + if not is_valid_dh_parameters(dh_conf['parameters']): + raise ConfigError(f'Invalid DH parameters on "{name}"') + + if 'key_pair' in pki: + for name, key_conf in pki['key_pair'].items(): + if 'public' in key_conf and 'key' in key_conf['public']: + if not is_valid_public_key(key_conf['public']['key']): + raise ConfigError(f'Invalid public key on key-pair "{name}"') + + if 'private' in key_conf and 'key' in key_conf['private']: + private = key_conf['private'] + protected = 'password_protected' in private + if not is_valid_private_key(private['key'], protected): + raise ConfigError(f'Invalid private key on key-pair "{name}"') + + if 'x509' in pki: + if 'default' in pki['x509']: + default_values = pki['x509']['default'] + if 'country' in default_values: + country = default_values['country'] + if len(country) != 2 or not country.isalpha(): + raise ConfigError(f'Invalid default country value. Value must be 2 alpha characters.') + + return None + +def generate(pki): + if not pki: + return None + + return None + +def apply(pki): + if not pki: + return None + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py index a141fdddf..70b4c52e6 100755 --- a/src/conf_mode/vpn_ipsec.py +++ b/src/conf_mode/vpn_ipsec.py @@ -23,6 +23,10 @@ from vyos.config import Config from vyos.configdict import leaf_node_changed from vyos.configverify import verify_interface_exists from vyos.ifconfig import Interface +from vyos.pki import wrap_certificate +from vyos.pki import wrap_crl +from vyos.pki import wrap_public_key +from vyos.pki import wrap_private_key from vyos.template import ip_from_cidr from vyos.template import render from vyos.validate import is_ipv6_link_local @@ -113,6 +117,8 @@ def get_config(config=None): ipsec['interface_change'] = leaf_node_changed(conf, base + ['ipsec-interfaces', 'interface']) ipsec['l2tp_exists'] = conf.exists(['vpn', 'l2tp', 'remote-access', 'ipsec-settings']) ipsec['nhrp_exists'] = conf.exists(['protocols', 'nhrp', 'tunnel']) + ipsec['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) ipsec['rsa_keys'] = conf.get_config_dict(['vpn', 'rsa-keys'], key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) @@ -185,6 +191,24 @@ def get_dhcp_address(iface): return ip_from_cidr(address) return None +def verify_pki(pki, x509_conf): + if not pki or 'ca' not in pki or 'certificate' not in pki: + raise ConfigError(f'PKI is not configured') + + ca_cert_name = x509_conf['ca_certificate'] + cert_name = x509_conf['certificate'] + + if not dict_search(f'ca.{ca_cert_name}.certificate', ipsec['pki']): + raise ConfigError(f'Missing CA certificate on specified PKI CA certificate "{ca_cert_name}"') + + if not dict_search(f'certificate.{cert_name}.certificate', ipsec['pki']): + raise ConfigError(f'Missing certificate on specified PKI certificate "{cert_name}"') + + if not dict_search(f'certificate.{cert_name}.private.key', ipsec['pki']): + raise ConfigError(f'Missing private key on specified PKI certificate "{cert_name}"') + + return True + def verify(ipsec): if not ipsec: return None @@ -235,24 +259,12 @@ def verify(ipsec): if 'x509' not in peer_conf['authentication']: raise ConfigError(f"Missing x509 settings on site-to-site peer {peer}") - if 'key' not in peer_conf['authentication']['x509']: - raise ConfigError(f"Missing x509 key on site-to-site peer {peer}") - - if 'ca_cert_file' not in peer_conf['authentication']['x509'] or 'cert_file' not in peer_conf['authentication']['x509']: - raise ConfigError(f"Missing x509 settings on site-to-site peer {peer}") + x509 = peer_conf['authentication']['x509'] - if 'file' not in peer_conf['authentication']['x509']['key']: - raise ConfigError(f"Missing x509 key file on site-to-site peer {peer}") + if 'ca_certificate' not in x509 or 'certificate' not in x509: + raise ConfigError(f"Missing x509 certificates on site-to-site peer {peer}") - for key in ['ca_cert_file', 'cert_file', 'crl_file']: - if key in peer_conf['authentication']['x509']: - path = os.path.join(X509_PATH, peer_conf['authentication']['x509'][key]) - if not os.path.exists(path): - raise ConfigError(f"File not found for {key} on site-to-site peer {peer}") - - key_path = os.path.join(X509_PATH, peer_conf['authentication']['x509']['key']['file']) - if not os.path.exists(key_path): - raise ConfigError(f"Private key not found on site-to-site peer {peer}") + verify_pki(ipsec['pki'], x509) if peer_conf['authentication']['mode'] == 'rsa': if not verify_rsa_local_key(ipsec): @@ -318,6 +330,31 @@ def verify(ipsec): if ('local' in tunnel_conf and 'prefix' in tunnel_conf['local']) or ('remote' in tunnel_conf and 'prefix' in tunnel_conf['remote']): raise ConfigError(f"Local/remote prefix cannot be used with ESP transport mode on tunnel {tunnel} for site-to-site peer {peer}") +def generate_pki_files(pki, x509_conf): + ca_cert_name = x509_conf['ca_certificate'] + ca_cert_data = dict_search(f'ca.{ca_cert_name}.certificate', pki) + ca_cert_crls = dict_search(f'ca.{ca_cert_name}.crl', pki) or [] + crl_index = 1 + + cert_name = x509_conf['certificate'] + cert_data = dict_search(f'certificate.{cert_name}.certificate', pki) + key_data = dict_search(f'certificate.{cert_name}.private.key', pki) + protected = 'passphrase' in x509_conf + + with open(os.path.join(CA_PATH, f'{ca_cert_name}.pem'), 'w') as f: + f.write(wrap_certificate(ca_cert_data)) + + for crl in ca_cert_crls: + with open(os.path.join(CRL_PATH, f'{ca_cert_name}_{crl_index}.pem'), 'w') as f: + f.write(wrap_crl(crl)) + crl_index += 1 + + with open(os.path.join(CERT_PATH, f'{cert_name}.pem'), 'w') as f: + f.write(wrap_certificate(cert_data)) + + with open(os.path.join(KEY_PATH, f'{cert_name}.pem'), 'w') as f: + f.write(wrap_private_key(key_data, protected)) + def generate(ipsec): if not ipsec: for config_file in [ipsec_conf, ipsec_secrets, interface_conf, swanctl_conf]: @@ -336,25 +373,20 @@ def generate(ipsec): data['ciphers'] = {'ike': ike_ciphers, 'esp': esp_ciphers} data['rsa_local_key'] = verify_rsa_local_key(ipsec) + for path in [swanctl_dir, CERT_PATH, CA_PATH, CRL_PATH]: + if not os.path.exists(path): + os.mkdir(path, mode=0o755) + + if not os.path.exists(KEY_PATH): + os.mkdir(KEY_PATH, mode=0o700) + if 'site_to_site' in data and 'peer' in data['site_to_site']: for peer, peer_conf in ipsec['site_to_site']['peer'].items(): if peer in ipsec['dhcp_no_address']: continue if peer_conf['authentication']['mode'] == 'x509': - cert_file = os.path.join(X509_PATH, dict_search('authentication.x509.cert_file', peer_conf)) - copy_file(cert_file, CERT_PATH, True) - - key_file = os.path.join(X509_PATH, dict_search('authentication.x509.key.file', peer_conf)) - copy_file(key_file, KEY_PATH, True) - - ca_cert_file = os.path.join(X509_PATH, dict_search('authentication.x509.ca_cert_file', peer_conf)) - copy_file(ca_cert_file, CA_PATH, True) - - crl = dict_search('authentication.x509.crl_file', peer_conf) - if crl: - crl_file = os.path.join(X509_PATH, crl) - copy_file(crl_file, CRL_PATH, True) + generate_pki_files(ipsec['pki'], peer_conf['authentication']['x509']) local_ip = '' if 'local_address' in peer_conf: diff --git a/src/migration-scripts/ipsec/6-to-7 b/src/migration-scripts/ipsec/6-to-7 new file mode 100755 index 000000000..788a87095 --- /dev/null +++ b/src/migration-scripts/ipsec/6-to-7 @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# Migrate /config/auth certificates and keys into PKI configuration + +import os + +from sys import argv +from sys import exit + +from vyos.configtree import ConfigTree +from vyos.pki import load_certificate +from vyos.pki import load_crl +from vyos.pki import load_private_key +from vyos.pki import encode_certificate +from vyos.pki import encode_private_key +from vyos.util import run + +if (len(argv) < 1): + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +pki_base = ['pki'] +ipsec_site_base = ['vpn', 'ipsec', 'site-to-site', 'peer'] + +config = ConfigTree(config_file) +changes_made = False + +AUTH_DIR = '/config/auth' + +def wrapped_pem_to_config_value(pem): + return "".join(pem.strip().split("\n")[1:-1]) + +if config.exists(ipsec_site_base): + config.set(pki_base + ['ca']) + config.set_tag(pki_base + ['ca']) + + config.set(pki_base + ['certificate']) + config.set_tag(pki_base + ['certificate']) + + for peer in config.list_nodes(ipsec_site_base): + if not config.exists(ipsec_site_base + [peer, 'authentication', 'x509']): + continue + + changes_made = True + + peer_x509_base = ipsec_site_base + [peer, 'authentication', 'x509'] + pki_name = 'peer_' + peer.replace(".", "-") + + if config.exists(peer_x509_base + ['cert-file']): + cert_file = config.return_value(peer_x509_base + ['cert-file']) + cert_path = os.path.join(AUTH_DIR, cert_file) + cert = None + + if os.path.isfile(cert_path): + if not os.access(cert_path, os.R_OK): + run(f'sudo chmod 644 {cert_path}') + + with open(cert_path, 'r') as f: + cert_data = f.read() + cert = load_certificate(cert_data, wrap_tags=False) + + if cert: + cert_pem = encode_certificate(cert) + config.set(pki_base + ['certificate', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem)) + config.set(peer_x509_base + ['certificate'], value=pki_name) + else: + print(f'Failed to migrate certificate on peer "{peer}"') + + config.delete(peer_x509_base + ['cert-file']) + + if config.exists(peer_x509_base + ['ca-cert-file']): + ca_cert_file = config.return_value(peer_x509_base + ['ca-cert-file']) + ca_cert_path = os.path.join(AUTH_DIR, ca_cert_file) + ca_cert = None + + if os.path.isfile(ca_cert_path): + if not os.access(ca_cert_path, os.R_OK): + run(f'sudo chmod 644 {ca_cert_path}') + + with open(ca_cert_path, 'r') as f: + ca_cert_data = f.read() + ca_cert = load_certificate(ca_cert_data, wrap_tags=False) + + if ca_cert: + ca_cert_pem = encode_certificate(ca_cert) + config.set(pki_base + ['ca', pki_name, 'certificate'], value=wrapped_pem_to_config_value(ca_cert_pem)) + config.set(peer_x509_base + ['ca-certificate'], value=pki_name) + else: + print(f'Failed to migrate CA certificate on peer "{peer}"') + + config.delete(peer_x509_base + ['ca-cert-file']) + + if config.exists(peer_x509_base + ['crl-file']): + crl_file = config.return_value(peer_x509_base + ['crl-file']) + crl_path = os.path.join(AUTH_DIR, crl_file) + crl = None + + if os.path.isfile(crl_path): + if not os.access(crl_path, os.R_OK): + run(f'sudo chmod 644 {crl_path}') + + with open(crl_path, 'r') as f: + crl_data = f.read() + crl = load_crl(crl_data, wrap_tags=False) + + if crl: + crl_pem = encode_certificate(crl) + config.set(pki_base + ['ca', pki_name, 'crl'], value=wrapped_pem_to_config_value(crl_pem)) + else: + print(f'Failed to migrate CRL on peer "{peer}"') + + config.delete(peer_x509_base + ['crl-file']) + + if config.exists(peer_x509_base + ['key', 'file']): + key_file = config.return_value(peer_x509_base + ['key', 'file']) + key_passphrase = None + + if config.exists(peer_x509_base + ['key', 'password']): + key_passphrase = config.return_value(peer_x509_base + ['key', 'password']) + + key_path = os.path.join(AUTH_DIR, key_file) + key = None + + if os.path.isfile(key_path): + if not os.access(key_path, os.R_OK): + run(f'sudo chmod 644 {key_path}') + + with open(key_path, 'r') as f: + key_data = f.read() + key = load_private_key(key_data, passphrase=key_passphrase, wrap_tags=False) + + if key: + key_pem = encode_private_key(key, passphrase=key_passphrase) + config.set(pki_base + ['certificate', pki_name, 'private', 'key'], value=wrapped_pem_to_config_value(key_pem)) + + if key_passphrase: + config.set(pki_base + ['certificate', pki_name, 'private', 'password-protected']) + config.set(peer_x509_base + ['private-key-passphrase'], value=key_passphrase) + else: + print(f'Failed to migrate private key on peer "{peer}"') + + config.delete(peer_x509_base + ['key']) + +if changes_made: + try: + with open(file_name, 'w') as f: + f.write(config.to_string()) + except OSError as e: + print("Failed to save the modified config: {}".format(e)) + sys.exit(1) diff --git a/src/op_mode/pki.py b/src/op_mode/pki.py new file mode 100755 index 000000000..d99a432aa --- /dev/null +++ b/src/op_mode/pki.py @@ -0,0 +1,662 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import argparse +import os +import re +import sys +import tabulate + +from cryptography import x509 +from cryptography.x509.oid import ExtendedKeyUsageOID + +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.pki import encode_certificate, encode_public_key, encode_private_key, encode_dh_parameters +from vyos.pki import create_certificate, create_certificate_request, create_certificate_revocation_list +from vyos.pki import create_private_key +from vyos.pki import create_dh_parameters +from vyos.pki import load_certificate, load_certificate_request, load_private_key, load_crl +from vyos.pki import verify_certificate +from vyos.xml import defaults +from vyos.util import ask_input, ask_yes_no +from vyos.util import cmd + +CERT_REQ_END = '-----END CERTIFICATE REQUEST-----' + +# Helper Functions + +def get_default_values(): + # Fetch default x509 values + conf = Config() + base = ['pki', 'x509', 'default'] + x509_defaults = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + default_values = defaults(base) + return dict_merge(default_values, x509_defaults) + +def get_config_ca_certificate(name=None): + # Fetch ca certificates from config + conf = Config() + base = ['pki', 'ca'] + + if not conf.exists(base): + return False + + if name: + base = base + [name] + if not conf.exists(base + ['private', 'key']) or not conf.exists(base + ['certificate']): + return False + + return conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + +def get_config_certificate(name=None): + # Get certificates from config + conf = Config() + base = ['pki', 'certificate'] + + if not conf.exists(base): + return False + + if name: + base = base + [name] + if not conf.exists(base + ['private', 'key']) or not conf.exists(base + ['certificate']): + return False + + return conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + +def get_certificate_ca(cert, ca_certs): + # Find CA certificate for given certificate + for ca_name, ca_dict in ca_certs.items(): + if 'certificate' not in ca_dict: + continue + + ca_cert = load_certificate(ca_dict['certificate']) + + if not ca_cert: + continue + + if verify_certificate(cert, ca_cert): + return ca_name + return None + +def get_config_revoked_certificates(): + # Fetch revoked certificates from config + conf = Config() + base = ['pki', 'certificate'] + if not conf.exists(base): + return {} + + certificates = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + + return {cert: cert_dict for cert, cert_dict in certificates.items() if 'revoke' in cert_dict} + +def get_revoked_by_serial_numbers(serial_numbers=[]): + # Return serial numbers of revoked certificates + certs_out = [] + certs = get_config_certificate() + if certs: + for cert_name, cert_dict in certs.items(): + if 'certificate' not in cert_dict: + continue + + cert = load_certificate(cert_dict['certificate']) + if cert.serial_number in serial_numbers: + certs_out.append(cert_name) + else: + certs_out.append(str(cert.serial_number)[0:10] + '...') + return certs_out + +def install_certificate(name, cert='', private_key=None, key_type=None, key_passphrase=None, is_ca=False): + # Show conf commands for installing certificate + prefix = 'ca' if is_ca else 'certificate' + print("Configure mode commands to install:") + + if cert: + cert_pem = "".join(encode_certificate(cert).strip().split("\n")[1:-1]) + print("set pki %s %s certificate '%s'" % (prefix, name, cert_pem)) + + if private_key: + key_pem = "".join(encode_private_key(private_key, passphrase=key_passphrase).strip().split("\n")[1:-1]) + print("set pki %s %s private key '%s'" % (prefix, name, key_pem)) + if key_passphrase: + print("set pki %s %s private password-protected" % (prefix, name)) + +def install_crl(ca_name, crl): + # Show conf commands for installing crl + print("Configure mode commands to install CRL:") + crl_pem = "".join(encode_public_key(crl).strip().split("\n")[1:-1]) + print("set pki ca %s crl '%s'" % (ca_name, crl_pem)) + +def install_dh_parameters(name, params): + # Show conf commands for installing dh params + print("Configure mode commands to install DH parameters:") + dh_pem = "".join(encode_dh_parameters(params).strip().split("\n")[1:-1]) + print("set pki dh %s parameters '%s'" % (name, dh_pem)) + +def install_ssh_key(name, public_key, private_key, passphrase=None): + # Show conf commands for installing ssh key + key_openssh = encode_public_key(public_key, encoding='OpenSSH', key_format='OpenSSH') + username = os.getlogin() + type_key_split = key_openssh.split(" ") + print("Configure mode commands to install SSH key:") + print("set system login user %s authentication public-keys %s key '%s'" % (username, name, type_key_split[1])) + print("set system login user %s authentication public-keys %s type '%s'" % (username, name, type_key_split[0])) + print("") + print(encode_private_key(private_key, encoding='PEM', key_format='OpenSSH', passphrase=passphrase)) + +def install_keypair(name, key_type, private_key=None, public_key=None, passphrase=None): + # Show conf commands for installing key-pair + print("Configure mode commands to install key pair:") + + if public_key: + install_public_key = ask_yes_no('Do you want to install the public key?', default=True) + public_key_pem = encode_public_key(public_key) + + if install_public_key: + install_public_pem = "".join(public_key_pem.strip().split("\n")[1:-1]) + print("set pki key-pair %s public key '%s'" % (name, install_public_pem)) + else: + print("Public key:") + print(public_key_pem) + + if private_key: + install_private_key = ask_yes_no('Do you want to install the private key?', default=True) + private_key_pem = encode_private_key(private_key, passphrase=passphrase) + + if install_private_key: + install_private_pem = "".join(private_key_pem.strip().split("\n")[1:-1]) + print("set pki key-pair %s private key '%s'" % (name, install_private_pem)) + if passphrase: + print("set pki key-pair %s private password-protected" % (name,)) + else: + print("Private key:") + print(private_key_pem) + +def install_wireguard_key(name, private_key, public_key): + # Show conf commands for installing wireguard key pairs + is_interface = re.match(r'^wg[\d]+$', name) + + print("Configure mode commands to install key:") + if is_interface: + print("set interfaces wireguard %s private-key '%s'" % (name, private_key)) + print("") + print("Public key for use on peer configuration: " + public_key) + else: + print("set interfaces wireguard [INTERFACE] peer %s pubkey '%s'" % (name, public_key)) + print("") + print("Private key for use on peer configuration: " + private_key) + +def install_wireguard_psk(name, psk): + # Show conf commands for installing wireguard psk + print("set interfaces wireguard [INTERFACE] peer %s preshared-key '%s'" % (name, psk)) + +def ask_passphrase(): + passphrase = None + print("Note: If you plan to use the generated key on this router, do not encrypt the private key.") + if ask_yes_no('Do you want to encrypt the private key with a passphrase?'): + passphrase = ask_input('Enter passphrase:') + return passphrase + +# Generation functions + +def generate_private_key(): + key_type = ask_input('Enter private key type: [rsa, dsa, ec]', default='rsa', valid_responses=['rsa', 'dsa', 'ec']) + + size_valid = [] + size_default = 0 + + if key_type in ['rsa', 'dsa']: + size_default = 2048 + size_valid = [512, 1024, 2048, 4096] + elif key_type == 'ec': + size_default = 256 + size_valid = [224, 256, 384, 521] + + size = ask_input('Enter private key bits:', default=size_default, numeric_only=True, valid_responses=size_valid) + + return create_private_key(key_type, size), key_type + +def generate_certificate_request(private_key=None, key_type=None, return_request=False, name=None, install=False): + if not private_key: + private_key, key_type = generate_private_key() + + default_values = get_default_values() + subject = {} + subject['country'] = ask_input('Enter country code:', default=default_values['country']) + subject['state'] = ask_input('Enter state:', default=default_values['state']) + subject['locality'] = ask_input('Enter locality:', default=default_values['locality']) + subject['organization'] = ask_input('Enter organization name:', default=default_values['organization']) + subject['common_name'] = ask_input('Enter common name:', default='vyos.io') + + cert_req = create_certificate_request(subject, private_key) + + if return_request: + return cert_req + + passphrase = ask_passphrase() + + if not install: + print(encode_certificate(cert_req)) + print(encode_private_key(private_key, passphrase=passphrase)) + return None + + print("Certificate request:") + print(encode_public_key(cert_req) + "\n") + install_certificate(name, private_key=private_key, key_type=key_type, key_passphrase=passphrase, is_ca=False) + +def generate_certificate(cert_req, ca_cert, ca_private_key, is_ca=False): + valid_days = ask_input('Enter how many days certificate will be valid:', default='365' if not is_ca else '1825', numeric_only=True) + cert_type = None + if not is_ca: + cert_type = ask_input('Enter certificate type: (client, server)', default='server', valid_responses=['client', 'server']) + return create_certificate(cert_req, ca_cert, ca_private_key, valid_days, cert_type, is_ca) + +def generate_ca_certificate(name, install=False): + private_key, key_type = generate_private_key() + cert_req = generate_certificate_request(private_key, key_type, return_request=True) + cert = generate_certificate(cert_req, cert_req, private_key, is_ca=True) + passphrase = ask_passphrase() + + if not install: + print(encode_certificate(cert)) + print(encode_private_key(private_key, passphrase=passphrase)) + return None + + install_certificate(name, cert, private_key, key_type, key_passphrase=passphrase, is_ca=True) + +def generate_certificate_sign(name, ca_name, install=False): + ca_dict = get_config_ca_certificate(ca_name) + + if not ca_dict: + print(f"CA certificate or private key for '{ca_name}' not found") + return None + + ca_cert = load_certificate(ca_dict['certificate']) + + if not ca_cert: + print("Failed to load CA certificate, aborting") + return None + + ca_private = ca_dict['private'] + ca_private_passphrase = None + if 'password_protected' in ca_private: + ca_private_passphrase = ask_input('Enter CA private key passphrase:') + ca_private_key = load_private_key(ca_private['key'], passphrase=ca_private_passphrase) + + if not ca_private_key: + print("Failed to load CA private key, aborting") + return None + + private_key = None + key_type = None + + cert_req = None + if not ask_yes_no('Do you already have a certificate request?'): + private_key, key_type = generate_private_key() + cert_req = generate_certificate_request(private_key, key_type, return_request=True) + else: + print("Paste certificate request and press enter:") + lines = [] + curr_line = '' + while True: + curr_line = input().strip() + if not curr_line or curr_line == CERT_REQ_END: + break + lines.append(curr_line) + + if not lines: + print("Aborted") + return None + + wrap = lines[0].find('-----') < 0 # Only base64 pasted, add the CSR tags for parsing + cert_req = load_certificate_request("\n".join(lines), wrap) + + if not cert_req: + print("Invalid certificate request") + return None + + cert = generate_certificate(cert_req, ca_cert, ca_private_key, is_ca=False) + passphrase = ask_passphrase() + + if not install: + print(encode_certificate(cert)) + print(encode_private_key(private_key, passphrase=passphrase)) + return None + + install_certificate(name, cert, private_key, key_type, key_passphrase=passphrase, is_ca=False) + +def generate_certificate_selfsign(name, install=False): + private_key, key_type = generate_private_key() + cert_req = generate_certificate_request(private_key, key_type, return_request=True) + cert = generate_certificate(cert_req, cert_req, private_key, is_ca=False) + passphrase = ask_passphrase() + + if not install: + print(encode_certificate(cert)) + print(encode_private_key(private_key, passphrase=passphrase)) + return None + + install_certificate(name, cert, private_key=private_key, key_type=key_type, key_passphrase=passphrase, is_ca=False) + +def generate_certificate_revocation_list(ca_name, install=False): + ca_dict = get_config_ca_certificate(ca_name) + + if not ca_dict: + print(f"CA certificate or private key for '{ca_name}' not found") + return None + + ca_cert = load_certificate(ca_dict['certificate']) + + if not ca_cert: + print("Failed to load CA certificate, aborting") + return None + + ca_private = ca_dict['private'] + ca_private_passphrase = None + if 'password_protected' in ca_private: + ca_private_passphrase = ask_input('Enter CA private key passphrase:') + ca_private_key = load_private_key(ca_private['key'], passphrase=ca_private_passphrase) + + if not ca_private_key: + print("Failed to load CA private key, aborting") + return None + + revoked_certs = get_config_revoked_Certificates() + to_revoke = [] + + for cert_name, cert_dict in revoked_certs.items(): + if 'certificate' not in cert_dict: + continue + + cert_data = cert_dict['certificate'] + + try: + cert = load_certificate(cert_data) + + if cert.issuer == ca_cert.subject: + to_revoke.append(cert.serial_number) + except ValueError: + continue + + if not to_revoke: + print("No revoked certificates to add to the CRL") + return None + + crl = create_certificate_revocation_list(ca_cert, ca_private_key, to_revoke) + + if not crl: + print("Failed to create CRL") + return None + + if not install: + print(encode_certificate(crl)) + return None + + install_crl(ca_name, crl) + +def generate_ssh_keypair(name, install=False): + private_key, key_type = generate_private_key() + public_key = private_key.public_key() + passphrase = ask_passphrase() + + if not install: + print(encode_public_key(public_key, encoding='OpenSSH', key_format='OpenSSH')) + print("") + print(encode_private_key(private_key, encoding='PEM', key_format='OpenSSH', passphrase=passphrase)) + return None + + install_ssh_key(name, public_key, private_key, passphrase) + +def generate_dh_parameters(name, install=False): + bits = ask_input('Enter DH parameters key size:', default=2048, numeric_only=True) + + print("Generating parameters...") + + dh_params = create_dh_parameters(bits) + if not dh_params: + print("Failed to create DH parameters") + return None + + if not install: + print("DH Parameters:") + print(encode_dh_parameters(dh_params)) + + install_dh_parameters(name, dh_params) + +def generate_keypair(name, install=False): + private_key, key_type = generate_private_key() + public_key = private_key.public_key() + passphrase = ask_passphrase() + + if not install: + print(encode_public_key(public_key)) + print("") + print(encode_private_key(private_key, passphrase=passphrase)) + return None + + install_keypair(name, key_type, private_key, public_key, passphrase) + +def generate_openvpn_key(name, install=False): + result = cmd('openvpn --genkey secret /dev/stdout | grep -o "^[^#]*"') + + if not result: + print("Failed to generate OpenVPN key") + return None + + if not install: + print(result) + return None + + key_lines = result.split("\n") + key_data = "".join(key_lines[1:-1]) # Remove wrapper tags and line endings + key_version = '1' + + version_search = re.search(r'BEGIN OpenVPN Static key V(\d+)', result) # Future-proofing (hopefully) + if version_search: + key_version = version_search[1] + + print("Configure mode commands to install OpenVPN key:") + print("set pki openvpn shared-secret %s key '%s'" % (name, key_data)) + print("set pki openvpn shared-secret %s version '%s'" % (name, key_version)) + +def generate_wireguard_key(name, install=False): + private_key = cmd('wg genkey') + public_key = cmd('wg pubkey', input=private_key) + + if not install: + print("Private key: " + private_key) + print("Public key: " + public_key) + return None + + install_wireguard_key(name, private_key, public_key) + +def generate_wireguard_psk(name, install=False): + psk = cmd('wg genpsk') + + if not install: + print("Pre-shared key:") + print(psk) + return None + + install_wireguard_psk(name, psk) + +# Show functions + +def show_certificate_authority(name=None): + headers = ['Name', 'Subject', 'Issued', 'Expiry', 'Private Key'] + data = [] + certs = get_config_ca_certificate() + if certs: + for cert_name, cert_dict in certs.items(): + if name and name != cert_name: + continue + if 'certificate' not in cert_dict: + continue + + cert = load_certificate(cert_dict['certificate']) + + if not cert: + continue + + have_private = 'Yes' if 'private' in cert_dict and 'key' in cert_dict['private'] else 'No' + data.append([cert_name, cert.subject.rfc4514_string(), cert.not_valid_before, cert.not_valid_after, have_private]) + + print("Certificate Authorities:") + print(tabulate.tabulate(data, headers)) + +def show_certificate(name=None): + headers = ['Name', 'Type', 'Subject CN', 'Issuer CN', 'Issued', 'Expiry', 'Revoked', 'Private Key', 'CA Present'] + data = [] + certs = get_config_certificate() + if certs: + ca_certs = get_config_ca_certificate() + + for cert_name, cert_dict in certs.items(): + if name and name != cert_name: + continue + if 'certificate' not in cert_dict: + continue + + cert = load_certificate(cert_dict['certificate']) + + if not cert: + continue + + ca_name = get_certificate_ca(cert, ca_certs) + cert_subject_cn = cert.subject.rfc4514_string().split(",")[0] + cert_issuer_cn = cert.issuer.rfc4514_string().split(",")[0] + cert_type = 'Unknown' + ext = cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage) + if ext and ExtendedKeyUsageOID.SERVER_AUTH in ext.value: + cert_type = 'Server' + elif ext and ExtendedKeyUsageOID.CLIENT_AUTH in ext.value: + cert_type = 'Client' + + revoked = 'Yes' if 'revoke' in cert_dict else 'No' + have_private = 'Yes' if 'private' in cert_dict and 'key' in cert_dict['private'] else 'No' + have_ca = f'Yes ({ca_name})' if ca_name else 'No' + data.append([ + cert_name, cert_type, cert_subject_cn, cert_issuer_cn, + cert.not_valid_before, cert.not_valid_after, + revoked, have_private, have_ca]) + + print("Certificates:") + print(tabulate.tabulate(data, headers)) + +def show_crl(name=None): + headers = ['CA Name', 'Updated', 'Revokes'] + data = [] + certs = get_config_ca_certificate() + if certs: + for cert_name, cert_dict in certs.items(): + if name and name != cert_name: + continue + if 'crl' not in cert_dict: + continue + + crls = cert_dict['crl'] + if isinstance(crls, str): + crls = [crls] + + for crl_data in cert_dict['crl']: + crl = load_crl(crl_data) + + if not crl: + continue + + certs = get_revoked_by_serial_numbers([revoked.serial_number for revoked in crl]) + data.append([cert_name, crl.last_update, ", ".join(certs)]) + + print("Certificate Revocation Lists:") + print(tabulate.tabulate(data, headers)) + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--action', help='PKI action', required=True) + + # X509 + parser.add_argument('--ca', help='Certificate Authority', required=False) + parser.add_argument('--certificate', help='Certificate', required=False) + parser.add_argument('--crl', help='Certificate Revocation List', required=False) + parser.add_argument('--sign', help='Sign certificate with specified CA', required=False) + parser.add_argument('--self-sign', help='Self-sign the certificate', action='store_true') + + # SSH + parser.add_argument('--ssh', help='SSH Key', required=False) + + # DH + parser.add_argument('--dh', help='DH Parameters', required=False) + + # Key pair + parser.add_argument('--keypair', help='Key pair', required=False) + + # OpenVPN + parser.add_argument('--openvpn', help='OpenVPN TLS key', required=False) + + # Wireguard + parser.add_argument('--wireguard', help='Wireguard', action='store_true') + parser.add_argument('--key', help='Wireguard key pair', required=False) + parser.add_argument('--psk', help='Wireguard pre shared key', required=False) + + # Global + parser.add_argument('--install', help='Install generated keys into running-config', action='store_true') + + args = parser.parse_args() + + try: + if args.action == 'generate': + if args.ca: + generate_ca_certificate(args.ca, args.install) + elif args.certificate: + if args.sign: + generate_certificate_sign(args.certificate, args.sign, args.install) + elif args.self_sign: + generate_certificate_selfsign(args.certificate, args.install) + else: + generate_certificate_request(name=args.certificate, install=args.install) + elif args.crl: + generate_certificate_revocation_list(args.crl, args.install) + elif args.ssh: + generate_ssh_keypair(args.ssh, args.install) + elif args.dh: + generate_dh_parameters(args.dh, args.install) + elif args.keypair: + generate_keypair(args.keypair, args.install) + elif args.openvpn: + generate_openvpn_key(args.openvpn, args.install) + elif args.wireguard: + if args.key: + generate_wireguard_key(args.key, args.install) + elif args.psk: + generate_wireguard_psk(args.psk, args.install) + elif args.action == 'show': + if args.ca: + show_certificate_authority(None if args.ca == 'all' else args.ca) + elif args.certificate: + show_certificate(None if args.certificate == 'all' else args.certificate) + elif args.crl: + show_crl(None if args.crl == 'all' else args.crl) + else: + show_certificate_authority() + show_certificate() + show_crl() + except KeyboardInterrupt: + print("Aborted") + sys.exit(0) |