Merge master security enhancements into PKI refactor

This merge preserves all critical security enhancements from master while
using the modern Ansible crypto modules approach:

Security features preserved:
- Name constraints to restrict CA certificate scope
- Extended Key Usage (EKU) restrictions for server vs client certificates
- Subject Alternative Name (SAN) requirements for certificate validation
- Password-protected CA private keys
- Certificate Revocation List (CRL) generation
- Proper file permissions and directory structure

The refactored approach eliminates shell commands and uses Ansible's
community.crypto modules for better security and maintainability.

🚨 Security-critical merge - all defensive measures retained

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dan Guido 2025-08-04 21:00:05 -07:00
commit b9cb08a980
9 changed files with 233 additions and 11 deletions

View file

@ -1,5 +1,5 @@
---
- name: daemon reload
- name: daemon-reload
systemd:
daemon_reload: true

View file

@ -62,3 +62,38 @@
AmbientCapabilities=CAP_NET_BIND_SERVICE
notify:
- restart dnscrypt-proxy
- name: Ubuntu | Apply systemd security hardening for dnscrypt-proxy
copy:
dest: /etc/systemd/system/dnscrypt-proxy.service.d/90-security-hardening.conf
content: |
# Algo VPN systemd security hardening for dnscrypt-proxy
# Additional hardening on top of comprehensive AppArmor
[Service]
# Privilege restrictions
NoNewPrivileges=yes
# Filesystem isolation (complements AppArmor)
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes
# Network restrictions
RestrictAddressFamilies=AF_INET AF_INET6
# Allow access to dnscrypt-proxy cache (AppArmor also controls this)
ReadWritePaths=/var/cache/dnscrypt-proxy
# System call filtering (complements AppArmor restrictions)
SystemCallFilter=@system-service @network-io
SystemCallFilter=~@debug @mount @swap @reboot @raw-io
SystemCallErrorNumber=EPERM
owner: root
group: root
mode: 0644
notify:
- daemon-reload
- restart dnscrypt-proxy

View file

@ -11,7 +11,12 @@ algo_ondemand_wifi_exclude: _null
algo_dns_adblocking: false
ipv6_support: false
dns_encryption: true
# Random UUID for CA name constraints - prevents certificate reuse across different Algo deployments
# This unique identifier ensures each CA can only issue certificates for its specific server instance
openssl_constraint_random_id: "{{ IP_subject_alt_name | to_uuid }}.algo"
# Subject Alternative Name (SAN) configuration - CRITICAL for client compatibility
# Modern clients (especially macOS/iOS) REQUIRE SAN extension in server certificates
# Without SAN, IKEv2 connections will fail with certificate validation errors
subjectAltName_type: "{{ 'DNS' if IP_subject_alt_name|regex_search('[a-z]') else 'IP' }}"
subjectAltName: >-
{{ subjectAltName_type }}:{{ IP_subject_alt_name }}
@ -21,12 +26,16 @@ nameConstraints: >-
critical,permitted;{{ subjectAltName_type }}:{{ IP_subject_alt_name }}{{- '/255.255.255.255' if subjectAltName_type == 'IP' else '' -}}
{%- if subjectAltName_type == 'IP' -%}
,permitted;DNS:{{ openssl_constraint_random_id }}
,excluded;DNS:.com,excluded;DNS:.org,excluded;DNS:.net,excluded;DNS:.gov,excluded;DNS:.edu,excluded;DNS:.mil,excluded;DNS:.int
,excluded;IP:10.0.0.0/255.0.0.0,excluded;IP:172.16.0.0/255.240.0.0,excluded;IP:192.168.0.0/255.255.0.0
{%- else -%}
,excluded;IP:0.0.0.0/0.0.0.0
{%- endif -%}
,permitted;email:{{ openssl_constraint_random_id }}
,excluded;email:.com,excluded;email:.org,excluded;email:.net,excluded;email:.gov,excluded;email:.edu,excluded;email:.mil,excluded;email:.int
{%- if ipv6_support -%}
,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
{%- endif -%}

View file

@ -33,6 +33,7 @@
with_items:
- "{{ users }}"
- name: Build the client ipsec secret file
template:
src: client_ipsec.secrets.j2

View file

@ -13,15 +13,18 @@
dest: "{{ ipsec_pki_path }}/{{ item }}"
state: directory
recurse: true
mode: "0700"
with_items:
- certs
- private
- public
- name: Ensure the config directories exist
file:
dest: "{{ ipsec_config_path }}/{{ item }}"
state: directory
recurse: true
mode: "0700"
with_items:
- apple
- manual
@ -34,7 +37,10 @@
curve: secp384r1
mode: "0600"
- name: Create certificate signing request (CSR) for CA certificate
# CRITICAL: Create CA certificate with proper security constraints
# 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
- 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 }}"
@ -42,10 +48,35 @@
use_common_name_for_san: true
basic_constraints:
- 'CA:TRUE'
- 'pathlen:0'
basic_constraints_critical: true
key_usage:
- keyCertSign
- cRLSign
key_usage_critical: true
# Restrict CA to only sign VPN-related certificates
extended_key_usage:
- serverAuth
- clientAuth
- '1.3.6.1.5.5.7.3.17' # IPsec End Entity
extended_key_usage_critical: true
# Name constraints to restrict certificate scope
name_constraints_permitted:
- "{{ subjectAltName_type }}:{{ IP_subject_alt_name }}{{ '/255.255.255.255' if subjectAltName_type == 'IP' else '' }}"
- "DNS:{{ openssl_constraint_random_id }}"
- "email:{{ openssl_constraint_random_id }}"
name_constraints_excluded:
- "DNS:.com"
- "DNS:.org"
- "DNS:.net"
- "DNS:.gov"
- "DNS:.edu"
- "DNS:.mil"
- "DNS:.int"
- "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"
name_constraints_critical: true
register: ca_csr
- name: Create self-signed CA certificate from CSR
@ -55,8 +86,14 @@
privatekey_path: "{{ ipsec_pki_path }}/private/cakey.pem"
privatekey_passphrase: "{{ CA_password }}"
provider: selfsigned
mode: "0644"
- name: Create private keys
- name: Copy the CA certificate
copy:
src: "{{ ipsec_pki_path }}/cacert.pem"
dest: "{{ ipsec_config_path }}/manual/cacert.pem"
- name: Create private keys for users and server
community.crypto.openssl_privatekey:
path: "{{ ipsec_pki_path }}/private/{{ item }}.key"
type: ECC
@ -67,17 +104,59 @@
- "{{ IP_subject_alt_name }}"
register: client_key_jobs
- name: Create CSRs
# Create CSRs with proper Subject Alternative Names
# CRITICAL: Server certificates need SAN extension for modern clients,
# especially macOS/iOS which perform strict certificate validation for IKEv2.
# Without SAN containing the server IP, clients will reject the certificate.
- 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 }}"
key_usage:
- digitalSignature
- keyEncipherment
key_usage_critical: false
# Server authentication for IKEv2 VPN connections
extended_key_usage:
- serverAuth
- '1.3.6.1.5.5.7.3.17' # IPsec End Entity
extended_key_usage_critical: false
register: server_csr
- name: Create CSRs for client certificates
community.crypto.openssl_csr_pipe:
privatekey_path: "{{ ipsec_pki_path }}/private/{{ item }}.key"
subject_alt_name: "{{ subjectAltName | split(',') if item == IP_subject_alt_name else [subjectAltName_USER] }}"
subject_alt_name:
- "email:{{ item }}@{{ openssl_constraint_random_id }}"
common_name: "{{ item }}"
with_items:
- "{{ users }}"
- "{{ IP_subject_alt_name }}"
key_usage:
- digitalSignature
- keyEncipherment
key_usage_critical: false
# Client certificates should not have serverAuth
extended_key_usage:
- clientAuth
- '1.3.6.1.5.5.7.3.17' # IPsec End Entity
extended_key_usage_critical: false
with_items: "{{ users }}"
register: client_csr_jobs
- name: Sign clients certificates with our CA
# Sign server certificate with proper extensions
- 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: +3650d
ownca_not_before: "-1d"
mode: "0644"
# Sign client certificates with CA
- name: Sign client certificates with CA
community.crypto.x509_certificate:
csr_content: "{{ item.csr }}"
path: "{{ ipsec_pki_path }}/certs/{{ item.item }}.crt"
@ -87,6 +166,7 @@
ownca_privatekey_passphrase: "{{ CA_password }}"
ownca_not_after: +3650d
ownca_not_before: "-1d"
mode: "0644"
with_items: "{{ client_csr_jobs.results }}"
register: client_sign_results
@ -101,6 +181,19 @@
encryption_level: "compatibility2022"
with_items: "{{ users }}"
- 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"
with_items: "{{ users }}"
- name: Copy the p12 certificates
copy:
src: "{{ ipsec_pki_path }}/private/{{ item }}.p12"
@ -108,6 +201,13 @@
with_items:
- "{{ users }}"
- 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: Add all users to the file
ansible.builtin.lineinfile:
path: "{{ ipsec_pki_path }}/all-users"
@ -142,6 +242,7 @@
issuer:
CN: "{{ IP_subject_alt_name }}"
revoked_certificates: "{{ revoked_certificates }}"
mode: "0644"
delegate_to: localhost
become: false
vars:

View file

@ -1,2 +1,24 @@
# Algo VPN systemd security hardening for StrongSwan
# Enhanced hardening on top of existing AppArmor
[Service]
MemoryLimit=16777216
# Privilege restrictions
NoNewPrivileges=yes
# Filesystem isolation (complements AppArmor)
ProtectHome=yes
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes
# Network restrictions - include IPsec kernel communication requirements
RestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINK AF_PACKET
# Allow access to IPsec configuration, state, and kernel interfaces
ReadWritePaths=/etc/ipsec.d /var/lib/strongswan
ReadOnlyPaths=/proc/net/pfkey
# System call filtering (complements AppArmor restrictions)
# Allow crypto operations, remove cpu-emulation restriction for crypto algorithms
SystemCallFilter=@system-service @network-io
SystemCallFilter=~@debug @mount @swap @reboot
SystemCallErrorNumber=EPERM

View file

@ -73,10 +73,13 @@
</dict>
<key>DeadPeerDetectionRate</key>
<string>Medium</string>
<!-- MOBIKE allows VPN to survive network changes (WiFi to cellular) -->
<key>DisableMOBIKE</key>
<integer>0</integer>
<!-- Disable IKEv2 redirects for security -->
<key>DisableRedirect</key>
<integer>1</integer>
<!-- Disable CRL checking for performance and reliability -->
<key>EnableCertificateRevocationCheck</key>
<integer>0</integer>
<key>EnablePFS</key>
@ -96,19 +99,24 @@
<string>{{ item.0 }}@{{ openssl_constraint_random_id }}</string>
<key>PayloadCertificateUUID</key>
<string>{{ pkcs12_PayloadCertificateUUID }}</string>
<!-- Use ECDSA P-384 certificates for strong security -->
<key>CertificateType</key>
<string>ECDSA384</string>
<key>ServerCertificateIssuerCommonName</key>
<string>{{ IP_subject_alt_name }}</string>
<key>ServerCertificateCommonName</key>
<string>{{ IP_subject_alt_name }}</string>
<key>RemoteAddress</key>
<string>{{ IP_subject_alt_name }}</string>
<key>RemoteIdentifier</key>
<string>{{ IP_subject_alt_name }}</string>
<!-- Use server-provided internal IP assignment -->
<key>UseConfigurationAttributeInternalIPSubnet</key>
<integer>0</integer>
</dict>
<key>IPv4</key>
<dict>
<!-- Override primary network interface for full VPN routing -->
<key>OverridePrimary</key>
<integer>1</integer>
</dict>

View file

@ -1,4 +1,8 @@
---
- name: daemon-reload
systemd:
daemon_reload: true
- name: restart wireguard
service:
name: "{{ service_name }}"

View file

@ -10,3 +10,45 @@
set_fact:
service_name: wg-quick@{{ wireguard_interface }}
tags: always
- name: Ubuntu | Ensure that the WireGuard service directory exists
file:
path: /etc/systemd/system/wg-quick@{{ wireguard_interface }}.service.d/
state: directory
mode: 0755
owner: root
group: root
- name: Ubuntu | Apply systemd security hardening for WireGuard
copy:
dest: /etc/systemd/system/wg-quick@{{ wireguard_interface }}.service.d/90-security-hardening.conf
content: |
# Algo VPN systemd security hardening for WireGuard
[Service]
# Privilege restrictions
NoNewPrivileges=yes
# Filesystem isolation
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes
# Network restrictions - WireGuard needs NETLINK for interface management
RestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINK
# Allow access to WireGuard configuration
ReadWritePaths=/etc/wireguard
ReadOnlyPaths=/etc/resolv.conf
# System call filtering - allow network and system service calls
SystemCallFilter=@system-service @network-io
SystemCallFilter=~@debug @mount @swap @reboot @raw-io
SystemCallErrorNumber=EPERM
owner: root
group: root
mode: 0644
notify:
- daemon-reload
- restart wireguard