algo/tests/unit/test_wireguard_key_generation.py
Jack Ivanov 4289db043a
Refactor StrongSwan PKI tasks to use Ansible crypto modules and remove legacy OpenSSL scripts (#14809)
* Refactor StrongSwan PKI automation with Ansible crypto modules

- Replace shell-based OpenSSL commands with community.crypto modules
- Remove custom OpenSSL config template and manual file management
- Upgrade Ansible to 11.8.0 in requirements.txt
- Improve idempotency, maintainability, and security of certificate and CRL handling

* Enhance nameConstraints with comprehensive exclusions

- Add email domain exclusions (.com, .org, .net, .gov, .edu, .mil, .int)
- Include private IPv4 network exclusions
- Add IPv6 null route exclusion
- Preserve all security constraints from original openssl.cnf.j2
- Note: Complex IPv6 conditional logic simplified for Ansible compatibility

Security: Maintains defense-in-depth certificate scope restrictions

* Refactor StrongSwan PKI with comprehensive security enhancements and hybrid testing

## StrongSwan PKI Modernization
- Migrated from shell-based OpenSSL commands to Ansible community.crypto modules
- Simplified complex Jinja2 templates while preserving all security properties
- Added clear, concise comments explaining security rationale and Apple compatibility

## Enhanced Security Implementation (Issues #75, #153)
- **Name constraints**: CA certificates restricted to specific IP/email domains
- **EKU role separation**: Server certs (serverAuth only) vs client certs (clientAuth only)
- **Domain exclusions**: Blocks public domains (.com, .org, etc.) and private IP ranges
- **Apple compatibility**: SAN extensions and PKCS#12 compatibility2022 encryption
- **Certificate revocation**: Automated CRL generation for removed users

## Comprehensive Test Suite
- **Hybrid testing**: Validates real certificates when available, config validation for CI
- **Security validation**: Verifies name constraints, EKU restrictions, role separation
- **Apple compatibility**: Tests SAN extensions and PKCS#12 format compliance
- **Certificate chain**: Validates CA signing and certificate validity periods
- **CI-compatible**: No deployment required, tests Ansible configuration directly

## Configuration Updates
- Updated CLAUDE.md: Ansible version rationale (stay current for security/performance)
- Streamlined comments: Removed duplicative explanations while preserving technical context
- Maintained all Issue #75/#153 security enhancements with modern Ansible approach

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix linting issues across the codebase

## Python Code Quality (ruff)
- Fixed import organization and removed unused imports in test files
- Replaced `== True` comparisons with direct boolean checks
- Added noqa comments for intentional imports in test modules

## YAML Formatting (yamllint)
- Removed trailing spaces in openssl.yml comments
- All YAML files now pass yamllint validation (except one pre-existing long regex line)

## Code Consistency
- Maintained proper import ordering in test files
- Ensured all code follows project linting standards
- Ready for CI pipeline validation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Replace magic number with configurable certificate validity period

## Maintainability Improvement
- Replaced hardcoded `+3650d` (10 years) with configurable variable
- Added `certificate_validity_days: 3650` in vars section with clear documentation
- Applied consistently to both server and client certificate signing

## Benefits
- Single location to modify certificate validity period
- Supports compliance requirements for shorter certificate lifespans
- Improves code readability and maintainability
- Eliminates magic number duplication

## Backwards Compatibility
- Default remains 10 years (3650 days) - no behavior change
- Organizations can now easily customize certificate validity as needed

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update test to validate configurable certificate validity period

## Test Update
- Fixed test failure after replacing magic number with configurable variable
- Now validates both variable definition and usage patterns:
  - `certificate_validity_days: 3650` (configurable parameter)
  - `ownca_not_after: "+{{ certificate_validity_days }}d"` (variable usage)

## Improved Test Coverage
- Better validation: checks that validity is configurable, not hardcoded
- Maintains backwards compatibility verification (10-year default)
- Ensures proper Ansible variable templating is used

## Verified
- Config validation mode: All 6 tests pass ✓
- Validates the maintainability improvement from previous commit

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update to Python 3.11 minimum and fix IPv6 constraint format

- Update Python requirement from 3.10 to 3.11 to align with Ansible 11
- Pin Ansible collections in requirements.yml for stability
- Fix invalid IPv6 constraint format causing deployment failure
- Update ruff target-version to py311 for consistency

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix x509_crl mode parameter and auto-fix Python linting

- Remove deprecated 'mode' parameter from x509_crl task
- Add separate file task to set CRL permissions (0644)
- Auto-fix Python datetime import (use datetime.UTC alias)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix final IPv6 constraint format in defaults template

- Update nameConstraints template in defaults/main.yml
- Change malformed IP:0:0:0:0:0:0:0:0/0:0:0:0:0:0:0:0 to correct IP:::/0
- This ensures both Ansible crypto modules and OpenSSL template use consistent IPv6 format

* Fix critical certificate generation issues for macOS/iOS VPN compatibility

This commit addresses multiple certificate generation bugs in the Ansible crypto
module implementation that were causing VPN authentication failures on Apple devices.

Fixes implemented:

1. **Basic Constraints Extension**: Added missing `CA:FALSE` constraints to both
   server and client certificate CSRs. This was causing certificate chain validation
   errors on macOS/iOS devices.

2. **Subject Key Identifier**: Added `create_subject_key_identifier: true` to CA
   certificate generation to enable proper Authority Key Identifier creation in
   signed certificates.

3. **Complete Name Constraints**: Fixed missing DNS and IPv6 constraints in CA
   certificate that were causing size differences compared to legacy shell-based
   generation. Now includes:
   - DNS constraints for the deployment-specific domain
   - IPv6 permitted addresses when IPv6 support is enabled
   - Complete IPv6 exclusion ranges (fc00::/7, fe80::/10, 2001:db8::/32)

These changes bring the certificate format much closer to the working shell-based
implementation and should resolve most macOS/iOS VPN connectivity issues.

**Outstanding Issue**: Authority Key Identifier still incomplete - missing DirName
and serial components. The community.crypto module limitation may require
additional investigation or alternative approaches.

Certificate size improvements: Server certificates increased from ~750 to ~775 bytes,
CA certificates from ~1070 to ~1250 bytes, bringing them closer to the expected
~3000 byte target size.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix certificate generation and improve version parsing

This commit addresses multiple issues found during macOS certificate validation:

Certificate Generation Fixes:
- Add Basic Constraints (CA:FALSE) to server and client certificates
- Generate Subject Key Identifier for proper AKI creation
- Improve Name Constraints implementation for security
- Update community.crypto to version 3.0.3 for latest fixes

Code Quality Improvements:
- Clean up certificate comments and remove obsolete references
- Fix server certificate identification in tests
- Update datetime comparisons for cryptography library compatibility
- Fix Ansible version parsing in main.yml with proper regex handling

Testing:
- All certificate validation tests pass
- Ansible syntax checks pass
- Python linting (ruff) clean
- YAML linting (yamllint) clean

These changes restore macOS/iOS certificate compatibility while maintaining
security best practices and improving code maintainability.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Enhance security documentation with comprehensive inline comments

Add detailed technical explanations for critical PKI security features:

- Name Constraints: Defense-in-depth rationale and attack prevention
- Public domain/network exclusions: Impersonation attack prevention
- RFC 1918 private IP blocking: Lateral movement prevention
- IPv6 constraint strategy: ULA/link-local/documentation range handling
- Role separation enforcement: Server vs client EKU restrictions
- CA delegation prevention: pathlen:0 security implications
- Cross-deployment isolation: UUID-based certificate scope limiting

These comments provide essential context for maintainers to understand
the security importance of each configuration without referencing
external issue numbers, ensuring long-term maintainability.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix CI test failures in PKI certificate validation

Resolve Smart Test Selection workflow failures by fixing test validation logic:

**Certificate Configuration Fixes:**
- Remove unnecessary serverAuth/clientAuth EKUs from CA certificate
- CA now only has IPsec End Entity EKU for VPN-specific certificate issuance
- Maintains proper role separation between server and client certificates

**Test Validation Improvements:**
- Fix domain exclusion detection to handle both single and double quotes in YAML
- Improve EKU validation to check actual configuration lines, not comments
- Server/client certificate tests now correctly parse YAML structure
- Tests pass in both CI mode (config validation) and local mode (real certificates)

**Root Cause:**
The CI failures were caused by overly broad test assertions that:
1. Expected double-quoted strings but found single-quoted YAML
2. Detected EKU keywords in comments rather than actual configuration
3. Failed to properly parse YAML list structures

All security constraints remain intact - no actual security issues were present.
The certificate generation produces properly constrained certificates for VPN use.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix trailing space in openssl.yml for yamllint compliance

---------

Co-authored-by: Dan Guido <dan@trailofbits.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-05 05:40:28 -07:00

358 lines
11 KiB
Python

#!/usr/bin/env python3
"""
Test WireGuard key generation - focused on x25519_pubkey module integration
Addresses test gap identified in tests/README.md line 63-67: WireGuard private/public key generation
"""
import base64
import os
import subprocess
import sys
import tempfile
# Add library directory to path to import our custom module
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'library'))
def test_wireguard_tools_available():
"""Test that WireGuard tools are available for validation"""
try:
result = subprocess.run(['wg', '--version'], capture_output=True, text=True)
assert result.returncode == 0, "WireGuard tools not available"
print(f"✓ WireGuard tools available: {result.stdout.strip()}")
return True
except FileNotFoundError:
print("⚠ WireGuard tools not available - skipping validation tests")
return False
def test_x25519_module_import():
"""Test that our custom x25519_pubkey module can be imported and used"""
try:
import x25519_pubkey # noqa: F401
print("✓ x25519_pubkey module imports successfully")
return True
except ImportError as e:
assert False, f"Cannot import x25519_pubkey module: {e}"
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):
os.unlink(raw_key_path)
raise
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
# 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
import x25519_pubkey
from x25519_pubkey import run_module
original_AnsibleModule = x25519_pubkey.AnsibleModule
try:
# Mock the module call
mock_module = MockModule({
'private_key_path': raw_key_path,
'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']
assert os.path.exists(public_key_path)
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)
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:
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
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)
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
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
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]
PrivateKey = {b64_key}
Address = 10.19.49.1/24
[Peer]
PublicKey = {derived_pubkey}
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("✓ 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)
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():
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
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)
if __name__ == "__main__":
tests = [
test_x25519_module_import,
test_x25519_pubkey_from_raw_file,
test_x25519_pubkey_from_b64_string,
test_key_consistency,
test_wireguard_validation,
]
failed = 0
for test in tests:
try:
test()
except AssertionError as e:
print(f"{test.__name__} failed: {e}")
failed += 1
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!")