IPsec 配置备忘 Part8 - iOS 客户端

⚠ 天坑预警 ⚠

我在 iOS 12/13/14 各一台设备上测试过,但是测试的时候不是次次都能工作。由于 iOS 的坑实在太多,以及不同版本的 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

坑坑坑

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

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

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

IKEv2 配置描述文件生成工具

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

另外我在脚本里有两个硬编码的地方,一个是 ciphersuite 写死了是aes256gcm128-sha256-ecp256。另一个是证书类型写死了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
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
#!/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 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")
#--- iOS on-demand settings
flags.DEFINE_bool("always_on_wifi", False, "Setup on-demand rules to always connect on WiFi")
#--- 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"
ret["IntegrityAlgorithm"] = "SHA2-256"
ret["DiffieHellmanGroup"] = 19 # ecp256
return ret

def ikev2_payload(client_cert_uuid:str):
ret = dict()
ret["RemoteAddress"] = FLAGS.server_addr
# iOS have bug with DN?
# use server address as suggested
ret["RemoteIdentifier"] = FLAGS.server_id
ret["LocalIdentifier"] = FLAGS.client_id
if FLAGS.ca_common_name is not None:
ret["ServerCertificateIssuerCommonName"] = FLAGS.ca_common_name
ret["AuthenticationMethod"] = "Certificate"
ret["PayloadCertificateUUID"] = client_cert_uuid
ret["CertificateType"] = "ECDSA256"
ret["IKESecurityAssociationParameters"] = security_parameter_payload()
ret["ChildSecurityAssociationParameters"] = security_parameter_payload()
if FLAGS.always_on_wifi:
# https://developer.apple.com/documentation/networkextension/personal_vpn/vpn_on_demand_rules
ret["OnDemandEnabled"] = 1
ret["OnDemandRules"] = [
dict(InterfaceTypeMatch="WiFi", Action="Connect"),
dict(Action="Ignore")
]

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():

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]

if FLAGS.ca_cert_pem_file is not None:
ca_p = ca_cert_payload()
ret["PayloadContent"].append(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
6
# common name 似乎可以随意设置
cn = "iOS Client"
# 给客户端设置 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 文件,注意:certtool 工具生成的 p12 与 iOS 有兼容性问题,可能导致无法导入,报错“容器只能包含一个证书及其密钥”,因此只能使用openssl生成。设置的实际密码为123456,以及这奇怪的输出格式是为了之后可以直接喂给 Python 脚本。

1
2
3
4
5
openssl pkcs12 \
-inkey ios-key.pem \
-in ios-cert.pem \
-export \
-password pass:123456 | base64 > 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
32
33
34
./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" \
# 启用 on-demand VPN,这里出于我自己的需要设定成只要连着 WiFi 就连接 VPN。
# 如果你需要自定义规则,则需要自己修改 Python 代码。
--always_on_wifi \
# 可选,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 的配置修改,只注释变化部分。iOS 要求服务器证书的 subject 非空,所以在生成服务器证书的时候别忘了设置 common name。

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-ecp256
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-ecp256
}
}
}
}

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

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