feature: the web service gives Apple VPN profiles that set up IKEv2 road-warrior access on macOS, iOS and iPadOS.
Release / release (push) Successful in 14s

This commit is contained in:
slawek committed 2026-10-26 20:37:58 +01:00
1 parent 770332728e
commit 74c24d7bc6
5 files changed
+142 -4

No files matched your search

+19
View File
@@ -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 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' not the router's synthesized ones. The package depends on dnsmasq-base. PowerDNS and the clients'
resolver share no address. 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.
+19
View File
@@ -776,6 +776,23 @@ the file.
The certificate is also accepted by road-warrior (IKEv2 EAP-TLS) access, which checks 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. 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/<name>/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 ### Console login and SSH
A member of `admins` enrolled for TOTP may have console login: the Router Manager's Shell page 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/<name>/unlock` | | the user | | `POST /users/<name>/unlock` | | the user |
| `DELETE /users/<name>` | | `{"name", "removed": true}` | | `DELETE /users/<name>` | | `{"name", "removed": true}` |
| `GET /users/<name>/profile` | | `{"name", "profile"}`, the OpenVPN client profile | | `GET /users/<name>/profile` | | `{"name", "profile"}`, the OpenVPN client profile |
| `POST /users/<name>/apple-profile` | `{"password"}` | `{"name", "profile"}`, the Apple configuration profile of IKEv2 road-warrior access (see Apple VPN profile) |
| `POST /users/<name>/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored | | `POST /users/<name>/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored |
| `PUT /users/<name>/otp` | `{"secret", "code"}` | the user, once the code matches the secret | | `PUT /users/<name>/otp` | `{"secret", "code"}` | the user, once the code matches the secret |
| `DELETE /users/<name>/otp` | | the user | | `DELETE /users/<name>/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 | | `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 | | `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/<id>` | | the user | | `DELETE /account/passkeys/<id>` | | 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 `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 403 and counts as a failed login; slapd accepts each code once, so a code used to sign in is not
+14
View File
@@ -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/<name>/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 <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 14:00:00 +0100
vpn-router (0.31.3-1) unstable; urgency=medium 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. fix: WireGuard clients may use the router's DNS resolver, which runs while WireGuard does, and /remote-access names its addresses for their configuration.
@@ -65,16 +65,22 @@ import fcntl
import grp import grp
import ipaddress import ipaddress
import json import json
import plistlib
import pwd import pwd
import re import re
import subprocess import subprocess
import time import time
import urllib.parse import urllib.parse
import uuid
import ldap import ldap
import ldap.filter import ldap.filter
import ldap.modlist import ldap.modlist
import pyotp 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
import vpnrouter_ldap import vpnrouter_ldap
@@ -807,6 +813,72 @@ def profile(connection, name):
return {'name': name, 'profile': text} 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): def group_dn(name):
return f'cn={name},{vpnrouter_ldap.GROUPS}' return f'cn={name},{vpnrouter_ldap.GROUPS}'
+18 -4
View File
@@ -565,7 +565,10 @@ def users(state, method, path, body):
POST /users/<name>/unlock end a lockout after failed logins POST /users/<name>/unlock end a lockout after failed logins
DELETE /users/<name> remove the user and revoke the certificate DELETE /users/<name> remove the user and revoke the certificate
GET /users/<name>/profile the OpenVPN client profile GET /users/<name>/profile the OpenVPN client profile
POST /users/<name>/otp a new TOTP secret and its otpauth URI; nothing is stored POST /users/<name>/apple-profile
the Apple configuration profile of IKEv2 road-warrior access: {"password"}, which
encrypts the user's key in it; {"name", "profile"}
POST /users/<name>/otp a new TOTP secret and its otpauth URI; nothing is stored
PUT /users/<name>/otp store a TOTP secret: {"secret", "code"}, code a code of the secret PUT /users/<name>/otp store a TOTP secret: {"secret", "code"}, code a code of the secret
DELETE /users/<name>/otp remove the TOTP token DELETE /users/<name>/otp remove the TOTP token
DELETE /users/<name>/passkeys remove all the user's passkeys DELETE /users/<name>/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), ('POST', 3, 'unlock'): lambda connection: vpnrouter_users.unlock(connection, name),
('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name), ('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name),
('GET', 3, 'profile'): lambda connection: vpnrouter_users.profile(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), ('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')), ('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), ('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 PUT /account/passkeys store the passkey: {"id", "credential", "label"?}, credential the browser's answer
DELETE /account/passkeys/<id> DELETE /account/passkeys/<id>
remove a passkey 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. 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)): 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('/')) route = (method, path.rstrip('/'))
removing = method == 'DELETE' and path.startswith('/account/passkeys/') and len(path.strip('/').split('/')) == 3 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'), 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}'} return 404, {'error': f'no route {method} {path}'}
try: try:
if route == ('PUT', '/account/password'): if route == ('PUT', '/account/password'):
vpnrouter_users.check_password(body.get('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')) vpnrouter_users.login(name, body.get('current_password'), body.get('current_code'))
with vpnrouter_users.session() as connection: with vpnrouter_users.session() as connection:
if route == ('GET', '/account'): if route == ('GET', '/account'):
@@ -1068,6 +1080,8 @@ def account(name, method, path, body):
document = vpnrouter_users.passwd(connection, name, body['password']) document = vpnrouter_users.passwd(connection, name, body['password'])
elif route == ('POST', '/account/otp'): elif route == ('POST', '/account/otp'):
document = vpnrouter_users.otp_new(connection, name) 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'): elif route == ('POST', '/account/passkeys'):
document = vpnrouter_passkeys.register_begin(connection, name) document = vpnrouter_passkeys.register_begin(connection, name)
elif route == ('PUT', '/account/passkeys'): elif route == ('PUT', '/account/passkeys'):