From 74c24d7bc6f87c790f34606fd8ee09605697fad4 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Mon, 26 Oct 2026 20:37:58 +0100 Subject: [PATCH] feature: the web service gives Apple VPN profiles that set up IKEv2 road-warrior access on macOS, iOS and iPadOS. --- ADR.md | 19 +++++ README.md | 19 +++++ debian-package/debian/changelog | 14 ++++ .../src/usr/lib/vpn-router/vpnrouter_users.py | 72 +++++++++++++++++++ debian-package/src/usr/lib/vpn-router/web | 22 ++++-- 5 files changed, 142 insertions(+), 4 deletions(-) diff --git a/ADR.md b/ADR.md index 750b72e..e1e6ec9 100644 --- a/ADR.md +++ b/ADR.md @@ -1182,3 +1182,22 @@ their client networks, and removes the earlier drop-in. Consequences: Clients resolve the names resolved resolves, Azure's internal names included, but not the router's synthesized ones. The package depends on dnsmasq-base. PowerDNS and the clients' resolver share no address. + +## ADR-44: Apple VPN profiles are unsigned IKEv2 profiles with a PKCS#12 encrypted by a password given per download + +Status: Accepted + +Context: macOS, iOS and iPadOS connect to the road-warrior access with their built-in IKEv2 client, +configured by a configuration profile. The router keeps each user's certificate and key. Apple's +clients read a PKCS#12 only when it is encrypted with 3DES and SHA-1, send one proposal per SA, +and show an unsigned profile as unverified. + +Decision: The web service builds the profile with `plistlib` and python3-cryptography: the P2S CA +and the root, the user's PKCS#12 encrypted with a password the caller gives with each request, +and an IKEv2 payload matching `road-warrior.conf` (EAP-TLS with the certificate, AES-256, +SHA2-256, group 14). The profile is not signed. Admins get any user's profile through `/users`, +users their own through `/account` with their current password and code. + +Consequences: The password is never stored; the device asks for it when the profile is installed. +A profile shows as unverified. A changed proposal of `road-warrior.conf` must be changed in the +profile too. WireGuard is not part of it. diff --git a/README.md b/README.md index 434b682..2c62041 100644 --- a/README.md +++ b/README.md @@ -776,6 +776,23 @@ the file. The certificate is also accepted by road-warrior (IKEv2 EAP-TLS) access, which checks certificates only, so `disable` does not affect it; `remove` revokes the certificate for both. +### Apple VPN profile + +While `p2s.enabled` is `true` and `wan.local_fqdn` is set, `/users//apple-profile` and a +user's own `/account/apple-profile` give an unsigned Apple configuration profile (`.mobileconfig`) +that sets up road-warrior access in the built-in VPN client of macOS, iOS and iPadOS. It holds: + +- the P2S CA and the root CA, the root installed as a trusted root; +- the user's certificate and key as a PKCS#12 encrypted with the password of the request, with + 3DES and SHA-1, the only encryption Apple's clients read; the device asks for the password when + the profile is installed, and the router does not keep it; +- an IKEv2 VPN to `wan.local_fqdn`, with the user name as the local identity, the certificate over + EAP-TLS, and AES-256, SHA2-256 and group 14 for IKE and ESP, as the router's road-warrior + connection takes them. + +The password is 8 to 128 characters. A user asking for the own profile also gives the current +password and, when enrolled, a code. + ### Console login and SSH A member of `admins` enrolled for TOTP may have console login: the Router Manager's Shell page @@ -1181,6 +1198,7 @@ and answer bodies are JSON; a request body is at most 65536 bytes. | `POST /users//unlock` | | the user | | `DELETE /users/` | | `{"name", "removed": true}` | | `GET /users//profile` | | `{"name", "profile"}`, the OpenVPN client profile | +| `POST /users//apple-profile` | `{"password"}` | `{"name", "profile"}`, the Apple configuration profile of IKEv2 road-warrior access (see Apple VPN profile) | | `POST /users//otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored | | `PUT /users//otp` | `{"secret", "code"}` | the user, once the code matches the secret | | `DELETE /users//otp` | | the user | @@ -1237,6 +1255,7 @@ router manages them, their display name and description included. A failure is o | `POST /account/passkeys` | `{"current_password", "current_code"}` | `{"id", "options"}`: a pending registration, valid 2 minutes, and the options of `navigator.credentials.create` in their JSON form | | `PUT /account/passkeys` | `{"id", "credential", "label"?}`, `credential` the browser's answer as its `toJSON()` gives it; `label` at most 64 characters, `Passkey` without one | the user | | `DELETE /account/passkeys/` | | the user | +| `POST /account/apple-profile` | `{"current_password", "current_code", "password"}` | `{"name", "profile"}`, the user's Apple configuration profile of IKEv2 road-warrior access (see Apple VPN profile) | `current_code` is needed only for a user enrolled for TOTP; removing a passkey needs neither. A wrong current password or code gets 403 and counts as a failed login; slapd accepts each code once, so a code used to sign in is not diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index 34e691c..46656c0 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -1,3 +1,17 @@ +vpn-router (0.32.0-1) unstable; urgency=medium + + feature: the web service gives Apple VPN profiles that set up IKEv2 road-warrior access on macOS, iOS and iPadOS. + + * POST /users//apple-profile and POST /account/apple-profile answer + an unsigned .mobileconfig with the P2S CA and the root CA, the user's + certificate and key as a PKCS#12 encrypted with the password of the + request, and an IKEv2 VPN to wan.local_fqdn with EAP-TLS and the + proposals of the road-warrior connection. + * A user's own profile needs the current password and, when enrolled, a + code. + + -- Slawomir Koszewski Tue, 27 Oct 2026 14:00:00 +0100 + vpn-router (0.31.3-1) unstable; urgency=medium fix: WireGuard clients may use the router's DNS resolver, which runs while WireGuard does, and /remote-access names its addresses for their configuration. diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py index 5ff500f..68375ee 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py @@ -65,16 +65,22 @@ import fcntl import grp import ipaddress import json +import plistlib import pwd import re import subprocess import time import urllib.parse +import uuid import ldap import ldap.filter import ldap.modlist import pyotp +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, rsa +from cryptography.hazmat.primitives.serialization import pkcs12 import vpnrouter import vpnrouter_ldap @@ -807,6 +813,72 @@ def profile(connection, name): return {'name': name, 'profile': text} +def apple_profile(connection, name, password): + """The Apple configuration profile (.mobileconfig, unsigned) of the user's IKEv2 road-warrior access: the root CA and + the P2S CA, the user's certificate and key as a PKCS#12 encrypted with the password, and the VPN. + + The connection matches road-warrior.conf: the router's wan.local_fqdn as address and identity, the user name, a + DNS name of the certificate, as the local identity, the certificate over EAP-TLS, and AES-256, SHA2-256 and group 14 + for IKE and ESP. The PKCS#12 uses 3DES and SHA-1, the only encryption Apple's clients read; they ask for the + password when the profile is installed. + """ + entry(connection, name) + if not isinstance(password, str) or not PASSWORD_MIN <= len(password) <= PASSWORD_MAX: + raise Failure(f'the password of the profile must have {PASSWORD_MIN} to {PASSWORD_MAX} characters', 2) + cfg = vpnrouter.load_config() + fqdn = cfg['wan']['local_fqdn'].strip() + if not cfg['p2s']['enabled'] or not fqdn: + raise Failure('IKEv2 road-warrior access is not enabled', 2) + credentials = vpnrouter_pki.user_credentials(name) + if credentials is None: + raise Failure(f'user {name} has no certificate') + try: + chain = x509.load_pem_x509_certificates(vpnrouter_pki.bundle('p2s')['pem'].encode()) + except vpnrouter_pki.Failure as failure: + raise Failure(str(failure), 4) + cert = x509.load_pem_x509_certificate(credentials[0]) + key = serialization.load_pem_private_key(credentials[1], password=None) + encryption = (serialization.PrivateFormat.PKCS12.encryption_builder().kdf_rounds(2048) + .key_cert_algorithm(pkcs12.PBES.PBESv1SHA1And3KeyTripleDESCBC).hmac_hash(hashes.SHA1()) + .build(password.encode())) + bundle = pkcs12.serialize_key_and_certificates(name.encode(), key, cert, None, encryption) + certificate_type = ('RSA' if isinstance(key, rsa.RSAPrivateKey) + else 'ECDSA384' if isinstance(key, ec.EllipticCurvePrivateKey) and key.curve.key_size == 384 + else 'ECDSA256') + identifier = f'{".".join(reversed(fqdn.split(".")))}.vpn-router.{name}' + ids = {kind: str(uuid.uuid5(uuid.NAMESPACE_URL, f'{identifier}.{kind}')) for kind in ('profile', 'pkcs12', 'vpn')} + proposal = {'EncryptionAlgorithm': 'AES-256', 'IntegrityAlgorithm': 'SHA2-256', 'DiffieHellmanGroup': 14, + 'LifeTimeInMinutes': 60} + payloads = [{'PayloadType': 'com.apple.security.root' if index == len(chain) - 1 else 'com.apple.security.pkcs1', + 'PayloadVersion': 1, 'PayloadIdentifier': f'{identifier}.ca{index}', + 'PayloadUUID': str(uuid.uuid5(uuid.NAMESPACE_URL, f'{identifier}.ca{index}')), + 'PayloadDisplayName': vpnrouter_pki.common_name(item), + 'PayloadContent': item.public_bytes(serialization.Encoding.DER)} + for index, item in enumerate(chain)] + payloads.append({'PayloadType': 'com.apple.security.pkcs12', 'PayloadVersion': 1, + 'PayloadIdentifier': f'{identifier}.pkcs12', 'PayloadUUID': ids['pkcs12'], + 'PayloadDisplayName': f'{name} ({fqdn})', 'PayloadContent': bundle}) + payloads.append({ + 'PayloadType': 'com.apple.vpn.managed', 'PayloadVersion': 1, 'PayloadIdentifier': f'{identifier}.vpn', + 'PayloadUUID': ids['vpn'], 'PayloadDisplayName': f'VPN Router {fqdn}', 'UserDefinedName': f'VPN Router {fqdn}', + 'VPNType': 'IKEv2', + 'IKEv2': { + 'RemoteAddress': fqdn, 'RemoteIdentifier': fqdn, 'LocalIdentifier': name, + 'AuthenticationMethod': 'Certificate', 'ExtendedAuthEnabled': 1, 'PayloadCertificateUUID': ids['pkcs12'], + 'CertificateType': certificate_type, + 'ServerCertificateIssuerCommonName': vpnrouter_pki.common_name(chain[0]), 'ServerCertificateCommonName': fqdn, + 'IKESecurityAssociationParameters': {**proposal, 'LifeTimeInMinutes': 480}, + 'ChildSecurityAssociationParameters': proposal, + 'EnablePFS': 0, 'DeadPeerDetectionRate': 'Medium', 'DisableMOBIKE': 0, 'DisableRedirect': 0, + }, + }) + document = {'PayloadType': 'Configuration', 'PayloadVersion': 1, 'PayloadIdentifier': identifier, + 'PayloadUUID': ids['profile'], 'PayloadDisplayName': f'VPN Router {fqdn} ({name})', + 'PayloadDescription': f'IKEv2 access of {name} to {fqdn}', 'PayloadRemovalDisallowed': False, + 'PayloadContent': payloads} + return {'name': name, 'profile': plistlib.dumps(document).decode()} + + def group_dn(name): return f'cn={name},{vpnrouter_ldap.GROUPS}' diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web index 6db13e3..e3b31d1 100644 --- a/debian-package/src/usr/lib/vpn-router/web +++ b/debian-package/src/usr/lib/vpn-router/web @@ -565,7 +565,10 @@ def users(state, method, path, body): POST /users//unlock end a lockout after failed logins DELETE /users/ remove the user and revoke the certificate GET /users//profile the OpenVPN client profile - POST /users//otp a new TOTP secret and its otpauth URI; nothing is stored + POST /users//apple-profile + the Apple configuration profile of IKEv2 road-warrior access: {"password"}, which + encrypts the user's key in it; {"name", "profile"} + POST /users//otp a new TOTP secret and its otpauth URI; nothing is stored PUT /users//otp store a TOTP secret: {"secret", "code"}, code a code of the secret DELETE /users//otp remove the TOTP token DELETE /users//passkeys remove all the user's passkeys @@ -592,6 +595,8 @@ def users(state, method, path, body): ('POST', 3, 'unlock'): lambda connection: vpnrouter_users.unlock(connection, name), ('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name), ('GET', 3, 'profile'): lambda connection: vpnrouter_users.profile(connection, name), + ('POST', 3, 'apple-profile'): lambda connection: vpnrouter_users.apple_profile(connection, name, + body.get('password')), ('POST', 3, 'otp'): lambda connection: vpnrouter_users.otp_new(connection, name), ('PUT', 3, 'otp'): lambda connection: vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')), ('DELETE', 3, 'otp'): lambda connection: vpnrouter_users.otp_remove(connection, name), @@ -1043,8 +1048,13 @@ def account(name, method, path, body): PUT /account/passkeys store the passkey: {"id", "credential", "label"?}, credential the browser's answer DELETE /account/passkeys/ remove a passkey + POST /account/apple-profile + the user's Apple configuration profile of IKEv2 road-warrior access: + {"current_password", "current_code"?, "password"}, password encrypting the user's key in + it; {"name", "profile"} - A change but the removal of a passkey needs the current password and, for an enrolled user, a current code; a wrong + A change but the removal of a passkey, and the Apple profile, need the current password and, for an enrolled user, a + current code; a wrong one is 403 and counts as a failed login. A password change revokes the user's refresh tokens. """ if body == 'invalid' or (body is not None and not isinstance(body, dict)): @@ -1053,12 +1063,14 @@ def account(name, method, path, body): route = (method, path.rstrip('/')) removing = method == 'DELETE' and path.startswith('/account/passkeys/') and len(path.strip('/').split('/')) == 3 if not removing and route not in (('GET', '/account'), ('PUT', '/account/password'), ('POST', '/account/otp'), - ('PUT', '/account/otp'), ('POST', '/account/passkeys'), ('PUT', '/account/passkeys')): + ('PUT', '/account/otp'), ('POST', '/account/passkeys'), ('PUT', '/account/passkeys'), + ('POST', '/account/apple-profile')): return 404, {'error': f'no route {method} {path}'} try: if route == ('PUT', '/account/password'): vpnrouter_users.check_password(body.get('password')) - if route in (('PUT', '/account/password'), ('PUT', '/account/otp'), ('POST', '/account/passkeys')): + if route in (('PUT', '/account/password'), ('PUT', '/account/otp'), ('POST', '/account/passkeys'), + ('POST', '/account/apple-profile')): vpnrouter_users.login(name, body.get('current_password'), body.get('current_code')) with vpnrouter_users.session() as connection: if route == ('GET', '/account'): @@ -1068,6 +1080,8 @@ def account(name, method, path, body): document = vpnrouter_users.passwd(connection, name, body['password']) elif route == ('POST', '/account/otp'): document = vpnrouter_users.otp_new(connection, name) + elif route == ('POST', '/account/apple-profile'): + document = vpnrouter_users.apple_profile(connection, name, body.get('password')) elif route == ('POST', '/account/passkeys'): document = vpnrouter_passkeys.register_begin(connection, name) elif route == ('PUT', '/account/passkeys'):