diff options
| author | Viacheslav Hletenko <v.gletenko@vyos.io> | 2026-03-04 12:01:50 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-04 12:01:50 +0200 |
| commit | 2a4d52b2b8c39f4f77b526ce80686247dd4d0f95 (patch) | |
| tree | edcf61a295a71c50d8ca20c22a8bec3378f5e86d | |
| parent | 3030e33b26c1a895179747f2444a0cd79d59f891 (diff) | |
| parent | 673ec335b885ae1de8391dd8c48a5fe16b282db5 (diff) | |
| download | vyos-1x-2a4d52b2b8c39f4f77b526ce80686247dd4d0f95.tar.gz vyos-1x-2a4d52b2b8c39f4f77b526ce80686247dd4d0f95.zip | |
Merge pull request #4930 from giga1699/T8136
ipsec: T8136: IPSEC PPK support
| -rw-r--r-- | data/templates/ipsec/swanctl.conf.j2 | 23 | ||||
| -rw-r--r-- | data/templates/ipsec/swanctl/peer.j2 | 9 | ||||
| -rw-r--r-- | data/templates/ipsec/swanctl/remote_access.j2 | 9 | ||||
| -rw-r--r-- | interface-definitions/include/ipsec/childless.xml.i | 29 | ||||
| -rw-r--r-- | interface-definitions/include/ipsec/ppk.xml.i | 24 | ||||
| -rw-r--r-- | interface-definitions/vpn_ipsec.xml.in | 46 | ||||
| -rwxr-xr-x | smoketest/scripts/cli/test_vpn_ipsec.py | 134 | ||||
| -rwxr-xr-x | src/conf_mode/vpn_ipsec.py | 59 | ||||
| -rwxr-xr-x | src/op_mode/ipsec.py | 44 |
9 files changed, 373 insertions, 4 deletions
diff --git a/data/templates/ipsec/swanctl.conf.j2 b/data/templates/ipsec/swanctl.conf.j2 index e4e0b373d..6e143d5be 100644 --- a/data/templates/ipsec/swanctl.conf.j2 +++ b/data/templates/ipsec/swanctl.conf.j2 @@ -91,6 +91,8 @@ secrets { {% endif %} {% if psk_config.secret_type is vyos_defined('base64') %} secret = 0s{{ psk_config.secret }} +{% elif psk_config.secret_type is vyos_defined('hex') %} + secret = 0x{{ psk_config.secret }} {% elif psk_config.secret_type is vyos_defined('plaintext') %} secret = "{{ psk_config.secret }}" {% endif %} @@ -98,6 +100,27 @@ secrets { {% endfor %} {% endif %} +{% if authentication.ppk is vyos_defined %} +{% for ppk, ppk_config in authentication.ppk.items() %} + ppk-{{ ppk }} { +{% if ppk_config.id is vyos_defined %} + # ID's from auth ppk <tag> id xxx +{% for id in ppk_config.id %} +{% set gen_uuid = '' | generate_uuid4 %} + id-{{ gen_uuid }} = "{{ id }}" +{% endfor %} +{% endif %} +{% if ppk_config.secret_type is vyos_defined('base64') %} + secret = 0s{{ ppk_config.secret }} +{% elif ppk_config.secret_type is vyos_defined('hex') %} + secret = 0x{{ ppk_config.secret }} +{% elif ppk_config.secret_type is vyos_defined('plaintext') %} + secret = "{{ ppk_config.secret }}" +{% endif %} + } +{% endfor %} +{% endif %} + {% if remote_access.connection is vyos_defined %} {% for ra, ra_conf in remote_access.connection.items() if ra_conf.disable is not vyos_defined %} {% if ra_conf.authentication.server_mode is vyos_defined('pre-shared-secret') %} diff --git a/data/templates/ipsec/swanctl/peer.j2 b/data/templates/ipsec/swanctl/peer.j2 index 15b5f5956..24d037fe1 100644 --- a/data/templates/ipsec/swanctl/peer.j2 +++ b/data/templates/ipsec/swanctl/peer.j2 @@ -3,6 +3,15 @@ {# peer needs to reference the global IKE configuration for certain values #} {% set ike = ike_group[peer_conf.ike_group] %} {{ name }} { +{% if peer_conf.authentication.ppk.id is vyos_defined %} + ppk_id = {{ peer_conf.authentication.ppk.id }} +{% endif %} +{% if peer_conf.authentication.ppk.required is vyos_defined %} + ppk_required = yes +{% endif %} +{% if peer_conf.childless is vyos_defined %} + childless = {{ peer_conf.childless }} +{% endif %} proposals = {{ ike | get_esp_ike_cipher | join(',') }} version = {{ ike.key_exchange[4:] if ike.key_exchange is vyos_defined else "0" }} {% if peer_conf.virtual_address is vyos_defined %} diff --git a/data/templates/ipsec/swanctl/remote_access.j2 b/data/templates/ipsec/swanctl/remote_access.j2 index d06b3d74b..77731e827 100644 --- a/data/templates/ipsec/swanctl/remote_access.j2 +++ b/data/templates/ipsec/swanctl/remote_access.j2 @@ -3,6 +3,15 @@ {% set ike = ike_group[rw_conf.ike_group] %} {% set esp = esp_group[rw_conf.esp_group] %} ra-{{ name }} { +{% if rw_conf.authentication.ppk.id is vyos_defined %} + ppk_id = {{ rw_conf.authentication.ppk.id }} +{% endif %} +{% if rw_conf.authentication.ppk.required is vyos_defined %} + ppk_required = yes +{% endif %} +{% if rw_conf.childless is vyos_defined %} + childless = {{ rw_conf.childless }} +{% endif %} remote_addrs = %any local_addrs = {{ rw_conf.local_address if rw_conf.local_address is not vyos_defined('any') else '%any' }} # dhcp:{{ rw_conf.dhcp_interface if rw_conf.dhcp_interface is vyos_defined else 'no' }} proposals = {{ ike_group[rw_conf.ike_group] | get_esp_ike_cipher | join(',') }} diff --git a/interface-definitions/include/ipsec/childless.xml.i b/interface-definitions/include/ipsec/childless.xml.i new file mode 100644 index 000000000..76ba8d548 --- /dev/null +++ b/interface-definitions/include/ipsec/childless.xml.i @@ -0,0 +1,29 @@ +<!-- include start from ipsec/childless.xml.i --> +<leafNode name="childless"> + <properties> + <help>Enable support for childless IKE SA initiation</help> + <completionHelp> + <list>allow prefer force never</list> + </completionHelp> + <valueHelp> + <format>allow</format> + <description>Accept childless IKE SA in responder mode. Create regular IKE SA in initiator mode</description> + </valueHelp> + <valueHelp> + <format>prefer</format> + <description>In both responder and initiator modes, accept and create childless IKE SA correspondingly</description> + </valueHelp> + <valueHelp> + <format>force</format> + <description>Require the use of childless IKE SA in both responder and initiator modes</description> + </valueHelp> + <valueHelp> + <format>never</format> + <description>Disable support for childless IKE SAs when acting as a responder</description> + </valueHelp> + <constraint> + <regex>(allow|prefer|force|never)</regex> + </constraint> + </properties> +</leafNode> +<!-- include end --> diff --git a/interface-definitions/include/ipsec/ppk.xml.i b/interface-definitions/include/ipsec/ppk.xml.i new file mode 100644 index 000000000..bd420766c --- /dev/null +++ b/interface-definitions/include/ipsec/ppk.xml.i @@ -0,0 +1,24 @@ +<!-- include start from ipsec/ppk.xml.i --> +<node name="ppk"> + <properties> + <help>Post-quantum preshared key</help> + </properties> + <children> + <leafNode name="id"> + <properties> + <help>Post-quantum preshared key for this connection</help> + <valueHelp> + <format>txt</format> + <description>ID used for PPK</description> + </valueHelp> + </properties> + </leafNode> + <leafNode name="required"> + <properties> + <help>Require a valid PPK for connection to establish</help> + <valueless/> + </properties> + </leafNode> + </children> +</node> +<!-- include end --> diff --git a/interface-definitions/vpn_ipsec.xml.in b/interface-definitions/vpn_ipsec.xml.in index a2721e2a1..ffdfeaedf 100644 --- a/interface-definitions/vpn_ipsec.xml.in +++ b/interface-definitions/vpn_ipsec.xml.in @@ -45,10 +45,48 @@ <properties> <help>Secret type</help> <completionHelp> - <list>base64 plaintext</list> + <list>base64 hex plaintext</list> </completionHelp> <constraint> - <regex>(base64|plaintext)</regex> + <regex>(base64|hex|plaintext)</regex> + </constraint> + </properties> + <defaultValue>plaintext</defaultValue> + </leafNode> + </children> + </tagNode> + <tagNode name="ppk"> + <properties> + <help>Post-quantum preshared key name</help> + </properties> + <children> + <leafNode name="id"> + <properties> + <help>ID for PPK</help> + <valueHelp> + <format>txt</format> + <description>ID used for PPK</description> + </valueHelp> + <multi/> + </properties> + </leafNode> + <leafNode name="secret"> + <properties> + <help>Post-quantum preshared secret key</help> + <valueHelp> + <format>txt</format> + <description>Post-quantum preshared secret key</description> + </valueHelp> + </properties> + </leafNode> + <leafNode name="secret-type"> + <properties> + <help>Secret type</help> + <completionHelp> + <list>base64 hex plaintext</list> + </completionHelp> + <constraint> + <regex>(base64|hex|plaintext)</regex> </constraint> </properties> <defaultValue>plaintext</defaultValue> @@ -896,9 +934,11 @@ </properties> <defaultValue>x509</defaultValue> </leafNode> + #include <include/ipsec/ppk.xml.i> #include <include/ipsec/authentication-pre-shared-secret.xml.i> </children> </node> + #include <include/ipsec/childless.xml.i> #include <include/generic-description.xml.i> #include <include/generic-disable-node.xml.i> #include <include/ipsec/esp-group.xml.i> @@ -1113,6 +1153,7 @@ </properties> <children> #include <include/ipsec/authentication-id.xml.i> + #include <include/ipsec/ppk.xml.i> #include <include/ipsec/authentication-rsa.xml.i> #include <include/ipsec/authentication-x509.xml.i> <leafNode name="mode"> @@ -1156,6 +1197,7 @@ </leafNode> </children> </node> + #include <include/ipsec/childless.xml.i> <leafNode name="connection-type"> <properties> <help>Connection type</help> diff --git a/smoketest/scripts/cli/test_vpn_ipsec.py b/smoketest/scripts/cli/test_vpn_ipsec.py index 9461cd183..a15532a96 100755 --- a/smoketest/scripts/cli/test_vpn_ipsec.py +++ b/smoketest/scripts/cli/test_vpn_ipsec.py @@ -47,6 +47,7 @@ vif = '100' esp_group = 'MyESPGroup' ike_group = 'MyIKEGroup' secret = 'MYSECRETKEY' +ppk_secret_hex = '55c2ebca1bada7ac0e4e1390a8dbb563cefea0c7bd59f4f2c86a627f5927fb90' PROCESS_NAME = 'charon-systemd' regex_uuid4 = '[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}' @@ -668,6 +669,139 @@ class TestVPNIPsec(VyOSUnitTestSHIM.TestCase): for line in swanctl_conf_lines: self.assertIn(line, swanctl_conf) + def test_site_to_site_nist_800_77_cnsa_1_with_ppk(self): + # Setup IKE group + self.cli_set(base_path + ['ike-group', 'cnsa1-ike', 'key-exchange', 'ikev2']) + self.cli_set(base_path + ['ike-group', 'cnsa1-ike', 'lifetime', '86400']) + self.cli_set( + base_path + ['ike-group', 'cnsa1-ike', 'proposal', '10', 'dh-group', '20'] + ) + self.cli_set( + base_path + + ['ike-group', 'cnsa1-ike', 'proposal', '10', 'encryption', 'aes256gcm128'] + ) + self.cli_set( + base_path + ['ike-group', 'cnsa1-ike', 'proposal', '10', 'hash', 'sha384'] + ) + self.cli_set( + base_path + ['ike-group', 'cnsa1-ike', 'proposal', '10', 'prf', 'prfsha384'] + ) + + # Setup ESP group + self.cli_set(base_path + ['esp-group', 'cnsa1-esp', 'lifetime', '28800']) + self.cli_set(base_path + ['esp-group', 'cnsa1-esp', 'mode', 'tunnel']) + self.cli_set(base_path + ['esp-group', 'cnsa1-esp', 'pfs', 'dh-group20']) + self.cli_set( + base_path + + ['esp-group', 'cnsa1-esp', 'proposal', '10', 'encryption', 'aes256gcm128'] + ) + self.cli_set( + base_path + ['esp-group', 'cnsa1-esp', 'proposal', '10', 'hash', 'sha384'] + ) + + local_address = '192.0.2.10' + + # vpn ipsec auth psk <tag> id <x.x.x.x> + self.cli_set( + base_path + ['authentication', 'psk', connection_name, 'id', local_id] + ) + self.cli_set( + base_path + ['authentication', 'psk', connection_name, 'id', remote_id] + ) + self.cli_set( + base_path + ['authentication', 'psk', connection_name, 'id', local_address] + ) + self.cli_set( + base_path + ['authentication', 'psk', connection_name, 'id', peer_ip] + ) + self.cli_set( + base_path + ['authentication', 'psk', connection_name, 'secret', secret] + ) + + # vpn ipsec auth ppk <tag> id <name> + self.cli_set( + base_path + ['authentication', 'ppk', connection_name, 'id', 'ppk-test'] + ) + self.cli_set( + base_path + + ['authentication', 'ppk', connection_name, 'secret', ppk_secret_hex] + ) + self.cli_set( + base_path + ['authentication', 'ppk', connection_name, 'secret-type', 'hex'] + ) + + # Site to site + peer_base_path = base_path + ['site-to-site', 'peer', connection_name] + + self.cli_set(peer_base_path + ['authentication', 'mode', 'pre-shared-secret']) + + # Require use of valid PPK + self.cli_set(peer_base_path + ['authentication', 'ppk', 'id', 'ppk-test']) + self.cli_set(peer_base_path + ['authentication', 'ppk', 'required']) + + # Set childless IKE_INIT to prefer + self.cli_set(peer_base_path + ['childless', 'prefer']) + + self.cli_set(peer_base_path + ['default-esp-group', 'cnsa1-esp']) + self.cli_set(peer_base_path + ['ike-group', 'cnsa1-ike']) + self.cli_set(peer_base_path + ['local-address', local_address]) + + self.cli_set(peer_base_path + ['remote-address', peer_ip]) + self.cli_set( + peer_base_path + ['tunnel', '1', 'local', 'prefix', '172.16.10.0/24'] + ) + self.cli_set( + peer_base_path + ['tunnel', '1', 'remote', 'prefix', '172.17.10.0/24'] + ) + + self.cli_commit() + + # Verify strongSwan configuration + swanctl_conf = read_file(swanctl_file) + swanctl_conf_lines = [ + f'ppk_id = ppk-test', + f'ppk_required = yes', + f'childless = prefer', + f'version = 2', + f'auth = psk', + f'rekey_time = 86400s', + f'proposals = aes256gcm128-sha384-prfsha384-ecp384', + f'esp_proposals = aes256gcm128-sha384-ecp384', + f'life_time = 28800s', # default value + f'local_addrs = {local_address} # dhcp:no', + f'remote_addrs = {peer_ip}', + f'mode = tunnel', + f'{connection_name}-tunnel-1', + f'local_ts = 172.16.10.0/24', + f'remote_ts = 172.17.10.0/24', + f'mode = tunnel', + f'replay_window = 32', + ] + for line in swanctl_conf_lines: + self.assertIn(line, swanctl_conf) + + # if dpd is not specified it should not be enabled (see T6599) + swanctl_unexpected_lines = [ + 'dpd_timeout', + 'dpd_delay', + ] + + for unexpected_line in swanctl_unexpected_lines: + self.assertNotIn(unexpected_line, swanctl_conf) + + swanctl_secrets_lines = [ + f'id-{regex_uuid4} = "{local_id}"', + f'id-{regex_uuid4} = "{remote_id}"', + f'id-{regex_uuid4} = "{local_address}"', + f'id-{regex_uuid4} = "{peer_ip}"', + f'secret = "{secret}"', + f'ppk-{connection_name}', + f'id-{regex_uuid4} = "ppk-test"', + f'secret = 0x{ppk_secret_hex}', + ] + for line in swanctl_secrets_lines: + self.assertRegex(swanctl_conf, fr'{line}') + def test_dmvpn(self): ike_lifetime = '3600' diff --git a/src/conf_mode/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py index c6492f513..3b5918218 100755 --- a/src/conf_mode/vpn_ipsec.py +++ b/src/conf_mode/vpn_ipsec.py @@ -54,6 +54,7 @@ from vyos.utils.vti_updown_db import vti_updown_db_exists from vyos.utils.vti_updown_db import open_vti_updown_db_for_create_or_update from vyos.utils.vti_updown_db import remove_vti_updown_db from vyos import ConfigError +from vyos.base import Warning from vyos import airbag airbag.enable() @@ -261,11 +262,29 @@ def verify(ipsec): if not ipsec or 'deleted' in ipsec: return + # T8136 PPK support; keep a list of PPK IDs + ppk_ids = [] + if 'authentication' in ipsec: if 'psk' in ipsec['authentication']: for psk, psk_config in ipsec['authentication']['psk'].items(): if 'id' not in psk_config or 'secret' not in psk_config: - raise ConfigError(f'Authentication psk "{psk}" missing "id" or "secret"') + raise ConfigError( + f'Authentication psk "{psk}" missing "id" or "secret"' + ) + # T8136 PPK Support; Check that PPK has an ID and secret defined, and ID is unique + if 'ppk' in ipsec['authentication']: + for ppk, ppk_config in ipsec['authentication']['ppk'].items(): + if 'id' not in ppk_config: + raise ConfigError(f'Authentication PPK "{ppk}" missing "id"') + if 'secret' not in ppk_config: + raise ConfigError(f'Authentication PPK "{ppk}" missing "secret"') + for ppk_id in ppk_config['id']: + if ppk_id in ppk_ids: + raise ConfigError( + f'Authentication PPK "{ppk}" has duplicate ID "{ppk_id}" from another PPK. IDs should be unique.' + ) + ppk_ids.append(ppk_id) if 'interface' in ipsec: tmp = re.compile(dynamic_interface_pattern) @@ -445,6 +464,25 @@ def verify(ipsec): elif 'pool' not in ipsec['remote_access'] or pool not in ipsec['remote_access']['pool']: raise ConfigError(f'Requested pool "{pool}" does not exist!') + # T8136 IPSEC PPK Support + # PPKs and Childless only works with IKEv2. Check that ike-group is v2 if either option is enabled. Check that PPK ID was actually defined in authentication. Recommend use of childless when using PPKs if not already configured. + if 'ppk' in ra_conf['authentication']: + ike = ra_conf['ike_group'] + if dict_search(f'ike_group.{ike}.key_exchange', ipsec) != 'ikev2': + raise ConfigError( + f'Incorrect configuration in IKE group "{ike}": post-quantum pre-shared keys require explicit IKEv2 usage.' + ) + if 'childless' not in ra_conf: + Warning( + 'It is recommended to use childless IKE SAs when using PPKs' + ) + if 'childless' in ra_conf: + ike = ra_conf['ike_group'] + if dict_search(f'ike_group.{ike}.key_exchange', ipsec) != 'ikev2': + raise ConfigError( + f'Incorrect configuration in IKE group "{ike}": childless IKE SAs can only be used with IKEv2.' + ) + if 'pool' in ipsec['remote_access']: pool_networks = [] for pool, pool_config in ipsec['remote_access']['pool'].items(): @@ -653,6 +691,25 @@ def verify(ipsec): f'for ESP proposal {proposal} on tunnel {tunnel} for site-to-site peer {peer} with VPP' ) + # T8136 IPSEC PPK Support + # PPKs and Childless only works with IKEv2. Check that ike-group is v2 if either option is enabled. Check that PPK ID was actually defined in authentication. Recommend use of childless when using PPKs if not already configured. + if 'ppk' in peer_conf['authentication']: + ike = peer_conf['ike_group'] + if dict_search(f'ike_group.{ike}.key_exchange', ipsec) != 'ikev2': + raise ConfigError( + f'Post-quantum preshared keys must be used with IKEv2! Please configure IKEv2 key-exchange in ike-group "{ike}".' + ) + if 'childless' not in peer_conf: + Warning( + 'It is recommended to use childless IKE SAs when using PPKs' + ) + if 'childless' in peer_conf: + ike = peer_conf['ike_group'] + if dict_search(f'ike_group.{ike}.key_exchange', ipsec) != 'ikev2': + raise ConfigError( + f'Childless IKE SAs be used with IKEv2! Please configure IKEv2 key-exchange in ike-group "{ike}".' + ) + def cleanup_pki_files(): for path in [CERT_PATH, CA_PATH, CRL_PATH, KEY_PATH, PUBKEY_PATH]: diff --git a/src/op_mode/ipsec.py b/src/op_mode/ipsec.py index 4ff035416..9e45f3265 100755 --- a/src/op_mode/ipsec.py +++ b/src/op_mode/ipsec.py @@ -229,6 +229,28 @@ def _get_parent_sa_state(connection_name: str, data: list) -> str: ike_state = 'up' return ike_state +def _get_parent_ppk_state(connection_name: str, data: list) -> str: + """Get paren PPK state by connection name + + Args: + connection_name (str): Connection name + data (list): List of current SAs from vici + + Returns: + Parent PPK state + """ + ppk_state = 'no' + if not data: + return ppk_state + for sa in data: + # check if parent PPK exists + for connection, connection_conf in sa.items(): + if connection_name != connection: + continue + if 'ppk' in connection_conf and connection_conf['ppk'].lower() == 'yes': + ppk_state = 'yes' + return ppk_state + def _get_child_sa_state( connection_name: str, tunnel_name: str, data: list, mode: str @@ -335,6 +357,17 @@ def _get_raw_data_connections(list_connections: list, list_sas: list) -> list: base_list['local_id'] = conn_conf.get('local-1', '').get('id') base_list['remote_id'] = conn_conf.get('remote-1', '').get('id') base_list['version'] = conn_conf.get('version', 'IKE') + if conn_conf.get('ppk_id'): + if conn_conf.get('ppk_required') == 'yes': + base_list['ppk'] = 'req/' + _get_parent_ppk_state( + connection, list_sas + ) + else: + base_list['ppk'] = 'opt/' + _get_parent_ppk_state( + connection, list_sas + ) + else: + base_list['ppk'] = 'none/' + _get_parent_ppk_state(connection, list_sas) base_list['children'] = [] children = conn_conf['children'] for tunnel, tun_options in children.items(): @@ -400,6 +433,8 @@ def _get_formatted_output_conections(data): f'{entry["ike_proposal"]["hash"]}/' f'{entry["ike_proposal"]["dh"]}' ) + ppk = entry['ppk'] + connections.append( [ ike_name, @@ -411,6 +446,7 @@ def _get_formatted_output_conections(data): local_id, remote_id, proposal, + ppk, ] ) for tun in entry['children']: @@ -428,6 +464,7 @@ def _get_formatted_output_conections(data): f'{tun["esp_proposal"]["hash"]}/' f'{tun["esp_proposal"]["dh"]}' ) + ppk = '-' connections.append( [ tun_name, @@ -439,6 +476,7 @@ def _get_formatted_output_conections(data): local_id, remote_id, proposal, + ppk, ] ) connection_headers = [ @@ -451,8 +489,12 @@ def _get_formatted_output_conections(data): 'Local id', 'Remote id', 'Proposal', + 'PPK', ] - output = tabulate(connections, connection_headers, numalign='left') + output = ( + 'PPK Codes: none - Not Configured, opt - PPK is Optional, req - PPK is required, no - PPK not negotiated, yes - PPK negotiated\n' + + tabulate(connections, connection_headers, numalign='left') + ) return output |
