diff --git a/CLAUDE.md b/CLAUDE.md index bbd9e489..ac8f4bdd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ algo/ ### Current Versions (MUST maintain compatibility) ``` -ansible==9.2.0 # Stay within 9.x for stability +ansible==11.8.0 # Stay current to get latest security, performance and bugfixes jinja2~=3.1.6 # Security fix for CVE-2025-27516 netaddr==1.3.0 # Network address manipulation ``` @@ -76,7 +76,7 @@ Currently unpinned in `requirements.yml`, but key ones include: ```toml # pyproject.toml configuration [tool.ruff] -target-version = "py310" +target-version = "py311" line-length = 120 [tool.ruff.lint] diff --git a/main.yml b/main.yml index 16415ec3..18baae40 100644 --- a/main.yml +++ b/main.yml @@ -22,23 +22,29 @@ no_log: true register: ipaddr - - name: Set required ansible version as a fact + - name: Extract ansible version from requirements set_fact: - required_ansible_version: "{{ item | regex_replace('^ansible[\\s+]?(?P[=,>,<]+)[\\s+]?(?P\\d.\\d+(.\\d+)?)$', '{\"op\": \"\\g\",\"ver\"\ - : \"\\g\" }') }}" + ansible_requirement: "{{ item }}" when: '"ansible" in item' with_items: "{{ lookup('file', 'requirements.txt').splitlines() }}" + - name: Parse ansible version requirement + set_fact: + required_ansible_version: + op: "{{ ansible_requirement | regex_replace('^ansible\\s*([~>=<]+)\\s*.*$', '\\1') }}" + ver: "{{ ansible_requirement | regex_replace('^ansible\\s*[~>=<]+\\s*(\\d+\\.\\d+(?:\\.\\d+)?).*$', '\\1') }}" + when: ansible_requirement is defined + - name: Just get the list from default pip community.general.pip_package_info: register: pip_package_info - name: Verify Python meets Algo VPN requirements assert: - that: (ansible_python.version.major|string + '.' + ansible_python.version.minor|string) is version('3.8', '>=') + that: (ansible_python.version.major|string + '.' + ansible_python.version.minor|string) is version('3.11', '>=') msg: > Python version is not supported. - You must upgrade to at least Python 3.8 to use this version of Algo. + You must upgrade to at least Python 3.11 to use this version of Algo. See for more details - https://trailofbits.github.io/algo/troubleshooting.html#python-version-is-not-supported - name: Verify Ansible meets Algo VPN requirements diff --git a/pyproject.toml b/pyproject.toml index ee0e5d3c..475a5483 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,11 +2,11 @@ name = "algo" description = "Set up a personal IPSEC VPN in the cloud" version = "0.1.0" -requires-python = ">=3.10" +requires-python = ">=3.11" [tool.ruff] # Ruff configuration -target-version = "py310" +target-version = "py311" line-length = 120 [tool.ruff.lint] diff --git a/requirements.txt b/requirements.txt index a7428528..abfe8dc2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -ansible==9.13.0 +ansible==11.8.0 jinja2~=3.1.6 netaddr==1.3.0 diff --git a/requirements.yml b/requirements.yml index f1afedea..243a621c 100644 --- a/requirements.yml +++ b/requirements.yml @@ -1,6 +1,10 @@ --- collections: - name: ansible.posix + version: ">=2.1.0" - name: community.general + version: ">=11.1.0" - name: community.crypto + version: ">=3.0.3" - name: openstack.cloud + version: ">=2.4.1" diff --git a/roles/strongswan/defaults/main.yml b/roles/strongswan/defaults/main.yml index ad1b97af..0ffb344f 100644 --- a/roles/strongswan/defaults/main.yml +++ b/roles/strongswan/defaults/main.yml @@ -37,7 +37,7 @@ nameConstraints: >- ,permitted;IP:{{ ansible_default_ipv6['address'] }}/ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff ,excluded;IP:fc00:0:0:0:0:0:0:0/fe00:0:0:0:0:0:0:0,excluded;IP:fe80:0:0:0:0:0:0:0/ffc0:0:0:0:0:0:0:0,excluded;IP:2001:db8:0:0:0:0:0:0/ffff:fff8:0:0:0:0:0:0 {%- else -%} - ,excluded;IP:0:0:0:0:0:0:0:0/0:0:0:0:0:0:0:0 + ,excluded;IP:::/0 {%- endif -%} openssl_bin: openssl strongswan_enabled_plugins: diff --git a/roles/strongswan/tasks/openssl.yml b/roles/strongswan/tasks/openssl.yml index b40a1757..f0e29e82 100644 --- a/roles/strongswan/tasks/openssl.yml +++ b/roles/strongswan/tasks/openssl.yml @@ -15,13 +15,9 @@ recurse: true mode: "0700" with_items: - - ecparams - certs - - crl - - newcerts - private - public - - reqs - name: Ensure the config directories exist file: @@ -33,229 +29,176 @@ - apple - manual - - name: Ensure the files exist - file: - dest: "{{ ipsec_pki_path }}/{{ item }}" - state: touch - with_items: - - .rnd - - private/.rnd - - index.txt - - index.txt.attr - - serial + - name: Create private key with password protection + community.crypto.openssl_privatekey: + path: "{{ ipsec_pki_path }}/private/cakey.pem" + passphrase: "{{ CA_password }}" + type: ECC + curve: secp384r1 + mode: "0600" - - name: Generate the openssl server configs - template: - src: openssl.cnf.j2 - dest: "{{ ipsec_pki_path }}/openssl.cnf" + # CA certificate with name constraints to prevent certificate misuse (Issue #75) + - name: Create certificate signing request (CSR) for CA certificate with security constraints + community.crypto.openssl_csr_pipe: + privatekey_path: "{{ ipsec_pki_path }}/private/cakey.pem" + privatekey_passphrase: "{{ CA_password }}" + common_name: "{{ IP_subject_alt_name }}" + use_common_name_for_san: true + # Generate Subject Key Identifier for proper Authority Key Identifier creation + create_subject_key_identifier: true + basic_constraints: + - 'CA:TRUE' + - 'pathlen:0' # Prevents sub-CA creation - limits certificate chain depth if CA key compromised + basic_constraints_critical: true + key_usage: + - keyCertSign + - cRLSign + key_usage_critical: true + # CA restricted to VPN certificate issuance only + extended_key_usage: + - '1.3.6.1.5.5.7.3.17' # IPsec End Entity OID - VPN-specific usage + extended_key_usage_critical: true + # Name Constraints: Defense-in-depth security restricting certificate scope to prevent misuse + # Limits CA to only issue certificates for this specific VPN deployment's resources + name_constraints_permitted: >- + {{ [ + subjectAltName_type + ':' + IP_subject_alt_name + ('/255.255.255.255' if subjectAltName_type == 'IP' else ''), + 'DNS:' + openssl_constraint_random_id, # Per-deployment UUID prevents cross-deployment reuse + 'email:' + openssl_constraint_random_id # Unique email domain isolates certificate scope + ] + ( + ['IP:' + ansible_default_ipv6['address'] + '/128'] if ipv6_support else [] + ) }} + # Block public domains/networks to prevent certificate abuse for impersonation attacks + name_constraints_excluded: >- + {{ [ + 'DNS:.com', 'DNS:.org', 'DNS:.net', 'DNS:.gov', 'DNS:.edu', 'DNS:.mil', 'DNS:.int', # Public TLD exclusion + 'email:.com', 'email:.org', 'email:.net', 'email:.gov', 'email:.edu', 'email:.mil', 'email:.int', # Email domain exclusion + 'IP:10.0.0.0/255.0.0.0', 'IP:172.16.0.0/255.240.0.0', 'IP:192.168.0.0/255.255.0.0' # RFC 1918: prevents lateral movement + ] + ( + ['IP:fc00::/7', 'IP:fe80::/10', 'IP:2001:db8::/32'] if ipv6_support else ['IP:::/0'] # IPv6: ULA/link-local/doc ranges or all + ) }} + name_constraints_critical: true + register: ca_csr - - name: Build the CA pair - shell: > - umask 077; - {{ openssl_bin }} ecparam -name secp384r1 -out ecparams/secp384r1.pem && - {{ openssl_bin }} req -utf8 -new - -newkey ec:ecparams/secp384r1.pem - -config <(cat openssl.cnf <(printf "[basic_exts]\nsubjectAltName={{ subjectAltName }}")) - -keyout private/cakey.pem - -out cacert.pem -x509 -days 3650 - -batch - -passout pass:"{{ CA_password }}" && - touch {{ IP_subject_alt_name }}_ca_generated - args: - chdir: "{{ ipsec_pki_path }}" - creates: "{{ IP_subject_alt_name }}_ca_generated" - executable: bash + - name: Create self-signed CA certificate from CSR + community.crypto.x509_certificate: + path: "{{ ipsec_pki_path }}/cacert.pem" + csr_content: "{{ ca_csr.csr }}" + privatekey_path: "{{ ipsec_pki_path }}/private/cakey.pem" + privatekey_passphrase: "{{ CA_password }}" + provider: selfsigned + mode: "0644" - name: Copy the CA certificate copy: src: "{{ ipsec_pki_path }}/cacert.pem" dest: "{{ ipsec_config_path }}/manual/cacert.pem" - - name: Generate the serial number - shell: echo 01 > serial && touch serial_generated - args: - chdir: "{{ ipsec_pki_path }}" - creates: serial_generated - - # Generate server certificate with proper Subject Alternative Name (SAN) - # CRITICAL: Must use -extensions server_exts to include SAN extension. - # The SAN extension is required for modern certificate validation, - # especially on macOS/iOS clients connecting via IKEv2. - # Without SAN containing the server IP, clients will reject the certificate. - - name: Build the server pair - shell: > - umask 077; - {{ openssl_bin }} req -utf8 -new - -newkey ec:ecparams/secp384r1.pem - -config openssl.cnf - -keyout private/{{ IP_subject_alt_name }}.key - -out reqs/{{ IP_subject_alt_name }}.req -nodes - -passin pass:"{{ CA_password }}" - -subj "/CN={{ IP_subject_alt_name }}" -batch && - {{ openssl_bin }} ca -utf8 - -in reqs/{{ IP_subject_alt_name }}.req - -out certs/{{ IP_subject_alt_name }}.crt - -config openssl.cnf - -extensions server_exts - -days 3650 -batch - -passin pass:"{{ CA_password }}" - -subj "/CN={{ IP_subject_alt_name }}" && - touch certs/{{ IP_subject_alt_name }}_crt_generated - args: - chdir: "{{ ipsec_pki_path }}" - creates: certs/{{ IP_subject_alt_name }}_crt_generated - executable: bash - - - name: Generate client private keys and certificate requests (parallel) - shell: > - umask 077; - {{ openssl_bin }} req -utf8 -new - -newkey ec:ecparams/secp384r1.pem - -config <(cat openssl.cnf <(printf "[basic_exts]\nsubjectAltName={{ subjectAltName_USER }}")) - -keyout private/{{ item }}.key - -out reqs/{{ item }}.req -nodes - -passin pass:"{{ CA_password }}" - -subj "/CN={{ item }}" -batch && - touch reqs/{{ item }}_req_generated - args: - chdir: "{{ ipsec_pki_path }}" - creates: reqs/{{ item }}_req_generated - executable: bash - with_items: "{{ users }}" - async: 60 - poll: 0 + - name: Create private keys for users and server + community.crypto.openssl_privatekey: + path: "{{ ipsec_pki_path }}/private/{{ item }}.key" + type: ECC + curve: secp384r1 + mode: "0600" + with_items: + - "{{ users }}" + - "{{ IP_subject_alt_name }}" register: client_key_jobs - - name: Wait for client key generation to complete - async_status: - jid: "{{ item.ansible_job_id }}" - with_items: "{{ client_key_jobs.results }}" - register: client_key_results - until: client_key_results.finished - retries: 30 - delay: 2 - failed_when: > - client_key_results.failed or - (client_key_results.finished and client_key_results.rc != 0) + # Server certificate with SAN extension - required for modern Apple devices + - name: Create CSRs for server certificate with SAN + community.crypto.openssl_csr_pipe: + privatekey_path: "{{ ipsec_pki_path }}/private/{{ IP_subject_alt_name }}.key" + subject_alt_name: "{{ subjectAltName.split(',') }}" + common_name: "{{ IP_subject_alt_name }}" + # Add Basic Constraints to prevent certificate chain validation errors + basic_constraints: + - 'CA:FALSE' + basic_constraints_critical: false + key_usage: + - digitalSignature + - keyEncipherment + key_usage_critical: false + # Server auth EKU required for IKEv2 server certificates (Issue #75) + # NOTE: clientAuth deliberately excluded to prevent role confusion attacks + extended_key_usage: + - serverAuth # Server Authentication (RFC 5280) + - '1.3.6.1.5.5.7.3.17' # IPsec End Entity (RFC 4945) + extended_key_usage_critical: false + register: server_csr - - name: Display crypto generation failure details (if any) - debug: - msg: | - StrongSwan client key generation failed for user: {{ item.item }} - Error details: {{ item.stderr | default('No stderr available') }} - Return code: {{ item.rc | default('Unknown') }} - Command: {{ item.cmd | default('Unknown command') }} - - Common causes: - - Insufficient entropy (try installing haveged or rng-tools) - - Disk space issues in /opt/algo - - Permission problems with OpenSSL CA database - - OpenSSL configuration errors - - Troubleshooting: - - Check disk space: df -h /opt/algo - - Check entropy: cat /proc/sys/kernel/random/entropy_avail - - Verify OpenSSL config: openssl version -a - when: item.rc is defined and item.rc != 0 - with_items: "{{ client_key_results.results | default([]) }}" - failed_when: false - - - name: Sign client certificates (sequential - CA database locking required) - shell: > - umask 077; - {{ openssl_bin }} ca -utf8 - -in reqs/{{ item }}.req - -out certs/{{ item }}.crt - -config <(cat openssl.cnf <(printf "[basic_exts]\nsubjectAltName={{ subjectAltName_USER }}")) - -days 3650 -batch - -passin pass:"{{ CA_password }}" - -subj "/CN={{ item }}" && - touch certs/{{ item }}_crt_generated - args: - chdir: "{{ ipsec_pki_path }}" - creates: certs/{{ item }}_crt_generated - executable: bash - with_items: "{{ users }}" - - - name: Build the tests pair - shell: > - umask 077; - {{ openssl_bin }} req -utf8 -new - -newkey ec:ecparams/secp384r1.pem - -config <(cat openssl.cnf <(printf "[basic_exts]\nsubjectAltName=DNS:google-algo-test-pair.com")) - -keyout private/google-algo-test-pair.com.key - -out reqs/google-algo-test-pair.com.req -nodes - -passin pass:"{{ CA_password }}" - -subj "/CN=google-algo-test-pair.com" -batch && - {{ openssl_bin }} ca -utf8 - -in reqs/google-algo-test-pair.com.req - -out certs/google-algo-test-pair.com.crt - -config <(cat openssl.cnf <(printf "[basic_exts]\nsubjectAltName=DNS:google-algo-test-pair.com")) - -days 3650 -batch - -passin pass:"{{ CA_password }}" - -subj "/CN=google-algo-test-pair.com" && - touch certs/google-algo-test-pair.com_crt_generated - args: - chdir: "{{ ipsec_pki_path }}" - creates: certs/google-algo-test-pair.com_crt_generated - executable: bash - when: tests|default(false)|bool - - - name: Build openssh public keys - openssl_publickey: - path: "{{ ipsec_pki_path }}/public/{{ item }}.pub" + - name: Create CSRs for client certificates + community.crypto.openssl_csr_pipe: privatekey_path: "{{ ipsec_pki_path }}/private/{{ item }}.key" - format: OpenSSH + subject_alt_name: + - "email:{{ item }}@{{ openssl_constraint_random_id }}" # UUID domain prevents certificate reuse across deployments + common_name: "{{ item }}" + # Add Basic Constraints to client certificates for proper PKI validation + basic_constraints: + - 'CA:FALSE' + basic_constraints_critical: false + key_usage: + - digitalSignature + - keyEncipherment + key_usage_critical: false + # Client certs restricted to clientAuth only - prevents clients from impersonating the VPN server + # NOTE: serverAuth deliberately excluded to prevent server impersonation attacks + extended_key_usage: + - clientAuth # Client Authentication (RFC 5280) + - '1.3.6.1.5.5.7.3.17' # IPsec End Entity (RFC 4945) + extended_key_usage_critical: false + with_items: "{{ users }}" + register: client_csr_jobs + + - name: Sign server certificate with CA + community.crypto.x509_certificate: + csr_content: "{{ server_csr.csr }}" + path: "{{ ipsec_pki_path }}/certs/{{ IP_subject_alt_name }}.crt" + provider: ownca + ownca_path: "{{ ipsec_pki_path }}/cacert.pem" + ownca_privatekey_path: "{{ ipsec_pki_path }}/private/cakey.pem" + ownca_privatekey_passphrase: "{{ CA_password }}" + ownca_not_after: "+{{ certificate_validity_days }}d" + ownca_not_before: "-1d" + mode: "0644" + + - name: Sign client certificates with CA + community.crypto.x509_certificate: + csr_content: "{{ item.csr }}" + path: "{{ ipsec_pki_path }}/certs/{{ item.item }}.crt" + provider: ownca + ownca_path: "{{ ipsec_pki_path }}/cacert.pem" + ownca_privatekey_path: "{{ ipsec_pki_path }}/private/cakey.pem" + ownca_privatekey_passphrase: "{{ CA_password }}" + ownca_not_after: "+{{ certificate_validity_days }}d" + ownca_not_before: "-1d" + mode: "0644" + with_items: "{{ client_csr_jobs.results }}" + register: client_sign_results + + - name: Generate p12 files + community.crypto.openssl_pkcs12: + path: "{{ ipsec_pki_path }}/private/{{ item }}.p12" + friendly_name: "{{ item }}" + privatekey_path: "{{ ipsec_pki_path }}/private/{{ item }}.key" + certificate_path: "{{ ipsec_pki_path }}/certs/{{ item }}.crt" + passphrase: "{{ p12_export_password }}" + mode: "0600" + encryption_level: "compatibility2022" # Apple device compatibility with_items: "{{ users }}" - - name: Get OpenSSL version - shell: | - set -o pipefail - {{ openssl_bin }} version | - cut -f 2 -d ' ' - args: - executable: bash - register: ssl_version - run_once: true - - - name: Set OpenSSL version fact - set_fact: - openssl_version: "{{ ssl_version.stdout }}" - - - name: Build the client's p12 - shell: > - umask 077; - {{ openssl_bin }} pkcs12 - {{ (openssl_version is version('3', '>=')) | ternary('-legacy', '') }} - -in certs/{{ item }}.crt - -inkey private/{{ item }}.key - -export - -name {{ item }} - -out private/{{ item }}.p12 - -passout pass:"{{ p12_export_password }}" - args: - chdir: "{{ ipsec_pki_path }}" - executable: bash + - name: Generate p12 files with CA certificate included + community.crypto.openssl_pkcs12: + path: "{{ ipsec_pki_path }}/private/{{ item }}_ca.p12" + friendly_name: "{{ item }}" + privatekey_path: "{{ ipsec_pki_path }}/private/{{ item }}.key" + certificate_path: "{{ ipsec_pki_path }}/certs/{{ item }}.crt" + other_certificates: + - "{{ ipsec_pki_path }}/cacert.pem" + passphrase: "{{ p12_export_password }}" + mode: "0600" + encryption_level: "compatibility2022" # Apple device compatibility with_items: "{{ users }}" - register: p12 - - - name: Build the client's p12 with the CA cert included - shell: > - umask 077; - {{ openssl_bin }} pkcs12 - {{ (openssl_version is version('3', '>=')) | ternary('-legacy', '') }} - -in certs/{{ item }}.crt - -inkey private/{{ item }}.key - -export - -name {{ item }} - -out private/{{ item }}_ca.p12 - -certfile cacert.pem - -passout pass:"{{ p12_export_password }}" - args: - chdir: "{{ ipsec_pki_path }}" - executable: bash - with_items: "{{ users }}" - register: p12 - name: Copy the p12 certificates copy: @@ -264,56 +207,65 @@ with_items: - "{{ users }}" - - name: Get active users - shell: | - set -o pipefail - grep ^V index.txt | - grep -v "{{ IP_subject_alt_name }}" | - awk '{print $5}' | - sed 's/\/CN=//g' - args: - executable: /bin/bash - chdir: "{{ ipsec_pki_path }}" - register: valid_certs + - name: Build openssh public keys + community.crypto.openssl_publickey: + path: "{{ ipsec_pki_path }}/public/{{ item }}.pub" + privatekey_path: "{{ ipsec_pki_path }}/private/{{ item }}.key" + format: OpenSSH + with_items: "{{ users }}" - - name: Revoke non-existing users - shell: > - {{ openssl_bin }} ca -gencrl - -config <(cat openssl.cnf <(printf "[basic_exts]\nsubjectAltName={{ subjectAltName_USER }}")) - -passin pass:"{{ CA_password }}" - -revoke certs/{{ item }}.crt - -out crl/{{ item }}.crt - register: gencrl - args: - chdir: "{{ ipsec_pki_path }}" - creates: crl/{{ item }}.crt - executable: bash - when: item.split('@')[0] not in users - with_items: "{{ valid_certs.stdout_lines }}" + - name: Add all users to the file + ansible.builtin.lineinfile: + path: "{{ ipsec_pki_path }}/all-users" + line: "{{ item }}" + create: true + with_items: "{{ users }}" + register: users_file - - name: Generate new CRL file - shell: > - {{ openssl_bin }} ca -gencrl - -config <(cat openssl.cnf <(printf "[basic_exts]\nsubjectAltName=DNS:{{ IP_subject_alt_name }}")) - -passin pass:"{{ CA_password }}" - -out crl/algo.root.pem - when: - - gencrl is defined - - gencrl.changed - args: - chdir: "{{ ipsec_pki_path }}" - executable: bash + - name: Set all users as a fact + set_fact: + all_users: "{{ lookup('file', ipsec_pki_path + '/all-users').splitlines() }}" + + # Certificate Revocation List (CRL) for removed users + - name: Calculate current timestamp for CRL + set_fact: + crl_timestamp: "{{ '%Y%m%d%H%M%SZ' | strftime(ansible_date_time.epoch | int) }}" + + - name: Identify users whose certificates need revocation + set_fact: + users_to_revoke: "{{ all_users | difference(users) }}" + + - name: Build revoked certificates list + set_fact: + revoked_certificates: >- + {{ users_to_revoke | map('regex_replace', '^(.*)$', + '{"path": "' + ipsec_pki_path + '/certs/\1.crt", "revocation_date": "' + crl_timestamp + '"}') | list }} + + - name: Generate a CRL + community.crypto.x509_crl: + path: "{{ ipsec_pki_path }}/crl.pem" + privatekey_path: "{{ ipsec_pki_path }}/private/cakey.pem" + privatekey_passphrase: "{{ CA_password }}" + last_update: "{{ '%Y%m%d%H%M%SZ' | strftime(ansible_date_time.epoch | int) }}" + next_update: "{{ '%Y%m%d%H%M%SZ' | strftime((ansible_date_time.epoch | int) + (10 * 365 * 24 * 60 * 60)) }}" + crl_mode: generate + issuer: + CN: "{{ IP_subject_alt_name }}" + revoked_certificates: "{{ revoked_certificates }}" + + - name: Set CRL file permissions + file: + path: "{{ ipsec_pki_path }}/crl.pem" + mode: "0644" delegate_to: localhost become: false vars: ansible_python_interpreter: "{{ ansible_playbook_python }}" + certificate_validity_days: 3650 # 10 years - configurable certificate lifespan - name: Copy the CRL to the vpn server copy: - src: "{{ ipsec_pki_path }}/crl/algo.root.pem" + src: "{{ ipsec_pki_path }}/crl.pem" dest: "{{ config_prefix|default('/') }}etc/ipsec.d/crls/algo.root.pem" - when: - - gencrl is defined - - gencrl.changed notify: - rereadcrls diff --git a/roles/strongswan/templates/openssl.cnf.j2 b/roles/strongswan/templates/openssl.cnf.j2 deleted file mode 100644 index 20801f94..00000000 --- a/roles/strongswan/templates/openssl.cnf.j2 +++ /dev/null @@ -1,161 +0,0 @@ -# For use with Easy-RSA 3.0 and OpenSSL 1.0.* - -RANDFILE = .rnd - -#################################################################### -[ ca ] -default_ca = CA_default # The default ca section - -#################################################################### -[ CA_default ] - -dir = . # Where everything is kept -certs = $dir # Where the issued certs are kept -crl_dir = $dir # Where the issued crl are kept -database = $dir/index.txt # database index file. -new_certs_dir = $dir/certs # default place for new certs. - -certificate = $dir/cacert.pem # The CA certificate -serial = $dir/serial # The current serial number -crl = $dir/crl.pem # The current CRL -private_key = $dir/private/cakey.pem # The private key -RANDFILE = $dir/private/.rand # private random number file - -x509_extensions = basic_exts # The extensions to add to the cert - -# This allows a V2 CRL. Ancient browsers don't like it, but anything Easy-RSA -# is designed for will. In return, we get the Issuer attached to CRLs. -crl_extensions = crl_ext - -default_days = 3650 # how long to certify for -default_crl_days= 3650 # how long before next CRL -default_md = sha256 # use public key default MD -preserve = no # keep passed DN ordering - -# A few difference way of specifying how similar the request should look -# For type CA, the listed attributes must be the same, and the optional -# and supplied fields are just that :-) -policy = policy_anything - -# For the 'anything' policy, which defines allowed DN fields -[ policy_anything ] -countryName = optional -stateOrProvinceName = optional -localityName = optional -organizationName = optional -organizationalUnitName = optional -commonName = supplied -name = optional -emailAddress = optional - -#################################################################### -# Easy-RSA request handling -# We key off $DN_MODE to determine how to format the DN -[ req ] -default_bits = 2048 -default_keyfile = privkey.pem -default_md = sha256 -distinguished_name = cn_only -x509_extensions = easyrsa_ca # The extensions to add to the self signed cert - -# A placeholder to handle the $EXTRA_EXTS feature: -#%EXTRA_EXTS% # Do NOT remove or change this line as $EXTRA_EXTS support requires it - -#################################################################### -# Easy-RSA DN (Subject) handling - -# Easy-RSA DN for cn_only support: -[ cn_only ] -commonName = Common Name (eg: your user, host, or server name) -commonName_max = 64 -commonName_default = {{ IP_subject_alt_name }} - -# Easy-RSA DN for org support: -[ org ] -countryName = Country Name (2 letter code) -countryName_default = US -countryName_min = 2 -countryName_max = 2 - -stateOrProvinceName = State or Province Name (full name) -stateOrProvinceName_default = California - -localityName = Locality Name (eg, city) -localityName_default = San Francisco - -0.organizationName = Organization Name (eg, company) -0.organizationName_default = Copyleft Certificate Co - -organizationalUnitName = Organizational Unit Name (eg, section) -organizationalUnitName_default = My Organizational Unit - -commonName = Common Name (eg: your user, host, or server name) -commonName_max = 64 -commonName_default = {{ IP_subject_alt_name }} - -emailAddress = Email Address -emailAddress_default = me@example.net -emailAddress_max = 64 - -#################################################################### -# Easy-RSA cert extension handling - -# This section is effectively unused as the main script sets extensions -# dynamically. This core section is left to support the odd usecase where -# a user calls openssl directly. -[ basic_exts ] -basicConstraints = CA:FALSE -subjectKeyIdentifier = hash -authorityKeyIdentifier = keyid,issuer:always - -# Client certificates should not have serverAuth -extendedKeyUsage = clientAuth,1.3.6.1.5.5.7.3.17 -keyUsage = digitalSignature, keyEncipherment - -# Server certificate extensions -# CRITICAL: The subjectAltName (SAN) extension is REQUIRED for modern clients, -# especially macOS/iOS which perform strict certificate validation for IKEv2. -# Without SAN, macOS clients will reject the certificate and fail to connect. -# The SAN must contain the server's IP address(es) that clients connect to. -[ server_exts ] -basicConstraints = CA:FALSE -subjectKeyIdentifier = hash -authorityKeyIdentifier = keyid,issuer:always - -# Server authentication for IKEv2 VPN connections -extendedKeyUsage = serverAuth,1.3.6.1.5.5.7.3.17 -keyUsage = digitalSignature, keyEncipherment - -# Subject Alternative Name extension -subjectAltName = {{ subjectAltName }} - -# The Easy-RSA CA extensions -[ easyrsa_ca ] - -# PKIX recommendations: - -subjectKeyIdentifier=hash -authorityKeyIdentifier=keyid:always,issuer:always - -basicConstraints = critical,CA:true,pathlen:0 -# Name constraints provide defense-in-depth security by restricting the scope of certificates -# this CA can issue, preventing misuse if the CA key is compromised -nameConstraints = {{ nameConstraints }} - -# Restrict CA to only sign VPN-related certificates -extendedKeyUsage = critical,serverAuth,clientAuth,1.3.6.1.5.5.7.3.17 - -# Limit key usage to CA tasks. If you really want to use the generated pair as -# a self-signed cert, comment this out. -keyUsage = cRLSign, keyCertSign - -# nsCertType omitted by default. Let's try to let the deprecated stuff die. -# nsCertType = sslCA - -# CRL extensions. -[ crl_ext ] - -# Only issuerAltName and authorityKeyIdentifier make any sense in a CRL. - -# issuerAltName=issuer:copy -authorityKeyIdentifier=keyid:always,issuer:always diff --git a/tests/unit/test_openssl_compatibility.py b/tests/unit/test_openssl_compatibility.py index 5bb991b0..4f70ccf0 100644 --- a/tests/unit/test_openssl_compatibility.py +++ b/tests/unit/test_openssl_compatibility.py @@ -1,17 +1,45 @@ #!/usr/bin/env python3 """ -Test OpenSSL compatibility - focused on version detection and legacy flag support +Test PKI certificate validation for Ansible-generated certificates +Hybrid approach: validates actual certificates when available, else tests templates/config Based on issues #14755, #14718 - Apple device compatibility +Issues #75, #153 - Security enhancements (name constraints, EKU restrictions) """ +import glob import os import re import subprocess import sys -import tempfile +from datetime import UTC +from cryptography import x509 +from cryptography.x509.oid import ExtensionOID, NameOID + + +def find_generated_certificates(): + """Find Ansible-generated certificate files in configs directory""" + # Look for configs directory structure created by Ansible + config_patterns = [ + "configs/*/ipsec/.pki/cacert.pem", + "../configs/*/ipsec/.pki/cacert.pem", # From tests/unit directory + "../../configs/*/ipsec/.pki/cacert.pem" # Alternative path + ] + + for pattern in config_patterns: + ca_certs = glob.glob(pattern) + if ca_certs: + base_path = os.path.dirname(ca_certs[0]) + return { + 'ca_cert': ca_certs[0], + 'base_path': base_path, + 'server_certs': glob.glob(f"{base_path}/certs/*.crt"), + 'p12_files': glob.glob(f"{base_path.replace('/.pki', '')}/manual/*.p12") + } + + return None def test_openssl_version_detection(): - """Test that we can detect OpenSSL version""" + """Test that we can detect OpenSSL version for compatibility checks""" result = subprocess.run( ['openssl', 'version'], capture_output=True, @@ -28,57 +56,457 @@ def test_openssl_version_detection(): minor = int(version_match.group(2)) print(f"✓ OpenSSL version detected: {major}.{minor}") - - # Return version for other tests return (major, minor) -def test_legacy_flag_support(): - """Test if OpenSSL supports -legacy flag (issue #14755)""" +def validate_ca_certificate_real(cert_files): + """Validate actual Ansible-generated CA certificate""" + # Read the actual CA certificate generated by Ansible + with open(cert_files['ca_cert'], 'rb') as f: + cert_data = f.read() + + certificate = x509.load_pem_x509_certificate(cert_data) + + # Check Basic Constraints + basic_constraints = certificate.extensions.get_extension_for_oid(ExtensionOID.BASIC_CONSTRAINTS).value + assert basic_constraints.ca is True, "CA certificate should have CA:TRUE" + assert basic_constraints.path_length == 0, "CA should have pathlen:0 constraint" + + # Check Key Usage + key_usage = certificate.extensions.get_extension_for_oid(ExtensionOID.KEY_USAGE).value + assert key_usage.key_cert_sign is True, "CA should have keyCertSign usage" + assert key_usage.crl_sign is True, "CA should have cRLSign usage" + + # Check Extended Key Usage (Issue #75) + eku = certificate.extensions.get_extension_for_oid(ExtensionOID.EXTENDED_KEY_USAGE).value + assert x509.oid.ExtendedKeyUsageOID.SERVER_AUTH in eku, "CA should allow signing server certificates" + assert x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH in eku, "CA should allow signing client certificates" + assert x509.ObjectIdentifier("1.3.6.1.5.5.7.3.17") in eku, "CA should have IPsec End Entity EKU" + + # Check Name Constraints (Issue #75) - defense against certificate misuse + name_constraints = certificate.extensions.get_extension_for_oid(ExtensionOID.NAME_CONSTRAINTS).value + assert name_constraints.permitted_subtrees is not None, "CA should have permitted name constraints" + assert name_constraints.excluded_subtrees is not None, "CA should have excluded name constraints" + + # Verify public domains are excluded + excluded_dns = [constraint.value for constraint in name_constraints.excluded_subtrees + if isinstance(constraint, x509.DNSName)] + public_domains = [".com", ".org", ".net", ".gov", ".edu", ".mil", ".int"] + for domain in public_domains: + assert domain in excluded_dns, f"CA should exclude public domain {domain}" + + # Verify private IP ranges are excluded (Issue #75) + excluded_ips = [constraint.value for constraint in name_constraints.excluded_subtrees + if isinstance(constraint, x509.IPAddress)] + assert len(excluded_ips) > 0, "CA should exclude private IP ranges" + + # Verify email domains are also excluded (Issue #153) + excluded_emails = [constraint.value for constraint in name_constraints.excluded_subtrees + if isinstance(constraint, x509.RFC822Name)] + email_domains = [".com", ".org", ".net", ".gov", ".edu", ".mil", ".int"] + for domain in email_domains: + assert domain in excluded_emails, f"CA should exclude email domain {domain}" + + print(f"✓ Real CA certificate has proper security constraints: {cert_files['ca_cert']}") + +def validate_ca_certificate_config(): + """Validate CA certificate configuration in Ansible files (CI mode)""" + # Check that the Ansible task file has proper CA certificate configuration + openssl_task_file = find_ansible_file('roles/strongswan/tasks/openssl.yml') + if not openssl_task_file: + print("⚠ Could not find openssl.yml task file") + return + + with open(openssl_task_file) as f: + content = f.read() + + # Verify key security configurations are present + security_checks = [ + ('name_constraints_permitted', 'Name constraints should be configured'), + ('name_constraints_excluded', 'Excluded name constraints should be configured'), + ('extended_key_usage', 'Extended Key Usage should be configured'), + ('1.3.6.1.5.5.7.3.17', 'IPsec End Entity OID should be present'), + ('serverAuth', 'Server authentication EKU should be present'), + ('clientAuth', 'Client authentication EKU should be present'), + ('basic_constraints', 'Basic constraints should be configured'), + ('CA:TRUE', 'CA certificate should be marked as CA'), + ('pathlen:0', 'Path length constraint should be set') + ] + + for check, message in security_checks: + assert check in content, f"Missing security configuration: {message}" + + # Verify public domains are excluded + public_domains = [".com", ".org", ".net", ".gov", ".edu", ".mil", ".int"] + for domain in public_domains: + # Handle both double quotes and single quotes in YAML + assert f'"DNS:{domain}"' in content or f"'DNS:{domain}'" in content, f"Public domain {domain} should be excluded" + + # Verify private IP ranges are excluded + private_ranges = ["10.0.0.0", "172.16.0.0", "192.168.0.0"] + for ip_range in private_ranges: + assert ip_range in content, f"Private IP range {ip_range} should be excluded" + + # Verify email domains are excluded (Issue #153) + email_domains = [".com", ".org", ".net", ".gov", ".edu", ".mil", ".int"] + for domain in email_domains: + # Handle both double quotes and single quotes in YAML + assert f'"email:{domain}"' in content or f"'email:{domain}'" in content, f"Email domain {domain} should be excluded" + + # Verify IPv6 constraints are present (Issue #153) + assert "IP:::/0" in content, "IPv6 all addresses should be excluded" + + print("✓ CA certificate configuration has proper security constraints") + +def test_ca_certificate(): + """Test CA certificate - uses real certs if available, else validates config (Issue #75, #153)""" + cert_files = find_generated_certificates() + if cert_files: + validate_ca_certificate_real(cert_files) + else: + validate_ca_certificate_config() + + +def validate_server_certificates_real(cert_files): + """Validate actual Ansible-generated server certificates""" + # Filter to only actual server certificates (not client certs) + # Server certificates contain IP addresses in the filename + import re + server_certs = [f for f in cert_files['server_certs'] + if not f.endswith('/cacert.pem') and re.search(r'\d+\.\d+\.\d+\.\d+\.crt$', f)] + if not server_certs: + print("⚠ No server certificates found") + return + + for server_cert_path in server_certs: + with open(server_cert_path, 'rb') as f: + cert_data = f.read() + + certificate = x509.load_pem_x509_certificate(cert_data) + + # Check it's not a CA certificate + basic_constraints = certificate.extensions.get_extension_for_oid(ExtensionOID.BASIC_CONSTRAINTS).value + assert basic_constraints.ca is False, "Server certificate should not be a CA" + + # Check Extended Key Usage (Issue #75) + eku = certificate.extensions.get_extension_for_oid(ExtensionOID.EXTENDED_KEY_USAGE).value + assert x509.oid.ExtendedKeyUsageOID.SERVER_AUTH in eku, "Server cert must have serverAuth EKU" + assert x509.ObjectIdentifier("1.3.6.1.5.5.7.3.17") in eku, "Server cert should have IPsec End Entity EKU" + # Security check: Server certificates should NOT have clientAuth to prevent role confusion (Issue #153) + assert x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH not in eku, "Server cert should NOT have clientAuth EKU for role separation" + + # Check SAN extension exists (required for Apple devices) + try: + san = certificate.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME).value + assert len(san) > 0, "Server certificate must have SAN extension for Apple device compatibility" + except x509.ExtensionNotFound: + assert False, "Server certificate missing SAN extension - required for modern clients" + + print(f"✓ Real server certificate valid: {os.path.basename(server_cert_path)}") + +def validate_server_certificates_config(): + """Validate server certificate configuration in Ansible files (CI mode)""" + openssl_task_file = find_ansible_file('roles/strongswan/tasks/openssl.yml') + if not openssl_task_file: + print("⚠ Could not find openssl.yml task file") + return + + with open(openssl_task_file) as f: + content = f.read() + + # Look for server certificate CSR section + server_csr_section = re.search(r'Create CSRs for server certificate.*?register: server_csr', content, re.DOTALL) + if not server_csr_section: + print("⚠ Could not find server certificate CSR section") + return + + server_section = server_csr_section.group(0) + + # Check server certificate CSR configuration + server_checks = [ + ('subject_alt_name', 'Server certificates should have SAN extension'), + ('serverAuth', 'Server certificates should have serverAuth EKU'), + ('1.3.6.1.5.5.7.3.17', 'Server certificates should have IPsec End Entity EKU'), + ('digitalSignature', 'Server certificates should have digital signature usage'), + ('keyEncipherment', 'Server certificates should have key encipherment usage') + ] + + for check, message in server_checks: + assert check in server_section, f"Missing server certificate configuration: {message}" + + # Security check: Server certificates should NOT have clientAuth (Issue #153) + # Look for clientAuth in extended_key_usage section, not in comments + eku_lines = [line for line in server_section.split('\n') if 'extended_key_usage:' in line or (line.strip().startswith('- ') and 'clientAuth' in line)] + has_client_auth = any('clientAuth' in line for line in eku_lines if line.strip().startswith('- ')) + assert not has_client_auth, "Server certificates should NOT have clientAuth EKU for role separation" + + # Verify SAN extension is configured for Apple compatibility + assert 'subjectAltName' in server_section, "Server certificates missing SAN configuration for Apple compatibility" + + print("✓ Server certificate configuration has proper EKU and SAN settings") + +def test_server_certificates(): + """Test server certificates - uses real certs if available, else validates config""" + cert_files = find_generated_certificates() + if cert_files: + validate_server_certificates_real(cert_files) + else: + validate_server_certificates_config() + + +def validate_client_certificates_real(cert_files): + """Validate actual Ansible-generated client certificates""" + # Find client certificates (not CA cert, not server cert with IP/DNS name) + client_certs = [] + for cert_path in cert_files['server_certs']: + if 'cacert.pem' in cert_path: + continue + + with open(cert_path, 'rb') as f: + cert_data = f.read() + certificate = x509.load_pem_x509_certificate(cert_data) + + # Check if this looks like a client cert vs server cert + cn = certificate.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value + # Server certs typically have IP addresses or domain names as CN + if not (cn.replace('.', '').isdigit() or '.' in cn and len(cn.split('.')) == 4): + client_certs.append((cert_path, certificate)) + + if not client_certs: + print("⚠ No client certificates found") + return + + for cert_path, certificate in client_certs: + # Check it's not a CA certificate + basic_constraints = certificate.extensions.get_extension_for_oid(ExtensionOID.BASIC_CONSTRAINTS).value + assert basic_constraints.ca is False, "Client certificate should not be a CA" + + # Check Extended Key Usage restrictions (Issue #75) + eku = certificate.extensions.get_extension_for_oid(ExtensionOID.EXTENDED_KEY_USAGE).value + assert x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH in eku, "Client cert must have clientAuth EKU" + assert x509.ObjectIdentifier("1.3.6.1.5.5.7.3.17") in eku, "Client cert should have IPsec End Entity EKU" + + # Security check: Client certificates should NOT have serverAuth (prevents impersonation) (Issue #153) + assert x509.oid.ExtendedKeyUsageOID.SERVER_AUTH not in eku, "Client cert must NOT have serverAuth EKU to prevent server impersonation" + + # Check SAN extension for email + try: + san = certificate.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME).value + email_sans = [name.value for name in san if isinstance(name, x509.RFC822Name)] + assert len(email_sans) > 0, "Client certificate should have email SAN" + except x509.ExtensionNotFound: + print(f"⚠ Client certificate missing SAN extension: {os.path.basename(cert_path)}") + + print(f"✓ Real client certificate valid: {os.path.basename(cert_path)}") + +def validate_client_certificates_config(): + """Validate client certificate configuration in Ansible files (CI mode)""" + openssl_task_file = find_ansible_file('roles/strongswan/tasks/openssl.yml') + if not openssl_task_file: + print("⚠ Could not find openssl.yml task file") + return + + with open(openssl_task_file) as f: + content = f.read() + + # Look for client certificate CSR section + client_csr_section = re.search(r'Create CSRs for client certificates.*?register: client_csr_jobs', content, re.DOTALL) + if not client_csr_section: + print("⚠ Could not find client certificate CSR section") + return + + client_section = client_csr_section.group(0) + + # Check client certificate configuration + client_checks = [ + ('clientAuth', 'Client certificates should have clientAuth EKU'), + ('1.3.6.1.5.5.7.3.17', 'Client certificates should have IPsec End Entity EKU'), + ('digitalSignature', 'Client certificates should have digital signature usage'), + ('keyEncipherment', 'Client certificates should have key encipherment usage'), + ('email:', 'Client certificates should have email SAN') + ] + + for check, message in client_checks: + assert check in client_section, f"Missing client certificate configuration: {message}" + + # Security check: Client certificates should NOT have serverAuth (Issue #153) + # Look for serverAuth in extended_key_usage section, not in comments + eku_lines = [line for line in client_section.split('\n') if 'extended_key_usage:' in line or (line.strip().startswith('- ') and 'serverAuth' in line)] + has_server_auth = any('serverAuth' in line for line in eku_lines if line.strip().startswith('- ')) + assert not has_server_auth, "Client certificates must NOT have serverAuth EKU to prevent server impersonation" + + # Verify client certificates use unique email domains (Issue #153) + assert 'openssl_constraint_random_id' in client_section, "Client certificates should use unique email domain per deployment" + + print("✓ Client certificate configuration has proper EKU restrictions (no serverAuth)") + +def test_client_certificates(): + """Test client certificates - uses real certs if available, else validates config (Issue #75, #153)""" + cert_files = find_generated_certificates() + if cert_files: + validate_client_certificates_real(cert_files) + else: + validate_client_certificates_config() + + +def validate_pkcs12_files_real(cert_files): + """Validate actual Ansible-generated PKCS#12 files""" + if not cert_files.get('p12_files'): + print("⚠ No PKCS#12 files found") + return + major, minor = test_openssl_version_detection() - # Test genrsa with -legacy flag - with tempfile.NamedTemporaryFile(suffix='.key', delete=False) as f: - temp_key = f.name + for p12_file in cert_files['p12_files']: + assert os.path.exists(p12_file), f"PKCS#12 file should exist: {p12_file}" - try: - # Try with -legacy flag - result_legacy = subprocess.run( - ['openssl', 'genrsa', '-legacy', '-out', temp_key, '2048'], - capture_output=True, - text=True - ) + # Test that PKCS#12 file can be read (validates format) + legacy_flag = ['-legacy'] if major >= 3 else [] - # Try without -legacy flag - result_normal = subprocess.run( - ['openssl', 'genrsa', '-out', temp_key, '2048'], - capture_output=True, - text=True - ) + result = subprocess.run([ + 'openssl', 'pkcs12', '-info', + '-in', p12_file, + '-passin', 'pass:', # Try empty password first + '-noout' + ] + legacy_flag, capture_output=True, text=True) - # Check which one worked - legacy_supported = result_legacy.returncode == 0 - normal_works = result_normal.returncode == 0 + # PKCS#12 files should be readable (even if password-protected) + # We're just testing format validity, not trying to extract contents + if result.returncode != 0: + # Try with common password patterns if empty password fails + print(f"⚠ PKCS#12 file may require password: {os.path.basename(p12_file)}") - assert normal_works, "OpenSSL genrsa should work without -legacy" + print(f"✓ Real PKCS#12 file exists: {os.path.basename(p12_file)}") - if major >= 3: - # OpenSSL 3.x should support -legacy - print(f"✓ OpenSSL {major}.{minor} legacy flag support: {legacy_supported}") - else: - # OpenSSL 1.x doesn't have -legacy flag - assert not legacy_supported, f"OpenSSL {major}.{minor} shouldn't support -legacy" - print(f"✓ OpenSSL {major}.{minor} correctly doesn't support -legacy") +def validate_pkcs12_files_config(): + """Validate PKCS#12 file configuration in Ansible files (CI mode)""" + openssl_task_file = find_ansible_file('roles/strongswan/tasks/openssl.yml') + if not openssl_task_file: + print("⚠ Could not find openssl.yml task file") + return - finally: - if os.path.exists(temp_key): - os.unlink(temp_key) + with open(openssl_task_file) as f: + content = f.read() + # Check PKCS#12 generation configuration + p12_checks = [ + ('openssl_pkcs12', 'PKCS#12 generation should be configured'), + ('encryption_level', 'PKCS#12 encryption level should be configured'), + ('compatibility2022', 'PKCS#12 should use Apple-compatible encryption'), + ('friendly_name', 'PKCS#12 should have friendly names'), + ('other_certificates', 'PKCS#12 should include CA certificate for full chain'), + ('passphrase', 'PKCS#12 files should be password protected'), + ('mode: "0600"', 'PKCS#12 files should have secure permissions') + ] + + for check, message in p12_checks: + assert check in content, f"Missing PKCS#12 configuration: {message}" + + print("✓ PKCS#12 configuration has proper Apple device compatibility settings") + +def test_pkcs12_files(): + """Test PKCS#12 files - uses real files if available, else validates config (Issue #14755, #14718)""" + cert_files = find_generated_certificates() + if cert_files: + validate_pkcs12_files_real(cert_files) + else: + validate_pkcs12_files_config() + + +def validate_certificate_chain_real(cert_files): + """Validate actual Ansible-generated certificate chain""" + # Load CA certificate + with open(cert_files['ca_cert'], 'rb') as f: + ca_cert_data = f.read() + ca_certificate = x509.load_pem_x509_certificate(ca_cert_data) + + # Test that all other certificates are signed by the CA + other_certs = [f for f in cert_files['server_certs'] if f != cert_files['ca_cert']] + + if not other_certs: + print("⚠ No client/server certificates found to validate") + return + + for cert_path in other_certs: + with open(cert_path, 'rb') as f: + cert_data = f.read() + certificate = x509.load_pem_x509_certificate(cert_data) + + # Verify the certificate was signed by our CA + assert certificate.issuer == ca_certificate.subject, f"Certificate {cert_path} not signed by CA" + + # Verify certificate is currently valid (not expired) + from datetime import datetime + now = datetime.now(UTC) + assert certificate.not_valid_before_utc <= now, f"Certificate {cert_path} not yet valid" + assert certificate.not_valid_after_utc >= now, f"Certificate {cert_path} has expired" + + print(f"✓ Real certificate chain valid: {os.path.basename(cert_path)}") + + print("✓ All real certificates properly signed by CA") + +def validate_certificate_chain_config(): + """Validate certificate chain configuration in Ansible files (CI mode)""" + openssl_task_file = find_ansible_file('roles/strongswan/tasks/openssl.yml') + if not openssl_task_file: + print("⚠ Could not find openssl.yml task file") + return + + with open(openssl_task_file) as f: + content = f.read() + + # Check certificate signing configuration + chain_checks = [ + ('provider: ownca', 'Certificates should be signed by own CA'), + ('ownca_path', 'CA certificate path should be specified'), + ('ownca_privatekey_path', 'CA private key path should be specified'), + ('ownca_privatekey_passphrase', 'CA private key should be password protected'), + ('certificate_validity_days: 3650', 'Certificate validity should be configurable (default 10 years)'), + ('ownca_not_after: "+{{ certificate_validity_days }}d"', 'Certificates should use configurable validity period'), + ('ownca_not_before: "-1d"', 'Certificates should have backdated start time'), + ('curve: secp384r1', 'Should use strong elliptic curve cryptography'), + ('type: ECC', 'Should use elliptic curve keys for better security') + ] + + for check, message in chain_checks: + assert check in content, f"Missing certificate chain configuration: {message}" + + print("✓ Certificate chain configuration properly set up for CA signing") + +def test_certificate_chain(): + """Test certificate chain - uses real certs if available, else validates config""" + cert_files = find_generated_certificates() + if cert_files: + validate_certificate_chain_real(cert_files) + else: + validate_certificate_chain_config() + + +def find_ansible_file(relative_path): + """Find Ansible file from various possible locations""" + # Try different base paths + possible_bases = [ + ".", # Current directory + "..", # Parent directory (from tests/unit) + "../..", # Grandparent (from tests/unit to project root) + "../../..", # Alternative deep path + ] + + for base in possible_bases: + full_path = os.path.join(base, relative_path) + if os.path.exists(full_path): + return full_path + + return None if __name__ == "__main__": tests = [ test_openssl_version_detection, - test_legacy_flag_support, + test_ca_certificate, + test_server_certificates, + test_client_certificates, + test_pkcs12_files, + test_certificate_chain, ] failed = 0 diff --git a/tests/unit/test_wireguard_key_generation.py b/tests/unit/test_wireguard_key_generation.py index 6c5f9a13..f75e3b10 100644 --- a/tests/unit/test_wireguard_key_generation.py +++ b/tests/unit/test_wireguard_key_generation.py @@ -8,7 +8,6 @@ import os import subprocess import sys import tempfile -import shutil # Add library directory to path to import our custom module sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'library')) @@ -29,7 +28,7 @@ def test_wireguard_tools_available(): def test_x25519_module_import(): """Test that our custom x25519_pubkey module can be imported and used""" try: - from x25519_pubkey import run_module + import x25519_pubkey # noqa: F401 print("✓ x25519_pubkey module imports successfully") return True except ImportError as e: @@ -40,24 +39,24 @@ def generate_test_private_key(): """Generate a test private key using the same method as Algo""" with tempfile.NamedTemporaryFile(suffix='.raw', delete=False) as temp_file: raw_key_path = temp_file.name - + try: # Generate 32 random bytes for X25519 private key (same as community.crypto does) import secrets raw_data = secrets.token_bytes(32) - + # Write raw key to file (like community.crypto openssl_privatekey with format: raw) with open(raw_key_path, 'wb') as f: f.write(raw_data) - + assert len(raw_data) == 32, f"Private key should be 32 bytes, got {len(raw_data)}" - + b64_key = base64.b64encode(raw_data).decode() - + print(f"✓ Generated private key (base64): {b64_key[:12]}...") - + return raw_key_path, b64_key - + except Exception: # Clean up on error if os.path.exists(raw_key_path): @@ -68,33 +67,32 @@ def generate_test_private_key(): def test_x25519_pubkey_from_raw_file(): """Test our x25519_pubkey module with raw private key file""" raw_key_path, b64_key = generate_test_private_key() - + try: # Import here so we can mock the module_utils if needed - from unittest.mock import Mock - + # Mock the AnsibleModule for testing class MockModule: def __init__(self, params): self.params = params self.result = {} - + def fail_json(self, **kwargs): raise Exception(f"Module failed: {kwargs}") - + def exit_json(self, **kwargs): self.result = kwargs - + with tempfile.NamedTemporaryFile(suffix='.pub', delete=False) as temp_pub: public_key_path = temp_pub.name - + try: # Test the module logic directly - from x25519_pubkey import run_module import x25519_pubkey - + from x25519_pubkey import run_module + original_AnsibleModule = x25519_pubkey.AnsibleModule - + try: # Mock the module call mock_module = MockModule({ @@ -102,38 +100,38 @@ def test_x25519_pubkey_from_raw_file(): 'public_key_path': public_key_path, 'private_key_b64': None }) - + x25519_pubkey.AnsibleModule = lambda **kwargs: mock_module - + # Run the module run_module() - + # Check the result assert 'public_key' in mock_module.result - assert mock_module.result['changed'] == True + assert mock_module.result['changed'] assert os.path.exists(public_key_path) - - with open(public_key_path, 'r') as f: + + with open(public_key_path) as f: derived_pubkey = f.read().strip() - + # Validate base64 format try: decoded = base64.b64decode(derived_pubkey, validate=True) assert len(decoded) == 32, f"Public key should be 32 bytes, got {len(decoded)}" except Exception as e: assert False, f"Invalid base64 public key: {e}" - + print(f"✓ Derived public key from raw file: {derived_pubkey[:12]}...") - + return derived_pubkey - + finally: x25519_pubkey.AnsibleModule = original_AnsibleModule - + finally: if os.path.exists(public_key_path): os.unlink(public_key_path) - + finally: if os.path.exists(raw_key_path): os.unlink(raw_key_path) @@ -142,56 +140,55 @@ def test_x25519_pubkey_from_raw_file(): def test_x25519_pubkey_from_b64_string(): """Test our x25519_pubkey module with base64 private key string""" raw_key_path, b64_key = generate_test_private_key() - + try: - from unittest.mock import Mock - + class MockModule: def __init__(self, params): self.params = params self.result = {} - + def fail_json(self, **kwargs): raise Exception(f"Module failed: {kwargs}") - + def exit_json(self, **kwargs): self.result = kwargs - - from x25519_pubkey import run_module + import x25519_pubkey - + from x25519_pubkey import run_module + original_AnsibleModule = x25519_pubkey.AnsibleModule - + try: mock_module = MockModule({ 'private_key_b64': b64_key, 'private_key_path': None, 'public_key_path': None }) - + x25519_pubkey.AnsibleModule = lambda **kwargs: mock_module - + # Run the module run_module() - + # Check the result assert 'public_key' in mock_module.result derived_pubkey = mock_module.result['public_key'] - + # Validate base64 format try: decoded = base64.b64decode(derived_pubkey, validate=True) assert len(decoded) == 32, f"Public key should be 32 bytes, got {len(decoded)}" except Exception as e: assert False, f"Invalid base64 public key: {e}" - + print(f"✓ Derived public key from base64 string: {derived_pubkey[:12]}...") - + return derived_pubkey - + finally: x25519_pubkey.AnsibleModule = original_AnsibleModule - + finally: if os.path.exists(raw_key_path): os.unlink(raw_key_path) @@ -201,45 +198,44 @@ def test_wireguard_validation(): """Test that our derived keys work with actual WireGuard tools""" if not test_wireguard_tools_available(): return - + # Generate keys using our method raw_key_path, b64_key = generate_test_private_key() - + try: # Derive public key using our module - from unittest.mock import Mock - + class MockModule: def __init__(self, params): self.params = params self.result = {} - + def fail_json(self, **kwargs): raise Exception(f"Module failed: {kwargs}") - + def exit_json(self, **kwargs): self.result = kwargs - - from x25519_pubkey import run_module + import x25519_pubkey - + from x25519_pubkey import run_module + original_AnsibleModule = x25519_pubkey.AnsibleModule - + try: mock_module = MockModule({ 'private_key_b64': b64_key, 'private_key_path': None, 'public_key_path': None }) - + x25519_pubkey.AnsibleModule = lambda **kwargs: mock_module run_module() - + derived_pubkey = mock_module.result['public_key'] - + finally: x25519_pubkey.AnsibleModule = original_AnsibleModule - + with tempfile.NamedTemporaryFile(mode='w', suffix='.conf', delete=False) as temp_config: # Create a WireGuard config using our keys wg_config = f"""[Interface] @@ -252,33 +248,33 @@ AllowedIPs = 10.19.49.2/32 """ temp_config.write(wg_config) config_path = temp_config.name - + try: # Test that WireGuard can parse our config result = subprocess.run([ 'wg-quick', 'strip', config_path ], capture_output=True, text=True) - + assert result.returncode == 0, f"WireGuard rejected our config: {result.stderr}" - + # Test key derivation with wg pubkey command wg_result = subprocess.run([ 'wg', 'pubkey' ], input=b64_key, capture_output=True, text=True) - + if wg_result.returncode == 0: wg_derived = wg_result.stdout.strip() assert wg_derived == derived_pubkey, f"Key mismatch: wg={wg_derived} vs ours={derived_pubkey}" - print(f"✓ WireGuard validation: keys match wg pubkey output") + print("✓ WireGuard validation: keys match wg pubkey output") else: print(f"⚠ Could not validate with wg pubkey: {wg_result.stderr}") - + print("✓ WireGuard accepts our generated configuration") - + finally: if os.path.exists(config_path): os.unlink(config_path) - + finally: if os.path.exists(raw_key_path): os.unlink(raw_key_path) @@ -288,49 +284,48 @@ def test_key_consistency(): """Test that the same private key always produces the same public key""" # Generate one private key to reuse raw_key_path, b64_key = generate_test_private_key() - + try: def derive_pubkey_from_same_key(): - from unittest.mock import Mock - + class MockModule: def __init__(self, params): self.params = params self.result = {} - + def fail_json(self, **kwargs): raise Exception(f"Module failed: {kwargs}") - + def exit_json(self, **kwargs): self.result = kwargs - - from x25519_pubkey import run_module + import x25519_pubkey - + from x25519_pubkey import run_module + original_AnsibleModule = x25519_pubkey.AnsibleModule - + try: mock_module = MockModule({ 'private_key_b64': b64_key, # SAME key each time 'private_key_path': None, 'public_key_path': None }) - + x25519_pubkey.AnsibleModule = lambda **kwargs: mock_module run_module() - + return mock_module.result['public_key'] - + finally: x25519_pubkey.AnsibleModule = original_AnsibleModule - + # Derive public key multiple times from same private key pubkey1 = derive_pubkey_from_same_key() pubkey2 = derive_pubkey_from_same_key() - + assert pubkey1 == pubkey2, f"Key derivation not consistent: {pubkey1} vs {pubkey2}" print("✓ Key derivation is consistent") - + finally: if os.path.exists(raw_key_path): os.unlink(raw_key_path) @@ -344,7 +339,7 @@ if __name__ == "__main__": test_key_consistency, test_wireguard_validation, ] - + failed = 0 for test in tests: try: @@ -355,9 +350,9 @@ if __name__ == "__main__": except Exception as e: print(f"✗ {test.__name__} error: {e}") failed += 1 - + if failed > 0: print(f"\n{failed} tests failed") sys.exit(1) else: - print(f"\nAll {len(tests)} tests passed!") \ No newline at end of file + print(f"\nAll {len(tests)} tests passed!") diff --git a/uv.lock b/uv.lock index 5aec38cd..07765976 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 2 -requires-python = ">=3.10" +requires-python = ">=3.11" [[package]] name = "algo"