mirror of
https://github.com/trailofbits/algo.git
synced 2025-09-08 04:53:08 +02:00
* Refactor WireGuard key management: generate all keys locally with Ansible modules - Move all WireGuard key generation from remote hosts to local execution via Ansible modules - Enhance x25519_pubkey module for robust, idempotent, and secure key handling - Update WireGuard role tasks to use local key generation and management - Improve error handling and support for check mode * Improve x25519_pubkey module code quality and add integration tests Code Quality Improvements: - Fix import organization and Ruff linting errors - Replace bare except clauses with practical error handling - Simplify documentation while maintaining useful debugging info - Use dictionary literals instead of dict() calls for better performance New Integration Test: - Add comprehensive WireGuard key generation test (test_wireguard_key_generation.py) - Tests actual deployment scenarios matching roles/wireguard/tasks/keys.yml - Validates mathematical correctness of X25519 key derivation - Tests both file and string input methods used by Algo - Includes consistency validation and WireGuard tool integration - Addresses documented test gap in tests/README.md line 63-67 Test Coverage: - Module import validation - Raw private key file processing - Base64 private key string processing - Key derivation consistency checks - Optional WireGuard tool validation (when available) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Trigger CI build for PR #14803 Testing x25519_pubkey module improvements and WireGuard key generation changes. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix yamllint error: add missing newline at end of keys.yml Resolves: no new line character at the end of file (new-line-at-end-of-file) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix critical binary data corruption bug in x25519_pubkey module Issue: Private keys with whitespace-like bytes (0x09, 0x0A, etc.) at edges were corrupted by .strip() call on binary data, causing 32-byte keys to become 31 bytes and deployment failures. Root Cause: - Called .strip() on raw binary data unconditionally - X25519 keys containing whitespace bytes were truncated - Error: "got 31 bytes" instead of expected 32 bytes Fix: - Only strip whitespace when processing base64 text data - Preserve raw binary data integrity for 32-byte keys - Maintain backward compatibility with both formats Addresses deployment failure: "Private key file must be either base64 or exactly 32 raw bytes, got 31 bytes" 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Add inline comments to prevent binary data corruption bug Explain the base64/raw file detection logic with clear warnings about the critical issue where .strip() on raw binary data corrupts X25519 keys containing whitespace-like bytes (0x09, 0x0A, etc.). This prevents future developers from accidentally reintroducing the 'got 31 bytes' deployment error by misunderstanding the dual-format key handling logic. --------- Co-authored-by: Dan Guido <dan@trailofbits.com> Co-authored-by: Claude <noreply@anthropic.com>
135 lines
4.6 KiB
Python
Executable file
135 lines
4.6 KiB
Python
Executable file
#!/usr/bin/python
|
|
|
|
# x25519_pubkey.py - Ansible module to derive a base64-encoded WireGuard-compatible public key
|
|
# from a base64-encoded 32-byte X25519 private key.
|
|
#
|
|
# Why: community.crypto does not provide raw public key derivation for X25519 keys.
|
|
|
|
import base64
|
|
|
|
from ansible.module_utils.basic import AnsibleModule
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric import x25519
|
|
|
|
"""
|
|
Ansible module to derive base64-encoded X25519 public keys from private keys.
|
|
|
|
Supports both base64-encoded strings and raw 32-byte key files.
|
|
Used for WireGuard key generation where community.crypto lacks raw public key derivation.
|
|
|
|
Parameters:
|
|
- private_key_b64: Base64-encoded X25519 private key string
|
|
- private_key_path: Path to file containing X25519 private key (base64 or raw 32 bytes)
|
|
- public_key_path: Path where the derived public key should be written
|
|
|
|
Returns:
|
|
- public_key: Base64-encoded X25519 public key
|
|
- changed: Whether the public key file was modified
|
|
- public_key_path: Path where public key was written (if specified)
|
|
"""
|
|
|
|
|
|
def run_module():
|
|
"""
|
|
Main execution function for the x25519_pubkey Ansible module.
|
|
|
|
Handles parameter validation, private key processing, public key derivation,
|
|
and optional file output with idempotent behavior.
|
|
"""
|
|
module_args = {
|
|
'private_key_b64': {'type': 'str', 'required': False},
|
|
'private_key_path': {'type': 'path', 'required': False},
|
|
'public_key_path': {'type': 'path', 'required': False},
|
|
}
|
|
|
|
result = {
|
|
'changed': False,
|
|
'public_key': '',
|
|
}
|
|
|
|
module = AnsibleModule(
|
|
argument_spec=module_args,
|
|
required_one_of=[['private_key_b64', 'private_key_path']],
|
|
supports_check_mode=True
|
|
)
|
|
|
|
priv_b64 = None
|
|
|
|
if module.params['private_key_path']:
|
|
try:
|
|
with open(module.params['private_key_path'], 'rb') as f:
|
|
data = f.read()
|
|
try:
|
|
# First attempt: assume file contains base64 text data
|
|
# Strip whitespace from edges for text files (safe for base64 strings)
|
|
stripped_data = data.strip()
|
|
base64.b64decode(stripped_data, validate=True)
|
|
priv_b64 = stripped_data.decode()
|
|
except (base64.binascii.Error, ValueError):
|
|
# Second attempt: assume file contains raw binary data
|
|
# CRITICAL: Do NOT strip raw binary data - X25519 keys can contain
|
|
# whitespace-like bytes (0x09, 0x0A, etc.) that must be preserved
|
|
# Stripping would corrupt the key and cause "got 31 bytes" errors
|
|
if len(data) != 32:
|
|
module.fail_json(msg=f"Private key file must be either base64 or exactly 32 raw bytes, got {len(data)} bytes")
|
|
priv_b64 = base64.b64encode(data).decode()
|
|
except OSError as e:
|
|
module.fail_json(msg=f"Failed to read private key file: {e}")
|
|
else:
|
|
priv_b64 = module.params['private_key_b64']
|
|
|
|
# Validate input parameters
|
|
if not priv_b64:
|
|
module.fail_json(msg="No private key provided")
|
|
|
|
try:
|
|
priv_raw = base64.b64decode(priv_b64, validate=True)
|
|
except Exception as e:
|
|
module.fail_json(msg=f"Invalid base64 private key format: {e}")
|
|
|
|
if len(priv_raw) != 32:
|
|
module.fail_json(msg=f"Private key must decode to exactly 32 bytes, got {len(priv_raw)}")
|
|
|
|
try:
|
|
priv_key = x25519.X25519PrivateKey.from_private_bytes(priv_raw)
|
|
pub_key = priv_key.public_key()
|
|
pub_raw = pub_key.public_bytes(
|
|
encoding=serialization.Encoding.Raw,
|
|
format=serialization.PublicFormat.Raw
|
|
)
|
|
pub_b64 = base64.b64encode(pub_raw).decode()
|
|
result['public_key'] = pub_b64
|
|
|
|
if module.params['public_key_path']:
|
|
pub_path = module.params['public_key_path']
|
|
existing = None
|
|
|
|
try:
|
|
with open(pub_path) as f:
|
|
existing = f.read().strip()
|
|
except OSError:
|
|
existing = None
|
|
|
|
if existing != pub_b64:
|
|
try:
|
|
with open(pub_path, 'w') as f:
|
|
f.write(pub_b64)
|
|
result['changed'] = True
|
|
except OSError as e:
|
|
module.fail_json(msg=f"Failed to write public key file: {e}")
|
|
|
|
result['public_key_path'] = pub_path
|
|
|
|
except Exception as e:
|
|
module.fail_json(msg=f"Failed to derive public key: {e}")
|
|
|
|
module.exit_json(**result)
|
|
|
|
|
|
def main():
|
|
"""Entry point when module is executed directly."""
|
|
run_module()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|