Freeradius 3.2.10 实践 - 手动构建自己的证书签发系统

问题的引入

在之前的案例 《Freeradius 3.2.10 配置文件说明以及一个企业级网络认证案例》 中,我们已经搭建出一个在技术上非常完善的企业级网络安全认证系统。但还是存在一点不足之处,前面的 EAP-TTLS+PAP、或者 EPA-TLS 纯证书认证,使用到的证书都是 freeradius 3.2.10 自带的,这非常不安全。当然 freeradius 3.2.10 也自带了生成证书的脚本,你可以使用脚本生成自定义证书。但那不是我们的风格,我们要的是能够:

  • 自己生成 ca 根证书;

  • 自己使用 ca 根证书签发长期的 server 证书;

  • 自己可以使用 ca 证书签发长期的用户证书;

构建自主的证书签发系统,对于企业设备管理及网络准入安全具有长远的技术价值。在用户认证层面,针对传统硬件标识的局限性——MAC 地址存在伪造风险及动态随机化问题(随机 MAC/动态 MAC),而设备 SN 码又受限于 802.1X 协议无法直接传输——我们采取为每台设备下发唯一证书的策略(一机一证),将证书序列号作为设备的唯一身份标识,形成了极佳的技术补充。结合 FreeRADIUS + REST 服务,认证系统可在服务端对证书序列号进行实时安全校验,进而实现设备证书的动态吊销与即时准入控制,全面筑牢设备入网的安全防线。


具体实现

实现思路

本系统基于 PKI 体系 构建完整的网络认证闭环,系统内包含三个核心角色:根证书颁发机构(CA)、FreeRADIUS 认证服务器 以及 终端设备。其中 FreeRADIUS 与终端设备的证书均由自建 CA 统一签发,终端证书具备唯一的序列号(Serial Number)与 Subject。系统的初始化与运行逻辑如下:

  • 基础信任链建立:首先生成并严密保管 CA 根证书及私钥。利用该 CA 签发 FreeRADIUS 服务端密钥对,并使用生成的 CA 证书及服务端证书替换 FreeRADIUS 默认的初始证书。
  • 预引导与动态下发
    • 为新员工或新设备分配受限的访客网络(Guest VLAN),用于初始身份鉴权与安全通道建立。
    • 鉴权通过后,客户端建立长连接隧道。证书生成服务端即时为其签发专属证书,并经由长连接实时下发至终端。
  • 版本比对与增量更新
    • 终端将证书及对应的版本标识(如签发时间戳)持久化至本地。
    • 每次执行证书同步前,终端优先比对本地与服务端的版本号,仅在版本变更时触发重新拉取,完成下发逻辑闭环。
  • 证书存储安全策略:证书生成服务端采用 MySQL 进行集中管理。为防范静态数据泄露(Data-at-Rest)风险,所有私钥及证书数据在写入数据库前均进行对称加密处理(如 AES-256)。


证书签发实现

建表语句

为了简便,我们这里暂时使用 mysql 将证书持久化。

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
CREATE TABLE `owlias_aaa_ca_cert` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`issuer` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '颁发者信息',
`subject` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '被颁发者信息',
`cert_file` longblob NOT NULL COMMENT '证书文件二进制',
`cert_type` tinyint NOT NULL COMMENT '账户/证书类型: 0-KEY, 1-PEM, 2-DER, 3-CRT, 4-P12',
`cipher` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '证书导入导出密码',
`serial_num` bigint NOT NULL COMMENT '证书序列编号',
`start_date` datetime NOT NULL COMMENT '证书有效期开始时间',
`end_date` datetime NOT NULL COMMENT '证书有效期结束时间',
`create_user` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '创建者',
`update_user` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '更新者',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_cert_type_id` (`cert_type`,`id`) USING BTREE COMMENT '用于getLastCaCert优化'
) ENGINE=InnoDB COMMENT='入网根证书表';

CREATE TABLE `owlias_aaa_server_cert` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`subject` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '被颁发者信息',
`cert_file` longblob NOT NULL COMMENT '证书文件',
`cert_type` tinyint NOT NULL COMMENT '账户类型',
`cipher` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '证书导入导出密码',
`serial_num` bigint NOT NULL COMMENT '证书序列编号',
`ca_serial_num` bigint NOT NULL COMMENT 'CA证书序列编号',
`start_date` datetime NOT NULL COMMENT '证书有效期开始时间',
`end_date` datetime NOT NULL COMMENT '证书有效期结束时间',
`create_user` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '创建者',
`update_user` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '更新者',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_cert_type_id` (`cert_type`,`id`) USING BTREE
) ENGINE=InnoDB COMMENT='入网服务端证书表';

CREATE TABLE `owlias_aaa_client_cert` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` bigint unsigned NOT NULL COMMENT '用户ID',
`email` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '用户邮箱',
`assets_id` bigint NOT NULL COMMENT '设备ID(PC和移动端统一)',
`os_id` int NOT NULL COMMENT '系统ID: 1-MAC, 2-WINDOWS, 3-IOS, 4-ANDROID',
`mac` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT 'mac地址',
`cert_file_id` bigint unsigned NOT NULL COMMENT '证书文件ID',
`cert_type` tinyint NOT NULL COMMENT '证书文件类型',
`cipher` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '证书导入导出密码',
`serial_num` bigint NOT NULL COMMENT '证书序列编号',
`ca_serial_num` bigint NOT NULL COMMENT 'CA证书序列编号',
`server_serial_num` bigint NOT NULL COMMENT 'Server证书序列编号',
`start_date` datetime NOT NULL COMMENT '证书有效期开始时间',
`end_date` datetime NOT NULL COMMENT '证书有效期结束时间',
`client_serial_no` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '客户端用证书序列号',
`status` tinyint NOT NULL DEFAULT '0' COMMENT '证书状态: 0-正常, 1-禁用',
`deleted` tinyint NOT NULL DEFAULT '0' COMMENT '逻辑删除: 0-未删除, 1-已删除',
`create_user` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '创建者',
`update_user` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '更新者',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_assets_app_user` (`assets_id`,`os_id`,`user_id`,`deleted`) USING BTREE,
KEY `idx_user_status` (`user_id`,`status`) USING BTREE,
KEY `idx_create_time` (`create_time`) USING BTREE
) ENGINE=InnoDB COMMENT='客户端证书信息表';


依赖配置

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
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>3.3.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>3.3.0</version>
<scope>test</scope>
</dependency>
<!--证书生成依赖包-->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk15on</artifactId>
<version>1.68</version>
</dependency>

<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.7</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.3.0</version>
<scope>runtime</scope>
</dependency>

<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.14.0</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.16.1</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.44</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>


配置文件和启动类

application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.1.251:3306/owlias_demo?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: xxx
password: xxx

mybatis-plus:
global-config:
banner: false
db-config:
logic-delete-field: deleted
logic-not-delete-value: 0
logic-delete-value: 1
mapper-locations: classpath:/mapper/*.xml
type-aliases-package: com.zdemo.certs
configuration:
cache-enabled: true
default-executor-type: simple
map-underscore-to-camel-case: true

aaa_cert:
# 证书加密秘钥
eKey: 012345678901234567890123456789AB

启动类:

1
2
3
4
5
6
7
8
9
10
@Slf4j
@SpringBootApplication
@MapperScan({"com.zdemo.certs.mapper"})
public class App {
public static void main(String[] args) {
ConfigurableApplicationContext run = SpringApplication.run(App.class);
String[] activeProfiles = run.getEnvironment().getActiveProfiles();
log.info("Application Startup Success! activeProfiles:{}", Arrays.toString(activeProfiles));
}
}


持久化相关接口

仅以 AaaCaCertDao 为例进行介绍:

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
@Mapper
public interface AaaCaCertDao {

String TABLE_NAME = "aaa_ca_cert";
String COLUMNS = " id,issuer,subject,cert_file,cert_type,cipher,serial_num,start_date,end_date,create_user,update_user,create_time,update_time ";

/**
* 添加
*
* @param po 实体
* @return 影响的行数
*/
@Insert("insert into " + TABLE_NAME + " (issuer,subject,cert_file,cert_type,cipher,serial_num,start_date,end_date,create_user,update_user) " +
" values (#{c.issuer}, #{c.subject}, #{c.certFile}, #{c.certType}, #{c.cipher}, #{c.serialNum}, #{c.startDate}, #{c.endDate}, #{c.createUser}, #{c.updateUser})")
@Options(useGeneratedKeys = true, keyProperty = "c.id")
Long insert(@Param("c") AaaCaCertPO po);

/**
* 根据序列号获取证书
*
* @param serialNum 序列号
* @param certType 证书类型
* @return 根证书PO
*/
@Select("select " + COLUMNS + " from " + TABLE_NAME + " where serial_num = #{serialNum} and cert_type = #{certType} order by id desc limit 1")
AaaCaCertPO getCaCertBySerialNum(@Param("serialNum") Long serialNum, @Param("certType") Integer certType);

/**
* 获取最新的ca证书
*
* @param certType 证书类型
* @return 根证书PO
*/
@Select("select " + COLUMNS + " from " + TABLE_NAME + " where cert_type = #{certType} order by id desc limit 1")
AaaCaCertPO getLastCaCert(@Param("certType") Integer certType);

/**
* 获取最新ca证书的更新时间戳
*
* @param certType 证书类型
* @return 更新时间
*/
@Select("select update_time from " + TABLE_NAME + " where cert_type = #{certType} order by id desc limit 1")
Date getLastCaCertUpdateTime(@Param("certType") Integer certType);
}

AaaCaCertPO

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
@Data
@Accessors(chain = true)
public class AaaCaCertPO implements Serializable {

/**
* 主键
*/
private Long id;

/**
* 颁发者信息
*/
private String issuer;

/**
* 被颁发者信息
*/
private String subject;

/**
* 证书文件
*/
private byte[] certFile;

/**
* 账户类型,请参考 {@link AaaCertTypeEnum}
*/
private Integer certType;

/**
* 证书导入导出密码
*/
private String cipher;

/**
* 证书序列编号
*/
private Long serialNum;

/**
* 证书有效期开始时间
*/
private Date startDate;

/**
* 证书有效期结束时间
*/
private Date endDate;

/**
* 创建者
*/
private String createUser;

/**
* 更新者
*/
private String updateUser;

/**
* 创建时间
*/
private Date createTime;

/**
* 更新时间
*/
private Date updateTime;
}

证书的类型枚举:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@AllArgsConstructor
@Getter
public enum AaaCertTypeEnum {
KEY(0, "key"),
PEM(1, "pem"),
DER(2, "der"),
CRT(3, "crt"),
P12(4, "p12"),
;

private final int type;
private final String name;
}


AESUtil

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
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
import io.micrometer.common.util.StringUtils;
import lombok.extern.slf4j.Slf4j;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;

/**
* AES 工具类
*/
@Slf4j
public class AESUtil {

/**
* 编码
*/
private static final String ENCODING = "UTF-8";

/**
* 算法定义
*/
private static final String AES_ALGORITHM = "AES";

/**
* 指定填充方式
*/
private static final String CIPHER_PADDING = "AES/ECB/PKCS5Padding";
private static final String CIPHER_CBC_PADDING = "AES/CBC/PKCS5Padding";

/**
* AES加密
*
* @param input 待加密内容
* @param aesKey 密码
* @return
*/
public static byte[] encrypt(byte[] input, String aesKey) {
// 判断秘钥是否为16或32位
if (StringUtils.isNotBlank(aesKey) && (aesKey.length() == 16 || aesKey.length() == 32)) {
try {
// 对密码进行编码
byte[] bytes = aesKey.getBytes(ENCODING);
// 设置加密算法,生成秘钥
SecretKeySpec skeySpec = new SecretKeySpec(bytes, AES_ALGORITHM);
// "算法/模式/补码方式"
Cipher cipher = Cipher.getInstance(CIPHER_PADDING);
// 选择加密
cipher.init(Cipher.ENCRYPT_MODE, skeySpec);
// 根据待加密内容生成加密的字节数组
return cipher.doFinal(input);
} catch (Exception e) {
log.info("AES encrypt exception:" + e.getMessage());
throw new RuntimeException(e);
}
} else {
log.info("AES encrypt: the aesKey is null or error!");
return null;
}
}

/**
* 解密
*
* @param input 待解密内容
* @param aesKey 密码
* @return
*/
public static byte[] decrypt(byte[] input, String aesKey) {
// 判断秘钥是否为32位
if (StringUtils.isNotBlank(aesKey) && (aesKey.length() == 16 || aesKey.length() == 32)) {
try {
// 对密码进行编码
byte[] bytes = aesKey.getBytes(ENCODING);
// 设置解密算法,生成秘钥
SecretKeySpec skeySpec = new SecretKeySpec(bytes, AES_ALGORITHM);
// "算法/模式/补码方式"
Cipher cipher = Cipher.getInstance(CIPHER_PADDING);
// 选择解密
cipher.init(Cipher.DECRYPT_MODE, skeySpec);
// 解密并返回
return cipher.doFinal(input);
} catch (Exception e) {
log.info("AES decrypt exception:" + e.getMessage());
throw new RuntimeException(e);
}
} else {
log.info("AES decrypt: the aesKey is null or error!");
return null;
}
}

/**
* AES_CBC 加密
*
* @param input 待加密内容
* @param aesKey 密码
* @param aesKey 密码
* @param ivSeed 偏移量(CBC中使用,增强加密算法强度,一般为16位)
* @return
*/
public static byte[] encryptCBC(byte[] input, String aesKey, String ivSeed) {
// 判断秘钥是否为32位
if (StringUtils.isNotBlank(aesKey) && (aesKey.length() == 16 || aesKey.length() == 32)) {
try {
// 对密码进行编码
byte[] bytes = aesKey.getBytes(ENCODING);
// 设置加密算法,生成秘钥
SecretKeySpec skeySpec = new SecretKeySpec(bytes, AES_ALGORITHM);
// "算法/模式/补码方式"
Cipher cipher = Cipher.getInstance(CIPHER_CBC_PADDING);
// 偏移
IvParameterSpec iv = new IvParameterSpec(ivSeed.getBytes(ENCODING));
// 选择加密
cipher.init(Cipher.ENCRYPT_MODE, skeySpec, iv);
// 根据待加密内容生成字节数组
return cipher.doFinal(input);
} catch (Exception e) {
log.info("AES_CBC encrypt exception:" + e.getMessage());
throw new RuntimeException(e);
}
} else {
log.info("AES_CBC encrypt: the aesKey is null or error!");
return null;
}
}

/**
* AES_CBC 解密
*
* @param input 待解密内容
* @param aesKey 密码
* @param ivSeed 偏移量(CBC中使用,增强加密算法强度,一般为16位)
* @return
*/
public static byte[] decryptCBC(byte[] input, String aesKey, String ivSeed) {
// 判断秘钥是否为32位
if (StringUtils.isNotBlank(aesKey) && (aesKey.length() == 16 || aesKey.length() == 32)) {
try {
// 对密码进行编码
byte[] bytes = aesKey.getBytes(ENCODING);
// 设置解密算法,生成秘钥
SecretKeySpec skeySpec = new SecretKeySpec(bytes, AES_ALGORITHM);
// 偏移
IvParameterSpec iv = new IvParameterSpec(ivSeed.getBytes(ENCODING));
// "算法/模式/补码方式"
Cipher cipher = Cipher.getInstance(CIPHER_CBC_PADDING);
// 选择解密
cipher.init(Cipher.DECRYPT_MODE, skeySpec, iv);

// 根据待解密内容进行解密
return cipher.doFinal(input);
} catch (Exception e) {
log.info("AES_CBC decrypt exception:" + e.getMessage());
throw new RuntimeException(e);
}
} else {
log.info("AES_CBC decrypt: the aesKey is null or error!");
return null;
}
}

/**
* 获取 ivSeed
*
* @return
*/
public static String getIvSeed(long x1, long x2, long x3) {
StringBuffer ss = new StringBuffer();
ss.append(Integer.toHexString(Long.toHexString(((x1 % 41) * 7 + x2) | x3).hashCode()));
ss.append(Integer.toHexString(Long.toHexString(((x1 % 67) * 3 - x2) & x3).hashCode()));
return ss.toString();
}

/*public static void main(String[] args) {
String chars = "abcdefghijklmnopqrstuvwxyz1234567890";
String random = ThreadLocalRandom.current().ints(32, 0, chars.length())
.mapToObj(chars::charAt)
.map(Object::toString)
.collect(Collectors.joining());
System.out.println("随机key:" + random);
System.out.println();

System.out.println("---------加密---------");
byte[] aesResult = encrypt("测试AES加密12".getBytes(), random);
System.out.println("aes加密结果:" + Base64Utils.encodeToString(aesResult));
System.out.println();

System.out.println("---------解密---------");
byte[] decrypt = decrypt(aesResult, random);
System.out.println("aes解密结果:" + new String(decrypt));
System.out.println();


System.out.println("--------加密CBC---------");
String ivSeed = "1234567890123456";
byte[] cbcResult = encryptCBC("测试AES加密12456".getBytes(), random, ivSeed);
System.out.println("aes_cbc加密结果:" + Base64Utils.encodeToString(cbcResult));
System.out.println();

System.out.println("---------解密CBC---------");
byte[] cbcDecrypt = decryptCBC(cbcResult, random, ivSeed);
System.out.println("aes_cbc解密结果:" + new String(cbcDecrypt));
System.out.println();
}*/
}


CertUtil

支持 EC 椭圆曲线加密、和RSA 加密两种方式,推荐使用 EC,更安全轻量。

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
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.DERIA5String;
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.*;
import org.bouncycastle.asn1.x9.X9ObjectIdentifiers;
import org.bouncycastle.cert.X509CRLHolder;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.X509v2CRLBuilder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.openssl.PKCS8Generator;
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
import org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8DecryptorProviderBuilder;
import org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8EncryptorBuilder;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.InputDecryptorProvider;
import org.bouncycastle.operator.OutputEncryptor;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.pkcs.PKCS10CertificationRequest;
import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo;
import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfoBuilder;
import org.bouncycastle.pkcs.jcajce.JcaPKCS8EncryptedPrivateKeyInfoBuilder;
import java.io.*;
import java.math.BigInteger;
import java.security.*;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.RSAPrivateCrtKey;
import java.security.spec.ECGenParameterSpec;
import java.security.spec.ECPublicKeySpec;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.RSAPublicKeySpec;
import java.util.Date;

/**
* @author kinglyjn
*
* <h2>Owlias 证书管理与签发核心工具类</h2>
* 基于 Bouncy Castle 实现标准的 X.509 v3 证书体系。<br>
* 支持:根证书自签、服务端/客户端证书代签、CRL吊销列表导出、PKCS#12(P12)容器打包、商用加密私钥解析等。
*/
@Slf4j
public class CertUtil {

static {
// 动态注册 Bouncy Castle 安全提供者,使其接管 JDK 的底层加解密工厂
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
}
}

/**
* CA 根证书输出流载体(包含加密Key、PEM明文证书、DER二进制证书)
*/
@Data
public static class CaCertFileBytes {
private byte[] keyFileBytes; // PKCS#8 格式加密后的私钥字节流 (.key)
private byte[] pemFileBytes; // PEM 格式的文本证书字节流 (.pem / .crt)
private byte[] derFileBytes; // ASN.1 DER 编码的二进制证书字节流 (.der)
private X509Certificate caCertificate; // JDK 标准 X509 证书实例
}

/**
* 服务端证书输出流载体(包含客户端握手所需的关键容器)
*/
@Data
public static class ServerCertFileBytes {
private byte[] keyFileBytes; // 经密码加密的服务端私钥
private byte[] pemFileBytes; // PEM 架构证书
private byte[] crtFileBytes; // 标准 X509 规范公开证书
private byte[] p12FileBytes; // 融合了私钥与证书链的 PKCS#12 二进制密钥库
}

/**
* 客户端证书输出流载体(用于双向 TLS 认证)
*/
@Data
public static class ClientCertFileBytes {
private byte[] p12FileBytes; // 客户端个人身份 P12 安装包
private String clientSerialNo; // 证书唯一序列号
}

/**
* 证书生成的算法(推荐 EC 算法)
*/
public enum CertAlgo {
RSA, EC;
}

/**
* 生成非对称 RSA/EC 密钥对
*/
public static KeyPair generatePrivateKey(CertAlgo algo) throws Exception {
if (algo == CertAlgo.RSA) {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC");
keyPairGenerator.initialize(2048);
return keyPairGenerator.generateKeyPair();
} else if (algo == CertAlgo.EC) {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC", "BC");
kpg.initialize(new ECGenParameterSpec("secp256r1"));
return kpg.generateKeyPair();
} else {
throw new IllegalStateException("不支持的证书加密算法");
}
}

/**
* 使用 OpenSSL 兼容算法(3DES + SHA1 PBE)对私钥进行加密,并导出为 PEM 文本字节流
*/
public static byte[] getPrivateKeyToKeyFileBytes(KeyPair keyPair, String cipher) throws Exception {
char[] password = cipher.toCharArray();
PKCS8EncryptedPrivateKeyInfoBuilder privateKeyBuilder = new JcaPKCS8EncryptedPrivateKeyInfoBuilder(keyPair.getPrivate());
OutputEncryptor encryptor = new JceOpenSSLPKCS8EncryptorBuilder(PKCS8Generator.PBE_SHA1_3DES)
.setProvider("BC")
.setPasssword(password)
.build();
PKCS8EncryptedPrivateKeyInfo encryptedPrivateKeyInfo = privateKeyBuilder.build(encryptor);
StringWriter privateKeyStringWriter = new StringWriter();

try (JcaPEMWriter privateKeyPemWriter = new JcaPEMWriter(privateKeyStringWriter)) {
privateKeyPemWriter.writeObject(encryptedPrivateKeyInfo);
}
return privateKeyStringWriter.toString().getBytes();
}

/**
* 生成全新的自签名根证书(CA Certificate)字节数组
*/
public static CaCertFileBytes generateCaFileBytes(CertAlgo algo, String cipher, BigInteger serialNum, X500Name x500Name,
Date startDate, Date endDate, String crlDistributionPoints) throws Exception {
CaCertFileBytes result = new CaCertFileBytes();
KeyPair keyPair = generatePrivateKey(algo);

// 导出加密后的 ca.key
byte[] keyFileBytes = getPrivateKeyToKeyFileBytes(keyPair, cipher);
result.setKeyFileBytes(keyFileBytes);

// 组装并生成自签名 CA 证书
X509CertificateHolder caHolder = getCaX509Holder(keyPair, serialNum, x500Name, x500Name, startDate, endDate, crlDistributionPoints);
X509Certificate caCert = new JcaX509CertificateConverter().setProvider("BC").getCertificate(caHolder);
result.setCaCertificate(caCert);

// 导出明文 pem 格式:ca.pem
StringWriter certStringWriter = new StringWriter();
try (JcaPEMWriter pemWriter = new JcaPEMWriter(certStringWriter)) {
pemWriter.writeObject(caCert);
}
result.setPemFileBytes(certStringWriter.toString().getBytes());

// 导出二进制 DER 编码格式:ca.der
result.setDerFileBytes(caCert.getEncoded());
return result;
}

/**
* 【重载 1】服务端证书签发:显式指定 CA 私钥与 CA 公钥
*/
public static ServerCertFileBytes generateServerFileBytes(CertAlgo algo, PrivateKey caPrivateKey, PublicKey caPublicKey, String friendlyName,
String cipher, BigInteger serialNum, X500Name issuer, X500Name subject,
Date startDate, Date endDate, String crlDistributionPoints) throws Exception {
ServerCertFileBytes result = new ServerCertFileBytes();

// 1. 生成服务端的物理私钥对
KeyPair serverKeyPair = generatePrivateKey(algo);
byte[] keyFileBytes = getPrivateKeyToKeyFileBytes(serverKeyPair, cipher);
result.setKeyFileBytes(keyFileBytes);

// 2. 一性生成受 CA 私钥信任签名的 X509 物理证书实例
X509Certificate serverCert = signAndGetCustomX509Certificate(
true, serverKeyPair.getPublic(), caPrivateKey, caPublicKey, serialNum, issuer, subject, startDate, endDate, crlDistributionPoints
);

// 3. 导出 .crt / .pem 字节流
try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
JcaPEMWriter pemWriter = new JcaPEMWriter(new OutputStreamWriter(bos))) {
pemWriter.writeObject(serverCert);
pemWriter.flush();
byte[] certBytes = bos.toByteArray();
result.setCrtFileBytes(certBytes);
result.setPemFileBytes(certBytes);
}

// 4. 将私钥、证书及信任链打包输出为标准的 PKCS#12 (P12) 密钥归档文件
byte[] p12FileBytes = getP12FileBytes(serverKeyPair, cipher, serverCert, friendlyName);
result.setP12FileBytes(p12FileBytes);
return result;
}

/**
* 【重载 2 - 对应测试代码】服务端证书签发:仅传入 CA 私钥(自动推导 CA 公钥)
*/
public static ServerCertFileBytes generateServerFileBytes(CertAlgo algo, PrivateKey caPrivateKey, String friendlyName,
String cipher, BigInteger serialNum, X500Name issuer, X500Name subject,
Date startDate, Date endDate, String crlDistributionPoints) throws Exception {
PublicKey caPublicKey = derivePublicKeyFromPrivateKey(caPrivateKey);
return generateServerFileBytes(algo, caPrivateKey, caPublicKey, friendlyName, cipher, serialNum, issuer, subject, startDate, endDate, crlDistributionPoints);
}

/**
* 【重载 3】服务端证书签发:传入 CA 的 X509Certificate 实例
*/
public static ServerCertFileBytes generateServerFileBytes(CertAlgo algo, PrivateKey caPrivateKey, X509Certificate caCert, String friendlyName,
String cipher, BigInteger serialNum, X500Name issuer, X500Name subject,
Date startDate, Date endDate, String crlDistributionPoints) throws Exception {
return generateServerFileBytes(algo, caPrivateKey, caCert != null ? caCert.getPublicKey() : null, friendlyName, cipher, serialNum, issuer, subject, startDate, endDate, crlDistributionPoints);
}

/**
* 【重载 1】客户端证书签发:显式指定 CA 私钥与 CA 公钥
*/
public static ClientCertFileBytes generateClientFileBytes(CertAlgo algo, PrivateKey caPrivateKey, PublicKey caPublicKey, String friendlyName,
String cipher, BigInteger serialNum, X500Name issuer, X500Name subject,
Date startDate, Date endDate, String crlDistributionPoints) {
try {
ClientCertFileBytes result = new ClientCertFileBytes();

// 1. 生成客户端密钥对
KeyPair clientKeyPair = generatePrivateKey(algo);

// 2. 权威签发 X509 客户端证书
X509Certificate clientCert = signAndGetCustomX509Certificate(
false, clientKeyPair.getPublic(), caPrivateKey, caPublicKey, serialNum, issuer, subject, startDate, endDate, crlDistributionPoints
);

// 3. 打包成 P12 容器
byte[] p12FileBytes = getP12FileBytes(clientKeyPair, cipher, clientCert, friendlyName);
String clientSerialNo = clientCert.getSerialNumber().toString(16);

result.setP12FileBytes(p12FileBytes);
result.setClientSerialNo(clientSerialNo);
return result;
} catch (Exception e) {
log.error("CA 签发 client 证书失败,friendlyName:{},subject:{}", friendlyName, subject, e);
}
return null;
}

/**
* 【重载 2】客户端证书签发:仅传入 CA 私钥(自动推导 CA 公钥)
*/
public static ClientCertFileBytes generateClientFileBytes(CertAlgo algo, PrivateKey caPrivateKey, String friendlyName,
String cipher, BigInteger serialNum, X500Name issuer, X500Name subject,
Date startDate, Date endDate, String crlDistributionPoints) {
try {
PublicKey caPublicKey = derivePublicKeyFromPrivateKey(caPrivateKey);
return generateClientFileBytes(algo, caPrivateKey, caPublicKey, friendlyName, cipher, serialNum, issuer, subject, startDate, endDate, crlDistributionPoints);
} catch (Exception e) {
log.error("CA 签发 client 证书失败,friendlyName:{},subject:{}", friendlyName, subject, e);
}
return null;
}

/**
* 【重载 3】客户端证书签发:传入 CA 的 X509Certificate 实例
*/
public static ClientCertFileBytes generateClientFileBytes(CertAlgo algo, PrivateKey caPrivateKey, X509Certificate caCert, String friendlyName,
String cipher, BigInteger serialNum, X500Name issuer, X500Name subject,
Date startDate, Date endDate, String crlDistributionPoints) {
return generateClientFileBytes(algo, caPrivateKey, caCert != null ? caCert.getPublicKey() : null, friendlyName, cipher, serialNum, issuer, subject, startDate, endDate, crlDistributionPoints);
}

/**
* 废弃并注销指定证书,即刻动态写入或更新 CRL (证书吊销列表) 文件
*/
public static void revokedCertificate(File targetCrlFile, PrivateKey caPrivateKey, X500Name issuer, BigInteger revokedSerialNum, Date nextUpdateDate) throws Exception {
X509v2CRLBuilder crlBuilder = new X509v2CRLBuilder(issuer, new Date());
crlBuilder.addCRLEntry(revokedSerialNum, new Date(), CRLReason.privilegeWithdrawn);
crlBuilder.setNextUpdate(nextUpdateDate);

String sigAlgo = getSignatureAlgorithm(caPrivateKey);
ContentSigner crlContentSigner = new JcaContentSignerBuilder(sigAlgo).setProvider("BC").build(caPrivateKey);
X509CRLHolder crlHolder = crlBuilder.build(crlContentSigner);

try (FileOutputStream crlOutputStream = new FileOutputStream(targetCrlFile)) {
crlOutputStream.write(crlHolder.getEncoded());
}
}

/**
* 合并私钥与公钥证书到标准的 PKCS#12 (P12) 安全归档文件中
*/
private static byte[] getP12FileBytes(KeyPair keyPair, String cipher, X509Certificate customCertificate, String friendlyName) throws Exception {
KeyStore keyStore = KeyStore.getInstance("PKCS12", "BC");
char[] password = cipher.toCharArray();
keyStore.load(null, password);
keyStore.setKeyEntry(friendlyName, keyPair.getPrivate(), password, new X509Certificate[]{customCertificate});
try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
keyStore.store(bos, password);
return bos.toByteArray();
}
}

/**
* 内部组装:构建自签名 CA 的 X509 结构
*/
private static X509CertificateHolder getCaX509Holder(KeyPair caKeyPair, BigInteger serialNum, X500Name issuer, X500Name subject,
Date startDate, Date endDate, String crlDistributionPoints) throws Exception {
PrivateKey privateKey = caKeyPair.getPrivate();
PublicKey publicKey = caKeyPair.getPublic();

JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(issuer, serialNum, startDate, endDate, subject, publicKey);
JcaX509ExtensionUtils extUtils = new JcaX509ExtensionUtils();

// 1. Subject Key Identifier (SKID)
certBuilder.addExtension(Extension.subjectKeyIdentifier, false, extUtils.createSubjectKeyIdentifier(publicKey));

// 2. Authority Key Identifier (AKID)
certBuilder.addExtension(Extension.authorityKeyIdentifier, false, extUtils.createAuthorityKeyIdentifier(publicKey));

// 3. 基本约束:标记为权威 CA
certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true));

// 4. CA KeyUsage
KeyUsage caKeyUsage = new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign | KeyUsage.digitalSignature);
certBuilder.addExtension(Extension.keyUsage, true, caKeyUsage);

// 5. CRL 分发点
if (crlDistributionPoints != null && !crlDistributionPoints.isEmpty()) {
GeneralName crlName = new GeneralName(GeneralName.uniformResourceIdentifier, new DERIA5String(crlDistributionPoints));
DistributionPointName distPointName = new DistributionPointName(new GeneralNames(crlName));
DistributionPoint distPoint = new DistributionPoint(distPointName, null, null);
certBuilder.addExtension(Extension.cRLDistributionPoints, false, new CRLDistPoint(new DistributionPoint[]{distPoint}));
}

ContentSigner contentSigner = new JcaContentSignerBuilder(getSignatureAlgorithm(privateKey)).setProvider("BC").build(privateKey);
return certBuilder.build(contentSigner);
}

/**
* 核心签发枢纽:使用 CA 私钥对生成的终端应用公钥进行单次完整签名
*/
private static X509Certificate signAndGetCustomX509Certificate(boolean isServer, PublicKey targetPublicKey, PrivateKey caPrivateKey, PublicKey caPublicKey,
BigInteger serialNum, X500Name issuer, X500Name subject,
Date startDate, Date endDate, String crlDistributionPoints) throws Exception {

JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(
issuer, serialNum, startDate, endDate, subject, targetPublicKey
);
JcaX509ExtensionUtils extUtils = new JcaX509ExtensionUtils();

// 1. 明确声明非 CA 节点
certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false));

// 2. 计算并注入 Subject Key Identifier (SKID)
certBuilder.addExtension(Extension.subjectKeyIdentifier, false, extUtils.createSubjectKeyIdentifier(targetPublicKey));

// 3. 计算并注入 Authority Key Identifier (AKID)
if (caPublicKey != null) {
certBuilder.addExtension(Extension.authorityKeyIdentifier, false, extUtils.createAuthorityKeyIdentifier(caPublicKey));
}

// 4. 根据类型注入 KeyUsage 及 ExtendedKeyUsage
if (isServer) {
KeyUsage keyUsage = new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment | KeyUsage.keyAgreement);
certBuilder.addExtension(Extension.keyUsage, true, keyUsage);

ASN1ObjectIdentifier policyIdentifier = new ASN1ObjectIdentifier("1.3.6.1.4.1.40808.1.3.2");
PolicyInformation policyInformation = new PolicyInformation(policyIdentifier);
certBuilder.addExtension(Extension.certificatePolicies, false, new CertificatePolicies(new PolicyInformation[]{policyInformation}));

certBuilder.addExtension(Extension.extendedKeyUsage, false, new ExtendedKeyUsage(new KeyPurposeId[]{KeyPurposeId.id_kp_serverAuth}));
} else {
KeyUsage keyUsage = new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment);
certBuilder.addExtension(Extension.keyUsage, true, keyUsage);

certBuilder.addExtension(Extension.extendedKeyUsage, false, new ExtendedKeyUsage(new KeyPurposeId[]{KeyPurposeId.id_kp_clientAuth}));
}

// 5. CRL 分发点
if (crlDistributionPoints != null && !crlDistributionPoints.isEmpty()) {
GeneralName crlDistributionPoint = new GeneralName(GeneralName.uniformResourceIdentifier, new DERIA5String(crlDistributionPoints));
DistributionPointName distPointName = new DistributionPointName(new GeneralNames(crlDistributionPoint));
DistributionPoint distPoint = new DistributionPoint(distPointName, null, null);
certBuilder.addExtension(Extension.cRLDistributionPoints, false, new CRLDistPoint(new DistributionPoint[]{distPoint}));
}

// 6. 使用 CA 私钥进行签名
String sigAlgo = getSignatureAlgorithm(caPrivateKey);
ContentSigner signer = new JcaContentSignerBuilder(sigAlgo).setProvider("BC").build(caPrivateKey);

return new JcaX509CertificateConverter().setProvider("BC").getCertificate(certBuilder.build(signer));
}

/**
* 加载并还原已加密的私钥文件
*/
public static PrivateKey loadPrivateKey(byte[] keyFileBytes, String keyCipher) {
try {
Object pemObject;
try (PEMParser pemParser = new PEMParser(new InputStreamReader(new ByteArrayInputStream(keyFileBytes)))) {
pemObject = pemParser.readObject();
}

if (pemObject instanceof PKCS8EncryptedPrivateKeyInfo encryptedKeyInfo) {
InputDecryptorProvider decryptProvider = new JceOpenSSLPKCS8DecryptorProviderBuilder().build(keyCipher.toCharArray());
PrivateKeyInfo privateKeyInfo = encryptedKeyInfo.decryptPrivateKeyInfo(decryptProvider);

String keyFactoryAlgorithm = getKeyFactoryAlgorithm(privateKeyInfo);
KeyFactory keyFactory = KeyFactory.getInstance(keyFactoryAlgorithm, "BC");
return keyFactory.generatePrivate(new PKCS8EncodedKeySpec(privateKeyInfo.getEncoded()));
}
} catch (Exception e) {
log.error("加载密钥体反序列化失败", e);
}
return null;
}

/**
* 从本地文件系统中加载 PEM 证书文件为 X509Certificate
*/
public static X509Certificate loadCertificate(String certificateFilePath) throws Exception {
try (FileInputStream caCertInputStream = new FileInputStream(certificateFilePath)) {
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509", "BC");
return (X509Certificate) certificateFactory.generateCertificate(caCertInputStream);
}
}

/**
* 从本地文件中加载 CSR 文件
*/
public static PKCS10CertificationRequest loadCsr(String csrFilePath) throws IOException {
try (FileInputStream in = new FileInputStream(csrFilePath);
PEMParser pemParser = new PEMParser(new InputStreamReader(in))) {
return (PKCS10CertificationRequest) pemParser.readObject();
}
}

/**
* 辅助工具:从 PrivateKey 实例(EC/RSA)中算力推导还原其物理 PublicKey
*/
private static PublicKey derivePublicKeyFromPrivateKey(PrivateKey privateKey) throws Exception {
if (privateKey == null) {
return null;
}
KeyFactory keyFactory = KeyFactory.getInstance(privateKey.getAlgorithm(), "BC");

if (privateKey instanceof ECPrivateKey ecKey) {
if (ecKey.getParams() != null) {
java.security.spec.ECParameterSpec params = ecKey.getParams();
org.bouncycastle.math.ec.ECCurve curve = org.bouncycastle.jcajce.provider.asymmetric.util.EC5Util.convertCurve(params.getCurve());
org.bouncycastle.math.ec.ECPoint g = org.bouncycastle.jcajce.provider.asymmetric.util.EC5Util.convertPoint(curve, params.getGenerator());

// 1. 计算倍点,并显式调用 normalize() 转回规范化的仿射坐标点
org.bouncycastle.math.ec.ECPoint q = g.multiply(ecKey.getS()).normalize();

// 2. 此时再获取坐标就不会报错了
return keyFactory.generatePublic(new ECPublicKeySpec(
new java.security.spec.ECPoint(q.getAffineXCoord().toBigInteger(), q.getAffineYCoord().toBigInteger()),
params
));
}
} else if (privateKey instanceof RSAPrivateCrtKey rsaKey) {
return keyFactory.generatePublic(new RSAPublicKeySpec(rsaKey.getModulus(), rsaKey.getPublicExponent()));
}
return null;
}

/**
* 工具方法:通过 PrivateKey 的算法自动适配签名算法类型
*/
private static String getSignatureAlgorithm(PrivateKey privateKey) {
String algo = privateKey.getAlgorithm();
if ("EC".equalsIgnoreCase(algo)) {
return "SHA256withECDSA";
} else if ("RSA".equalsIgnoreCase(algo)) {
return "SHA256WithRSAEncryption";
}
throw new IllegalArgumentException("当前 CA 私钥算法 [" + algo + "] 无法匹配合法签名器类型");
}

private static String getKeyFactoryAlgorithm(PrivateKeyInfo privateKeyInfo) {
String algorithm = privateKeyInfo.getPrivateKeyAlgorithm().getAlgorithm().getId();
String keyFactoryAlgorithm = "RSA";
if (X509ObjectIdentifiers.id_ea_rsa.getId().equals(algorithm) || "1.2.840.113549.1.1.1".equals(algorithm)) {
keyFactoryAlgorithm = "RSA";
} else if (X9ObjectIdentifiers.id_ecPublicKey.getId().equals(algorithm) || "1.2.840.10045.2.1".equals(algorithm)) {
keyFactoryAlgorithm = "EC";
}
return keyFactoryAlgorithm;
}
}


AaaCertTest

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
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
import com.zdemo.certs.App;
import com.zdemo.certs.common.AaaCertTypeEnum;
import com.zdemo.certs.entity.AaaCaCertPO;
import com.zdemo.certs.entity.AaaClientCertPO;
import com.zdemo.certs.entity.AaaServerCertPO;
import com.zdemo.certs.entity.CertFilePO;
import com.zdemo.certs.mapper.AaaCaCertDao;
import com.zdemo.certs.mapper.AaaClientCertDao;
import com.zdemo.certs.mapper.AaaServerCertDao;
import com.zdemo.certs.util.AESUtil;
import com.zdemo.certs.util.CertUtil;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.IOUtils;
import org.bouncycastle.asn1.x500.X500Name;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import java.io.ByteArrayInputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.math.BigInteger;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.MessageDigest;
import java.security.PrivateKey;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.ZoneId;
import java.util.Date;

@Slf4j
@SpringBootTest(classes = {App.class})
public class AaaCertTest {
public AaaCertTest() throws ParseException {}
private SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

@Value("${aaa_cert.eKey}")
private String aesKey;

private CertUtil.CertAlgo algo = CertUtil.CertAlgo.EC;

// ca
private String caFilePath = "/Users/xxx/Desktop/test/cert";
private String caSubject = "C=CN, ST=BeiJing, L=ChaoYang, O=CA Center, emailAddress=admin@ca_center.com, CN=ca_center";
private String caCipher = "owlias123";
private BigInteger caSerialNum = new BigInteger("100000000000000");
private Date caStartDate = sdf.parse("2023-01-12 09:28:49");
private Date caEndDate = sdf.parse("2123-12-19 09:28:49");
private String caCrl = "http://www.koohub.com/cert/koohub_ca.crl";
private String caCreateUser = "admin@ca_center.com";
private String caUpdateUser = "admin@ca_center.com";

// server(注意 Subject 要与 ca 的 Subject 进行区分,如果它们俩的 Subject 重合,会导致 OpenSSL 无法区分层级,从而验证证书链失败)
private String serverFilePath = "/Users/xxx/Desktop/test/cert";
private String serverFriendlyName = "Koohub";
private String serverSubject = "C=CN, ST=BeiJing, L=ChaoYang, O=Koohub Inc, emailAddress=owlias@koohub.com, CN=Koohub";
private String serverCipher = "owlias456";
private BigInteger serverSerialNum = new BigInteger("100000000000001");
private Date serverStartDate = sdf.parse("2023-01-12 09:28:49");
private Date serverEndDate = sdf.parse("2123-12-19 09:28:49");
private String serveCrl = "http://www.koohub.com/cert/koohub_server.crl";
private String serverCreateUser = "server@koohub.com";
private String serverUpdateUser = "server@koohub.com";

// client(注意 Subject 要与 ca 和 server 的 Subject 进行区分)
private String clientFilePath = "/Users/xxx/Desktop/test/cert";
private String clientFriendlyName = "zhangsan@koohub.com";
private String clientSubject = "C=CN, ST=BeiJing, L=ChaoYang, O=Koohub Inc., emailAddress=zhangsan@koohub.com, CN=zhangsan@koohub.com";
private String clientCipher = "123456";
private BigInteger clientSerialNum = new BigInteger("100000000000002");
private Date clientStartDate = new Date();
private Date clientEndDate = Date.from(clientStartDate.toInstant().atZone(ZoneId.systemDefault()).plusYears(100).toInstant());
private String clientCrl = "http://www.koohub.com/cert/crl/kohhub_client.crl";

@Resource
private AaaCaCertDao aaaCaCertDao;
@Resource
private AaaServerCertDao aaaServerCertDao;
@Resource
private AaaClientCertDao aaaClientCertDao;

@BeforeEach
public void warmup() {
aaaCaCertDao.getCaCertBySerialNum(0L, 0);
aaaServerCertDao.getServerCertBySerialNum(0L, 0);
aaaClientCertDao.getClientCertInfo(0L, 0, 0L);
}


@Test
public void testGenerateCaCert() throws Exception {
CertUtil.CaCertFileBytes caFileBytes = CertUtil.generateCaFileBytes(algo, caCipher, caSerialNum, new X500Name(caSubject), caStartDate, caEndDate, caCrl);
byte[] certFileBytes = caFileBytes.getKeyFileBytes();
IOUtils.copy(new ByteArrayInputStream(certFileBytes), new FileOutputStream(caFilePath + "/ca.key"));
certFileBytes = caFileBytes.getPemFileBytes();
IOUtils.copy(new ByteArrayInputStream(certFileBytes), new FileOutputStream(caFilePath + "/ca.pem"));
certFileBytes = caFileBytes.getDerFileBytes();
IOUtils.copy(new ByteArrayInputStream(certFileBytes), new FileOutputStream(caFilePath + "/ca.der"));
Thread.sleep(3000);
}

/**
* 保存已有的根证书到数据库(file->db)
*/
@Test
public void testSaveCaCert() throws Exception {
AaaCaCertPO po = new AaaCaCertPO()
.setIssuer(caSubject)
.setSubject(caSubject)
.setCipher(caCipher)
.setSerialNum(caSerialNum.longValue())
.setStartDate(caStartDate)
.setEndDate(caEndDate)
.setCreateUser(caCreateUser)
.setUpdateUser(caUpdateUser)
.setCreateTime(new Date())
.setUpdateTime(new Date());
// ca.key
byte[] certFileBytes = IOUtils.toByteArray(new FileInputStream(caFilePath + "/ca.key"));
byte[] encryptedCertFileBytes = AESUtil.encrypt(certFileBytes, aesKey);
po.setCertFile(encryptedCertFileBytes).setCertType(AaaCertTypeEnum.KEY.getType());
Long id = aaaCaCertDao.insert(po);
System.out.println("ca key: " + id);

// ca.pem
certFileBytes = IOUtils.toByteArray(new FileInputStream(caFilePath + "/ca.pem"));
encryptedCertFileBytes = AESUtil.encrypt(certFileBytes, aesKey);
po.setCertFile(encryptedCertFileBytes).setCertType(AaaCertTypeEnum.PEM.getType());
id = aaaCaCertDao.insert(po);
System.out.println("ca pem: " + id);

// ca.der
certFileBytes = IOUtils.toByteArray(new FileInputStream(caFilePath + "/ca.der"));
encryptedCertFileBytes = AESUtil.encrypt(certFileBytes, aesKey);
po.setCertFile(encryptedCertFileBytes).setCertType(AaaCertTypeEnum.DER.getType());
id = aaaCaCertDao.insert(po);
System.out.println("ca der: " + id);
Thread.sleep(3000);
}

/**
* 从数据库查询ca证书到本地(db->file)
*/
@Test
public void testQueryCaCert() throws Exception {
AaaCaCertPO caKeyPO = aaaCaCertDao.getLastCaCert(AaaCertTypeEnum.KEY.getType());
byte[] encryptedKeyFileBytes = caKeyPO.getCertFile();
byte[] keyFileBytes = AESUtil.decrypt(encryptedKeyFileBytes, aesKey);
IOUtils.copy(new ByteArrayInputStream(keyFileBytes), new FileOutputStream(caFilePath + "/ca.key"));

AaaCaCertPO caPemPO = aaaCaCertDao.getLastCaCert(AaaCertTypeEnum.PEM.getType());
byte[] encryptedPemFileBytes = caPemPO.getCertFile();
byte[] pemFileBytes = AESUtil.decrypt(encryptedPemFileBytes, aesKey);
IOUtils.copy(new ByteArrayInputStream(pemFileBytes), new FileOutputStream(caFilePath + "/ca.pem"));

AaaCaCertPO caDerPO = aaaCaCertDao.getLastCaCert(AaaCertTypeEnum.DER.getType());
byte[] encryptedDerFileBytes = caDerPO.getCertFile();
byte[] derFileBytes = AESUtil.decrypt(encryptedDerFileBytes, aesKey);
IOUtils.copy(new ByteArrayInputStream(derFileBytes), new FileOutputStream(caFilePath + "/ca.der"));
}

/**
* server证书签发到本地
*/
@Test
public void testGenerateServerCert() throws Exception {
AaaCaCertPO caKeyPO = aaaCaCertDao.getLastCaCert(AaaCertTypeEnum.KEY.getType());
byte[] encryptedCaKeyFileBytes = caKeyPO.getCertFile();
byte[] caKeyFileBytes = AESUtil.decrypt(encryptedCaKeyFileBytes, aesKey);
PrivateKey caPrivateKey = CertUtil.loadPrivateKey(caKeyFileBytes, caCipher);

CertUtil.ServerCertFileBytes fileBytes = CertUtil.generateServerFileBytes(algo, caPrivateKey, serverFriendlyName, serverCipher,
serverSerialNum, new X500Name(caSubject), new X500Name(serverSubject), serverStartDate, serverEndDate, serveCrl);
final byte[] keyFileBytes = fileBytes.getKeyFileBytes();
final byte[] pemFileBytes = fileBytes.getPemFileBytes();
final byte[] crtFileBytes = fileBytes.getCrtFileBytes();
final byte[] p12FileBytes = fileBytes.getP12FileBytes();

IOUtils.copy(new ByteArrayInputStream(keyFileBytes), new FileOutputStream(serverFilePath + "/server.key"));
IOUtils.copy(new ByteArrayInputStream(pemFileBytes), new FileOutputStream(serverFilePath + "/server.pem"));
IOUtils.copy(new ByteArrayInputStream(crtFileBytes), new FileOutputStream(serverFilePath + "/server.crt"));
IOUtils.copy(new ByteArrayInputStream(p12FileBytes), new FileOutputStream(serverFilePath + "/server.p12"));
}

/**
* 保存已有的server证书到数据库(file->db)
*/
@Test
public void testSaveServerCert() throws Exception {
AaaServerCertPO po = new AaaServerCertPO()
.setSubject(serverSubject)
.setCipher(serverCipher)
.setSerialNum(serverSerialNum.longValue())
.setCaSerialNum(caSerialNum.longValue())
.setStartDate(serverStartDate)
.setEndDate(serverEndDate)
.setCreateUser(serverCreateUser)
.setUpdateUser(serverUpdateUser)
.setCreateTime(new Date())
.setUpdateTime(new Date());
// server.key
byte[] certFileBytes = IOUtils.toByteArray(new FileInputStream(serverFilePath + "/server.key"));
byte[] encryptedCertFileBytes = AESUtil.encrypt(certFileBytes, aesKey);
po.setCertFile(encryptedCertFileBytes).setCertType(AaaCertTypeEnum.KEY.getType());
Long id = aaaServerCertDao.insert(po);
System.out.println("server key: " + id);

// server.pem
certFileBytes = IOUtils.toByteArray(new FileInputStream(serverFilePath + "/server.pem"));
encryptedCertFileBytes = AESUtil.encrypt(certFileBytes, aesKey);
po.setCertFile(encryptedCertFileBytes).setCertType(AaaCertTypeEnum.PEM.getType());
id = aaaServerCertDao.insert(po);
System.out.println("server pem: " + id);

// server.crt
certFileBytes = IOUtils.toByteArray(new FileInputStream(serverFilePath + "/server.crt"));
encryptedCertFileBytes = AESUtil.encrypt(certFileBytes, aesKey);
po.setCertFile(encryptedCertFileBytes).setCertType(AaaCertTypeEnum.CRT.getType());
id = aaaServerCertDao.insert(po);
System.out.println("server crt: " + id);

// server.p12
certFileBytes = IOUtils.toByteArray(new FileInputStream(serverFilePath + "/server.p12"));
encryptedCertFileBytes = AESUtil.encrypt(certFileBytes, aesKey);
po.setCertFile(encryptedCertFileBytes).setCertType(AaaCertTypeEnum.P12.getType());
id = aaaServerCertDao.insert(po);
System.out.println("server p12: " + id);
Thread.sleep(3000);
}

/**
* 从数据库查询server证书到本地(db->file)
*/
@Test
public void testQueryServerCert() throws Exception {
AaaServerCertPO serverKeyPO = aaaServerCertDao.getLastServerCert(AaaCertTypeEnum.KEY.getType());
byte[] encryptedKeyFileBytes = serverKeyPO.getCertFile();
byte[] keyFileBytes = AESUtil.decrypt(encryptedKeyFileBytes, aesKey);
IOUtils.copy(new ByteArrayInputStream(keyFileBytes), new FileOutputStream(serverFilePath + "/server.key"));

AaaServerCertPO serverPemPO = aaaServerCertDao.getLastServerCert(AaaCertTypeEnum.PEM.getType());
byte[] encryptedPemFileBytes = serverPemPO.getCertFile();
byte[] pemFileBytes = AESUtil.decrypt(encryptedPemFileBytes, aesKey);
IOUtils.copy(new ByteArrayInputStream(pemFileBytes), new FileOutputStream(serverFilePath + "/server.pem"));

AaaServerCertPO serverCrtPO = aaaServerCertDao.getLastServerCert(AaaCertTypeEnum.CRT.getType());
byte[] encryptedCrtFileBytes = serverCrtPO.getCertFile();
byte[] crtFileBytes = AESUtil.decrypt(encryptedCrtFileBytes, aesKey);
IOUtils.copy(new ByteArrayInputStream(crtFileBytes), new FileOutputStream(serverFilePath + "/server.crt"));

AaaServerCertPO serverP12PO = aaaServerCertDao.getLastServerCert(AaaCertTypeEnum.P12.getType());
byte[] encryptedP12FileBytes = serverP12PO.getCertFile();
byte[] p12FileBytes = AESUtil.decrypt(encryptedP12FileBytes, aesKey);
IOUtils.copy(new ByteArrayInputStream(p12FileBytes), new FileOutputStream(serverFilePath + "/server.p12"));
}


/**
* client 证书签发测试
*/
@Test
public void testGenerateClientCert() throws Exception {
long time1 = System.currentTimeMillis();
AaaCaCertPO caKeyPO = aaaCaCertDao.getLastCaCert(AaaCertTypeEnum.KEY.getType());
long time2 = System.currentTimeMillis();
System.out.println("数据库查询 ca.key 耗时(ms):" + (time2 - time1)); // 22
byte[] encryptedCaKeyFileBytes = caKeyPO.getCertFile();

byte[] caKeyFileBytes = AESUtil.decrypt(encryptedCaKeyFileBytes, aesKey);
PrivateKey caPrivateKey = CertUtil.loadPrivateKey(caKeyFileBytes, caCipher);
long time3 = System.currentTimeMillis();
System.out.println("加载 ca.key 耗时(ms):" + (time3 - time2)); // 423

// xxx.p12
CertUtil.ClientCertFileBytes fileBytes = CertUtil.generateClientFileBytes(algo, caPrivateKey, clientFriendlyName, clientCipher,
clientSerialNum, new X500Name(caSubject), new X500Name(clientSubject), clientStartDate, clientEndDate, clientCrl);
long time4 = System.currentTimeMillis();
System.out.println("签发 client.p12 耗时(ms):" + (time4 - time3)); // 1064

final byte[] p12FileBytes = fileBytes.getP12FileBytes();
IOUtils.copy(new ByteArrayInputStream(p12FileBytes), new FileOutputStream(clientFilePath + "/" + clientFriendlyName + ".p12"));
System.out.println("证书序列号是:" + fileBytes.getClientSerialNo());
long time5 = System.currentTimeMillis();
System.out.println("写 client.p12 到本地耗时(ms):" + (time5 - time4)); // 7
}

/**
* 获取服务端证书指纹测试(windows端防弹窗使用)
*
* @throws Exception
*/
@Test
public void test10() throws Exception {
/*FileInputStream in = new FileInputStream("/Users/admin/Desktop/certs-test2/server.crt");
byte[] ss = IOUtils.toByteArray(in);
MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
sha1.update(ss, 0, ss.length);
byte[] digest = sha1.digest();
String shaResult = DatatypeConverter.printHexBinary(digest).toLowerCase();
System.out.println(shaResult);*/

String certificatePath = "/Users/xxx/Desktop/test/cert/server.crt";
FileInputStream fis = new FileInputStream(certificatePath);
CertificateFactory cf = CertificateFactory.getInstance("X.509");
X509Certificate cert = (X509Certificate) cf.generateCertificate(fis);
MessageDigest md = MessageDigest.getInstance("SHA-1");
byte[] certBytes = cert.getEncoded();
byte[] hashBytes = md.digest(certBytes);

StringBuilder sb = new StringBuilder();
for (byte b : hashBytes) {
sb.append(String.format("%02x", b));
}
String certFingerPrint = sb.toString();
System.out.println("Certificate fingerprint: " + certFingerPrint);
}

/**
* 测试获取 p12 文件获取证书序列号
*/
@Test
public void readP12Test() throws Exception {
// P12文件的路径和密码
String p12FilePath = "/Users/xxx/Desktop/test/cert/zhangsan@koohub.com.p12";
char[] password = "123456".toCharArray();

// 创建一个KeyStore对象,并加载P12文件
KeyStore keyStore = KeyStore.getInstance("PKCS12");
try (FileInputStream fis = new FileInputStream(p12FilePath)) {
keyStore.load(fis, password);
} catch (Exception e) {
e.printStackTrace();
}

// 获取P12文件中的私钥和证书
// 获取私钥和公钥等资源
String alias = keyStore.aliases().nextElement(); // 获取别名(key alias)
PrivateKey privateKey = (PrivateKey) keyStore.getKey(alias, password); // 获取私钥
java.security.cert.Certificate certificate = keyStore.getCertificate(alias); // 获取证书

// 打印私钥和证书的信息
if (certificate instanceof X509Certificate) {
X509Certificate x509Certificate = (X509Certificate) certificate;
System.out.println("Serial Number: " + x509Certificate.getSerialNumber().toString(16)); // radius服务器获取的、windows端保存的客户端证书序列号是16进制,mac端钥匙串保存的为10进制
} else {
System.out.println("This certificate is not of type X509Certificate.");
}
}

@Test
public void test13() throws KeyStoreException {
AaaClientCertPO infoPo = aaaClientCertDao.getClientCertInfoById(85L); // certType = 4
char[] cipher = infoPo.getCipher().toCharArray();

CertFilePO clientCertFile = aaaClientCertDao.getClientCertFile(infoPo.getCertFileId());
byte[] bs = clientCertFile.getCertFile();
bs = AESUtil.decrypt(bs, aesKey);

KeyStore keyStore = KeyStore.getInstance("PKCS12");
try (ByteArrayInputStream is = new ByteArrayInputStream(bs)) {
keyStore.load(is, cipher);
} catch (Exception e) {
e.printStackTrace();
}

String alias = keyStore.aliases().nextElement(); // 获取别名(key alias)
java.security.cert.Certificate certificate = keyStore.getCertificate(alias); // 获取证书

if (certificate instanceof X509Certificate) {
X509Certificate x509Certificate = (X509Certificate) certificate;
log.info("Serial Number: {}", x509Certificate.getSerialNumber().toString(16)); // radius服务器获取的、windows端保存的客户端证书序列号是16进制,mac端钥匙串保存的为10进制
} else {
log.info("This certificate is not of type X509Certificate.");
}
}
}


测试验证

生成证书

使用上述测试类生成一系列 CA、Server、Client 证书。其中客户端证书服务端仅保留 p12 格式即可。为了使用 eapol_test 认证测试 ,这里在使用命令行从 p12 提取生成 pem 或者 crt 证书:

1
2
3
4
5
# 从 P12 提取标准证书 (.crt)
openssl pkcs12 -in zhangsan@koohub.com.p12 -passin pass:'123456' -nokeys -out zhangsan@koohub.com.crt

# 从 P12 容器中提取 PEM 格式 的证书(和 .crt 一样的)
openssl pkcs12 -in zhangsan@koohub.com.p12 -passin pass:'123456' -nokeys -out zhangsan@koohub.com.pem


替换服务端证书

使用我们新生成的 ca. 和 server. 替换掉原 freeradius 的证书。第二,需要修改对应的 eap 配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
eap {

...

tls-config tls-common {
# server.key 的查看密码
private_key_password = owlias456
private_key_file = ${certdir}/server.key
certificate_file = ${certdir}/server.pem
ca_file = ${cadir}/ca.pem
ca_path = ${cadir}

tls_min_version = "1.2"
cipher_list = "DEFAULT@SECLEVEL=1"
}

...
}


客户端证书准备

1
2
3
4
$ docker cp ca.pem radius-client:/root/test 

$ docker cp zhangsan@koohub.com.pem radius-client:/root/test
$ docker cp zhangsan@koohub.com.key radius-client:/root/test


开始测试

启动 REST 服务、freeradius 服务,在测试端发送入网认证请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
vim eap-tls.conf
network={
ssid="Corporate-WiFi"
key_mgmt=IEEE8021X
eap=TLS
identity="zhangsan@koohub.com"
ca_cert="/root/test/ca.pem"
client_cert="/root/test/zhangsan@koohub.com.pem"
private_key="/root/test/zhangsan@koohub.com.key"
private_key_passwd="jia_mi_pass"
}

$ eapol_test -c eap-tls.conf -a 172.18.0.3 -p 1812 -s testing123
...
WPA: Clear old PMK and PTK
EAP: deinitialize previously used EAP method (13, TLS) at EAP deinit
MPPE keys OK: 1 mismatch: 0
SUCCESS

OK,成功!观察 REST 服务日志输出是否接收到了我们自定义证书的证书序列号(注意:freeradius 请求过来的证书号是16禁止进制格式,0x5af3107a4002 即是十进制的 100000000000002):

至此,我们的测试就非常完美了!如果想要对证书进行吊销,我们只需要对数据库中对应证书的状态变成 disabled 即可。当发现某个证书的状态不可用,那么就立即下发隔离 VLAN ,或者干脆直接拒绝入网即可。

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
@Slf4j
@Service
public class RadiusAssetService {

public String validateAssetAndAssignVlan(RadiusAssetCheckReq req) {
// 1. 哑终端/MAC 准入场景判断
if (req.getUsername() != null && req.getUsername().equalsIgnoreCase(req.getMac())) {
return processMacAccess(req);
}

// 2. EAP-TLS 证书认证场景判断
if (StringUtils.hasText(req.getCertSerialNumber())) {
// 模拟证书吊销
if ("5af3107a4002".equalsIgnoreCase(req.getCertSerialNumber())) {
throw new AssetAccessDeniedException("证书已吊销,拒绝入网");
}
return processCertAccess(req);
}

// 3. 普通账号密码(PAP / CHAP / EAP-TTLS)场景判断
return processUserAccess(req);
}

private String processMacAccess(RadiusAssetCheckReq req) {
// TODO
return "300";
}

// ...
}

再次入网请求就会发现,网络认证真的被阻止了!

1
2
3
4
5
6
$ eapol_test -c eap-tls.conf -a 172.18.0.3 -p 1812 -s testing123
...
WPA: Clear old PMK and PTK
EAP: deinitialize previously used EAP method (13, TLS) at EAP deinit
MPPE keys OK: 0 mismatch: 1
FAILURE

其实,这里还剩最后一个问题,无论是证书吊销,还是其他方式,我们能阻止的是用户下一次的入网登录认证,这里缺少 “踢人” 的关键步骤。零信任检测到风险设备,不仅要让其证书失效,还要把这个终端(对设备)或者用户(对人)踢下线。怎么实现呢?请听下回分解。。😄