IPsec 配置备忘 Part8 - iOS 客户端

⚠ 天坑预警 ⚠

目前我在我的三台苹果设备上只有一台能按照此文设置成功。其他两台遇到的问题包括但不限于:配置无法导入、认证失败、未知错误、无限转圈圈等。因此本文可能没有太大实用价值,请各位读者知悉。

接 Part6。折腾完 Android 以后我们来折腾一下 iOS。基本原理都是一样的,只是需要把苹果那一套 Vendor-specific 的配置选项翻译成 strongSwan 的,这样才能和服务器上的 strongSwan 互相通信嘛。由于 iOS 上的 IKEv2 客户端不是 strongSwan 而是苹果自己魔改的不知道什么版本,所以坑比起 Android 客户端更多。幸好 strongSwan 项目已经帮我们都踩了不少坑了:见 iOS (Apple iPhone, iPad…) and macOSIKEv2 Configuration Profile for Apple iOS 8 and newer

我的测试环境是一台 iOS 12.4.9 的 iPad。由于在测试的时候发现 iOS 的坑实在太多,以及不同版本的 iOS 的行为都不太一样,如果你碰到了我没碰到的坑我只能祝你好运(<ゝω・)☆

坑坑坑

由于本 Part 是基于 Part6 的,所以我们先来列举一下会需要我们做调整的 iOS 的坑(大部分在 strongSwan 的文档里已经提到过了):

  1. iOS 的 local_id 和 remote_id 全部都是 FQDN 类型,意味着我们需要按照 Part7 的说明给证书加上dns_name的 SAN。
  2. iOS 在导入客户端证书的时候只允许 CN/SAN 二选一,否则不能导入。所以 iOS 客户端证书不能有 CN。
  3. iOS 不允许 p12 证书使用空密码。
  4. iOS 12 还不支持 ed25519,所以我们需要改用其他算法。
  5. iOS 的 mobileconfig 全是坑。
  6. iOS 需要在配置描述文件显式指定 CA 的 CN 才会发送 CERTREQ。
  7. 你需要在服务端配置文件的 pool 里指定一个 DNS,否则 iOS 连上后无法上网。

基本流程是:生成密钥和证书;生成 iOS 的mobileconfig配置描述文件;想办法把这个文件安装到 iOS 设备上;最后尝试连接。由于 iOS 的配置文件比 Android 的复杂许多,所以我写了个 Python 脚本来负责生成。

IKEv2 配置描述文件生成工具

生成的配置文件原始模板来自 strongSwan 的文档,原文有很详细的注释,建议过一遍。另外 Apple 的开发者手册以及开发者网站也可参考。你可以在 Apple 的网站上查到每个算法都是在哪个 iOS 版本里支持的。

另外我在脚本里有两个硬编码的地方,一个是 ciphersuite 写死了是aes256gcm128-sha256-ecp521。另一个是证书类型写死了ECDSA256(certtool 在使用 ECDSA 的时候的默认值)。如果你需要设置为其他值,需要直接改脚本代码。

ios-config-gen.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
#!/usr/bin/python3
import uuid
import base64
import plistlib

from absl import app
from absl import flags

FLAGS = flags.FLAGS

#--- Reversed DNS notation, iOS requires these.
flags.DEFINE_string("profile_rdns", None, "Not displayed, but can be used to replace old profile.")
flags.DEFINE_string("vpn_rdns", None, "Not displayed. Can be arbitrary string.")
flags.DEFINE_string("client_cert_rdns", None, "Not displayed. Can be arbitrary string.")
flags.DEFINE_string("ca_cert_rdns", None, "Not displayed. Can be arbitrary string.")
#--- Strings to be displayed. Can be anything.
flags.DEFINE_string("profile_display_name", None, "Arbitrary descriptive name.")
flags.DEFINE_string("vpn_display_name", None, "Arbitrary descriptive name.")
flags.DEFINE_string("client_cert_display_name", None, "Arbitrary descriptive name.")
flags.DEFINE_string("client_cert_file_name", None, "Arbitrary descriptive name.")
flags.DEFINE_string("ca_cert_display_name", None, "Arbitrary descriptive name.")
flags.DEFINE_string("vpn_profile_name", None, "Arbitrary descriptive name.")
#--- Server config
flags.DEFINE_string("server_addr", None, "Domain name or IP of the server.")
flags.DEFINE_string("server_id", None, "Remote id. iOS always send this as FQDN type.")
#--- Client config
flags.DEFINE_string("client_cert_p12_file", None, "Client cert p12 file.")
flags.DEFINE_string("client_cert_p12_pwd", None, "Password for client p12 cert.")
flags.DEFINE_string("client_id", None, "Client's ID to be sent to the server. iOS always send this as FQDN type.")
#--- CA config
flags.DEFINE_string("ca_cert_pem_file", None, "CA cert file, not p12 format.")
flags.DEFINE_string("ca_common_name", None, "CA's Common name. iOS will send a CERTREQ iff this value MATCHES the CA's common name")
#--- Everything is required except client_cert_p12_pwd
flags.mark_flag_as_required("profile_rdns")
flags.mark_flag_as_required("vpn_rdns")
flags.mark_flag_as_required("client_cert_rdns")
flags.mark_flag_as_required("ca_cert_rdns")

flags.mark_flag_as_required("profile_display_name")
flags.mark_flag_as_required("vpn_display_name")
flags.mark_flag_as_required("client_cert_display_name")
flags.mark_flag_as_required("client_cert_file_name")
flags.mark_flag_as_required("ca_cert_display_name")
flags.mark_flag_as_required("vpn_profile_name")

flags.mark_flag_as_required("server_addr")
flags.mark_flag_as_required("server_id")

flags.mark_flag_as_required("client_cert_p12_file")
flags.mark_flag_as_required("client_id")

flags.mark_flag_as_required("ca_cert_pem_file")
flags.mark_flag_as_required("ca_common_name")


def load_pem_file(fname:str)->bytes:
with open(fname, "r") as f:
lines = [l.strip() for l in f.read().split("\n")]
lines = filter(lambda l:len(l) > 0 and '-----' not in l, lines)
return base64.b64decode(''.join(lines))

def client_cert_payload():
ret = dict()
ret["PayloadIdentifier"] = FLAGS.client_cert_rdns
ret["PayloadDisplayName"] = FLAGS.client_cert_display_name
ret["PayloadCertificateFileName"] = FLAGS.client_cert_file_name
ret["PayloadUUID"] = str(uuid.uuid4())
ret["PayloadType"] = "com.apple.security.pkcs12"
ret["PayloadVersion"] = 1
if FLAGS.client_cert_p12_pwd is not None:
ret["Password"] = FLAGS.client_cert_p12_pwd
ret["PayloadContent"] = load_pem_file(FLAGS.client_cert_p12_file)
return ret

def ca_cert_payload():
ret = dict()
ret["PayloadIdentifier"] = FLAGS.ca_cert_rdns
ret["PayloadDisplayName"] = FLAGS.ca_cert_display_name
ret["PayloadUUID"] = str(uuid.uuid4())
ret["PayloadType"] = "com.apple.security.root"
ret["PayloadVersion"] = 1
ret["PayloadContent"] = load_pem_file(FLAGS.ca_cert_pem_file)
return ret

def security_parameter_payload():
ret = dict()
# Please check Apple's doc for the iOS version supporting these options
# https://developer.apple.com/documentation/networkextension/nevpnikev2encryptionalgorithm
ret["EncryptionAlgorithm"] = "AES-256-GCM" # hardcoded
ret["IntegrityAlgorithm"] = "SHA2-256" # hardcoded
ret["DiffieHellmanGroup"] = 21 # hardcoded ecp521
return ret

def ikev2_payload(client_cert_uuid:str):
ret = dict()
ret["RemoteAddress"] = FLAGS.server_addr
ret["RemoteIdentifier"] = FLAGS.server_id
ret["LocalIdentifier"] = FLAGS.client_id
ret["ServerCertificateIssuerCommonName"] = FLAGS.ca_common_name
ret["AuthenticationMethod"] = "Certificate"
ret["PayloadCertificateUUID"] = client_cert_uuid
ret["CertificateType"] = "ECDSA256" # hardcoded
ret["IKESecurityAssociationParameters"] = security_parameter_payload()
ret["ChildSecurityAssociationParameters"] = security_parameter_payload()
return ret

def vpn_payload(client_cert_uuid:str):
ret = dict()
ret["PayloadIdentifier"] = FLAGS.vpn_rdns
ret["PayloadDisplayName"] = FLAGS.vpn_display_name
ret["PayloadUUID"] = str(uuid.uuid4())
ret["PayloadType"] = "com.apple.vpn.managed"
ret["PayloadVersion"] = 1
ret["UserDefinedName"] = FLAGS.vpn_profile_name
ret["VPNType"] = "IKEv2"
ret["IKEv2"] = ikev2_payload(client_cert_uuid)
return ret

def mobileconfig_payload():
ca_p = ca_cert_payload()
client_p = client_cert_payload()
vpn_p = vpn_payload(client_p["PayloadUUID"])

ret = dict()
ret["PayloadIdentifier"] = FLAGS.profile_rdns
ret["PayloadDisplayName"] = FLAGS.profile_display_name
ret["PayloadUUID"] = str(uuid.uuid4())
ret["PayloadType"] = "Configuration"
ret["PayloadVersion"] = 1
ret["PayloadContent"] = [vpn_p, client_p, ca_p]
return ret

def main(argv):
print(plistlib.dumps(mobileconfig_payload()).decode())

if __name__ == '__main__':
app.run(main)

客户端配置

iOS 的客户端证书模板如下,具体的私钥生成和证书签发和 Part2 中的一致,获得ios-key.pemios-cert.pem两个文件。

ios.tmpl
1
2
3
4
5
# 没有设置 common name
# 给客户端设置 DNS 类型的 SAN
dns_name = "iOS Client"
activation_date = "2020-01-01 00:00:00 UTC+0"
expiration_date = "2021-01-01 00:00:00 UTC+0"

然后生成 p12 文件:

1
2
3
4
5
6
7
8
certtool \
--load-privkey ios-key.pem \
--load-certificate ios-cert.pem \
--p12-name "iOS Key Cert Bundle" \
# 需要指定一个密码
--password "123456" \
--to-p12 \
--pkcs-cipher 3des-pkcs12 > ios-p12bundle.pem

生成 iOS 的配置描述文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
./ios-config-gen.py \
# 指定 CA 证书文件
--ca_cert_pem_file ca-cert.pem \
# 指定 CA 的 Common name。只有在这里的值和证书中的值匹配时 iOS 才会给服务器
# 发送证书请求。当然你也可以在服务器设置 send_cert=always
--ca_common_name "CA_COMMON_NAME" \
# 客户端证书文件
--client_cert_p12_file "ios-p12bundle.pem" \
# client_id 需要和之前的 SAN 名一致
--client_id "iOS Client" \
# iOS 似乎会优先将域名解析成 IPv6 地址,所以这里我们手动指定服务器的 IPv4 地址
--server_addr "12.34.56.78" \
# 服务器的 ID,这也是只匹配 SAN 不匹配 DN
--server_id "vpn.server.com" \
# 可选,p12 证书的密码,不指定的话会要求用户输入
# --client_cert_p12_pwd "123456" \
# 没啥影响但是必填的选项(大概可以随便填?)
--profile_rdns "profile_rdns"\
--vpn_rdns "vpn_rdns"\
--client_cert_rdns "client_cert_rdns"\
--ca_cert_rdns "ca_cert_rdns"\
# 所有以下选项(大概)只影响界面显示的字符串,可以(大概)随意填写。
# 你可以用当前设置看看每个选项到底会被显示在哪儿。
--profile_display_name "profile_display_name"\
--vpn_display_name "vpn_display_name"\
--client_cert_display_name "client_cert_display_name"\
--client_cert_file_name "client_cert_file_name"\
--ca_cert_display_name "ca_cert_display_name"\
--vpn_profile_name "vpn_profile_name"\
# 输出文件
> ios-profile.mobileconfig

然后导入进设备就 OK。可以用python -m http.server临时开启一个 HTTP 服务器,然后用浏览器访问这个文件即可。
你说导入失败?那是 iOS 的 BUG

服务器配置

服务器配置基于 Part7 的配置修改,只注释变化部分

server.conf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
connections {
android-connection {
version = 2
local_addrs = 12.34.56.78
remote_addrs = %any
# 添加 iOS 设备使用的 ciphersuite
proposals = aes256gcm128-sha512-x25519,aes256gcm128-sha256-ecp521
pools = ip4pool
local {
id = vpn.server.com
auth = pubkey
}
remote {
id = %any
auth = pubkey
}
children {
child_sa {
local_ts = 0.0.0.0/0
remote_ts = dynamic
mode = tunnel
# 同上
esp_proposals = aes256gcm128-sha512-x25519,aes256gcm128-sha256-ecp521
}
}
}
}

pools {
ip4pool {
addrs = 10.10.10.100-10.10.10.150
# 指定使用的 DNS
dns = 1.1.1.1
}
}

然后重启 strongSwan 测试即可。连不上?Have fun time debugging.