# iKuaiSSL证书管理插件0.1.2技术数据说明

## 1.产品边界

插件管理iKuai主Web使用的唯一共享证书入口：

- 证书链：`/usr/openresty/ssl/server.crt`
- 私钥：`/usr/openresty/ssl/server.key`
- 生效方式：证书文件校验通过后执行`openresty -s reload`

每台设备只维护一个证书任务。系统不会把多个域名证书分别部署到不同入口，也不会允许多个任务争用同一组`server.crt`和`server.key`。

## 2.证书任务模型

数据库表：`acme_certificate`

|字段|类型|说明|
|---|---|---|
|`id`|INTEGER|主键|
|`domain`|TEXT|域名或公网IP；域名支持泛域名，泛域名自动附加裸域|
|`email`|TEXT|ACME注册邮箱|
|`ca`|TEXT|`letsencrypt`、`zerossl`或`google`|
|`ca_dir_url`|TEXT|CA目录地址|
|`challenge_type`|TEXT|`dns01`或`http01`|
|`dns_provider`|TEXT|DNS服务商标识|
|`dns_credentials`|TEXT|AES-256-GCM加密后的DNS凭证|
|`eab_kid`|TEXT|EAB Key ID；非机密标识，可直接保存|
|`eab_hmac`|TEXT|AES-256-GCM加密后的EAB HMAC|
|`webroot`|TEXT|HTTP验证目录，默认`/usr/ikuai/www`|
|`status`|TEXT|`idle`、`issued`或`error`|
|`cert_path`|TEXT|证书路径记录|
|`key_path`|TEXT|私钥路径记录|
|`issued_at`|INTEGER|签发时间，Unix秒|
|`expires_at`|INTEGER|到期时间，Unix秒|
|`last_error`|TEXT|最近错误|
|`auto_renew`|INTEGER|是否自动续期，`1`或`0`|
|`renew_before_days`|INTEGER|提前续期天数；IP短证强制为2|
|`created_at`|INTEGER|创建时间，Unix秒|
|`updated_at`|INTEGER|更新时间，Unix秒|

单证书约束由三层保证：

1. 前端已有任务时隐藏创建入口；
2. 后端创建前检查已有记录；
3. SQLite插入使用`WHERE NOT EXISTS`原子限制，并发创建时第二个请求返回单证书错误。

0.1.1及以后版本启动迁移时，如果早期数据库存在多条任务，只保留`updated_at`最大的记录；时间相同时保留`id`最大的记录。

## 3.编辑配置

当前唯一证书行新增“编辑”按钮。编辑窗口回填以下非敏感配置：

- 域名或IP
- 邮箱
- 证书类型
- 验证方式
- DNS服务商
- CA
- 自动续期开关
- 提前续期天数

以下字段永远不从API返回，也不会回填到浏览器：

- DNS AccessKey ID
- DNS AccessKey Secret
- DNS Token
- DNS API Secret
- EAB HMAC
- 其他provider定义的凭证字段

编辑窗口中的密钥输入框为空时表示“不修改”，后端保留数据库中的原加密密文。只有用户填写新值时，后端才重新加密并替换旧密文。

如果编辑时更换DNS服务商，必须填写新服务商凭证；否则返回错误，不会保存半成品配置。

编辑操作只保存配置，不立即请求CA签发，避免用户修改一个普通字段时意外触发ACME限流。保存后任务状态变为`idle`，需要用户单独点击“申请/续期”完成新证书申请。申请失败时旧的已部署证书文件仍由部署回滚机制保护。

接口：

```text
GET /api/v1/acme/certificate
PUT /api/v1/acme/certificate
POST /api/v1/acme/certificate/renew?id=<id>
DELETE /api/v1/acme/certificate?id=<id>
```

`PUT`只更新当前唯一任务，不创建新任务。

## 4.密钥加密方案

插件没有直接把GWID当作业务数据的AES密钥。采用“设备身份派生KEK＋随机主密钥＋AES-256-GCM”的两层方案。

### 4.1设备密钥封装

设备启动时从`/etc/release`读取：

```text
GWID=<设备唯一标识>
```

派生密钥加密密钥：

```text
KEK = SHA-256("ikuai-ssl-v1:" + GWID)
```

结果为32字节，用作AES-256-GCM密钥。

首次运行生成密码学随机的32字节`master key`，再用KEK封装，保存到：

```text
<APP_DATA_DIR>/master.key
```

`master.key`文件内容包含随机Nonce和GCM密文，经过Base64编码，文件权限为`0600`。

### 4.2业务凭证加密

DNS凭证和EAB HMAC使用随机`master key`加密：

```text
明文凭证
  ↓ AES-256-GCM，随机12字节Nonce
Nonce + Ciphertext + 认证标签
  ↓ Base64
SQLite中的加密字段
```

每次加密使用新的随机Nonce。AES-GCM认证标签用于检测密文被修改、截断或伪造。

当前加密字段：

- `dns_credentials`
- `eab_hmac`

ACME账户私钥同样使用`KeyManager`的AES-256-GCM接口保存。0.1.2首次读取0.1.0明文账户私钥时，会先解析并立即重写为密文；之后文件中只保存Base64编码的Nonce、Ciphertext和认证标签。

### 4.3安全属性和限制

- GWID不是秘密，因此不直接把GWID的哈希当作所有业务数据的长期密钥；
- 随机`master key`保证业务密钥不由公开设备标识直接决定；
- GWID变化后，旧`master.key`无法通过新的KEK解封装；
- `master.key`和SQLite数据都在应用数据目录中，文件权限分别为`0600`和目录`0700`；
- 该方案依赖设备本地文件权限和设备运行环境安全，不等同于硬件安全模块；
- 如果攻击者同时取得设备运行权限、GWID和`master.key`，仍可能离线解密，因此不把文件加密误称为硬件级防护。

## 5.域名和IP证书规则

- 域名默认DNS-01，可申请泛域名；
- 泛域名自动加入对应裸域SAN；
- 普通域名也可使用HTTP-01；
- 公网IP只允许HTTP-01；
- IP证书使用Let'sEncrypt shortlived profile，有效期约160小时；
- IP证书CSR只写IP SAN，不把IP写入CN；
- IP证书续期窗口固定为提前2天；
- HTTP-01挑战路径需要OpenResty持续放行`/.well-known/acme-challenge/`。

## 6.数据流

```text
Web面板
  ↓ PUT，密钥字段为空或新值
HTTP API
  ↓ 校验单证书、类型、CA、验证方式
Service
  ↓ 空密钥保留旧密文；新密钥AES-256-GCM加密
SQLite
  ↓ 用户单独点击申请/续期
ACME引擎
  ↓ 签发成功
OpenResty部署器
  ↓ 备份、写入、openresty -t、reload、TLS重试校验
/usr/openresty/ssl/server.crt
/usr/openresty/ssl/server.key
```

## 7.验证记录

0.1.2源码已完成以下验证：

- 单证书创建限制测试；
- 多条旧数据迁移测试；
- 编辑配置测试；
- 密钥留空保留原加密密文测试；
- 编辑后状态回到待申请测试；
- `go test ./...`；
- `go vet ./...`；
- `go build ./...`；
- `node --check internal/server/web/app.js`；
- Shell语法检查；
- JSON格式检查；
- 浏览器编辑窗口实测；
- 编辑窗口不回显DNS密钥和EAB HMAC；
- 编辑保存使用`PUT`，不触发ACME申请。
