Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions library/x25519_pubkey.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
#!/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()
56 changes: 56 additions & 0 deletions roles/common/tasks/bsd_ipv6_facts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
# BSD systems return IPv6 addresses in the order they were added to the interface,
# not sorted by scope like Linux does. This means ansible_default_ipv6 often contains
# a link-local address (fe80::) instead of a global address, which breaks certificate
# generation due to the %interface suffix.
#
# This task file creates a fact with the first global IPv6 address found.

- name: Initialize all_ipv6_addresses as empty list
set_fact:
all_ipv6_addresses: []

- name: Get all IPv6 addresses for the default interface
set_fact:
all_ipv6_addresses: "{{ ansible_facts[ansible_default_ipv6.interface]['ipv6'] | default([]) }}"
when:
- ansible_default_ipv6 is defined
- ansible_default_ipv6.interface is defined
- ansible_facts[ansible_default_ipv6.interface] is defined

- name: Find first global IPv6 address from interface-specific addresses
set_fact:
global_ipv6_address: "{{ item.address }}"
global_ipv6_prefix: "{{ item.prefix }}"
loop: "{{ all_ipv6_addresses }}"
when:
- all_ipv6_addresses | length > 0
- item.address is defined
- not item.address.startswith('fe80:') # Filter out link-local addresses
- "'%' not in item.address" # Ensure no interface suffix
- global_ipv6_address is not defined # Only set once
loop_control:
label: "{{ item.address | default('no address') }}"

- name: Find first global IPv6 address from ansible_all_ipv6_addresses
set_fact:
global_ipv6_address: "{{ item | regex_replace('%.*', '') }}"
global_ipv6_prefix: "128" # Assume /128 for addresses from this list
loop: "{{ ansible_all_ipv6_addresses | default([]) }}"
when:
- global_ipv6_address is not defined
- ansible_all_ipv6_addresses is defined
- not item.startswith('fe80:')

- name: Override ansible_default_ipv6 with global address on BSD
set_fact:
ansible_default_ipv6: "{{ ansible_default_ipv6 | combine({'address': global_ipv6_address, 'prefix': global_ipv6_prefix}) }}"
when:
- global_ipv6_address is defined
- ansible_default_ipv6 is defined
- ansible_default_ipv6.address.startswith('fe80:') or '%' in ansible_default_ipv6.address

- name: Debug IPv6 address selection
debug:
msg: "Selected IPv6 address: {{ ansible_default_ipv6.address | default('none') }}"
when: algo_debug | default(false) | bool
4 changes: 4 additions & 0 deletions roles/common/tasks/freebsd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
- name: Gather additional facts
import_tasks: facts.yml

- name: Fix IPv6 address selection on BSD
import_tasks: bsd_ipv6_facts.yml
when: ipv6_support | default(false) | bool

- name: Set OS specific facts
set_fact:
config_prefix: /usr/local/
Expand Down Expand Up @@ -46,7 +50,7 @@
with_items:
- "{{ tools|default([]) }}"

- name: Loopback included into the rc config

Check warning on line 53 in roles/common/tasks/freebsd.yml

View workflow job for this annotation

GitHub Actions / Linting

risky-file-permissions

File permissions unset or incorrect.
blockinfile:
dest: /etc/rc.conf
create: true
Expand Down
Loading
Loading