From 8c872ac15df6c0329652a2bec56a54fb1d0786ab Mon Sep 17 00:00:00 2001 From: xiaoqu Date: Mon, 7 Sep 2026 12:41:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(open):=20ensure=E6=94=AF=E6=8C=81=E4=BA=8C?= =?UTF-8?q?=E6=AC=A1=E8=8E=B7=E5=8F=96=E6=98=8E=E6=96=87(ak=E5=AD=98AES-GC?= =?UTF-8?q?M=E5=AF=86=E6=96=87key=5Fcipher,=20app.token-cipher-key?= =?UTF-8?q?=E6=B4=BE=E7=94=9F=E5=AF=86=E9=92=A5);=20=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E6=97=A0=E5=AF=86=E6=96=87Token=E8=87=AA=E5=8A=A8=E8=BD=AE?= =?UTF-8?q?=E6=8D=A2;=20V3=E8=BF=81=E7=A7=BB+=E5=9B=9E=E6=BB=9A=E6=96=B9?= =?UTF-8?q?=E6=A1=88;=20=E5=90=8C=E6=AD=A5=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- xiaoqu-agent-service/README.md | 9 ++- .../doc/agent-mcp-service-design.md | 15 ++-- .../xiaoqu-ai-mcp-per-user-token-design.md | 8 +- .../doc/xiaoqu-mcp-integration-setup.md | 6 +- .../sql/rollback/V3__token_cipher.down.sql | 3 + .../me/iiv/agent/admin/AdminTokenService.java | 9 ++- .../me/iiv/agent/entity/svc/AgentApiKey.java | 5 ++ .../iiv/agent/open/OpenTokenController.java | 4 +- .../me/iiv/agent/open/OpenTokenService.java | 29 +++++-- .../me/iiv/agent/security/TokenCipher.java | 78 +++++++++++++++++++ .../src/main/resources/application-dev.yml | 3 + .../db/migration/V3__token_cipher.sql | 3 + .../resources/db/migration/checksums.sha256 | 1 + .../agent/admin/AdminTokenServiceTest.java | 9 ++- .../iiv/agent/config/FlywayMigrationTest.java | 5 +- .../iiv/agent/open/OpenTokenServiceTest.java | 46 ++++++++++- .../iiv/agent/security/TokenCipherTest.java | 50 ++++++++++++ 17 files changed, 249 insertions(+), 34 deletions(-) create mode 100644 xiaoqu-agent-service/sql/rollback/V3__token_cipher.down.sql create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/security/TokenCipher.java create mode 100644 xiaoqu-agent-service/src/main/resources/db/migration/V3__token_cipher.sql create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/security/TokenCipherTest.java diff --git a/xiaoqu-agent-service/README.md b/xiaoqu-agent-service/README.md index 25ced18..3e445b6 100644 --- a/xiaoqu-agent-service/README.md +++ b/xiaoqu-agent-service/README.md @@ -132,6 +132,8 @@ cp deploy/.env.example deploy/.env.dev # 填写 dev 凭据(已 gitignore, 仓库不提供固定 ak。每位开发者在本地生成随机 ak,只把 SHA-256 写入 `agent_api_key`,再用 `token_scope` 关联需要的作用域;明文只保存在本机客户端配置(如 `.kimi-code/mcp.json`,已 gitignore)中,不提交版本库。仓库历史中曾出现的 dev ak 必须视为已泄露并在数据库中禁用。 +手工插入的 ak 没有 `key_cipher` 密文:走 `/mcp` 正常(只校验哈希),但被 `ensure` 命中时会触发自动轮换(旧把禁用、签发新把)——这是预期行为。 + ```bash AK="xqak_$(openssl rand -hex 24)" # 生成随机 ak,明文只贴进本机客户端配置 printf %s "$AK" | shasum -a 256 # 输出的哈希写入 agent_api_key.key_hash @@ -211,9 +213,10 @@ curl -X POST http://localhost:8088/api/open/token/ensure \ ``` - 入参:`mobile`、`email` 至少一个(手机号优先);`scopeCode` 必填。人员只认企业账号(enterprise_type 0/1),查不到 → 404。 -- 已存在有效 Token → `{"created":false,"token":null,"keyPrefix":"xqak_xxx…",...}`(服务端只存哈希,**明文不可回吐**,丢了只能后台重新签发)。 -- 不存在 → 自动创建永久 Token → `{"created":true,"token":"xqak_...(明文,仅此一次)",...}`。 -- 调用写 `audit_log`(`open.token.ensure` / client_type=open-api)。 +- 已存在有效 Token → `{"created":false,"token":"xqak_...(解密既有密文得到的明文)",...}`(**支持二次获取**:服务端保存 SHA-256 哈希 + AES-GCM 密文,密钥由 `app.token-cipher-key` 派生)。 +- 已存在但无密文(历史数据)→ 自动轮换:禁用旧把、签发新把,按 `created=true` 返回新明文。 +- 不存在 → 自动创建永久 Token → `{"created":true,"token":"xqak_...(明文)",...}`。 +- 调用写 `audit_log`(`open.token.ensure` / client_type=open-api,不记录明文)。 **签发规则(2026-09-04 起)**:一作用域一 Token。后台签发时选 N 个作用域会生成 N 把 token,每把独立绑定单作用域;编辑存量 token 时作用域为单选。 diff --git a/xiaoqu-agent-service/doc/agent-mcp-service-design.md b/xiaoqu-agent-service/doc/agent-mcp-service-design.md index 692024b..080f3d0 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-service-design.md +++ b/xiaoqu-agent-service/doc/agent-mcp-service-design.md @@ -123,7 +123,7 @@ xq CLI ─────┘ (Go 单二进制, 同为 MCP 客户端) ┌── ``` - Claude Code:`claude mcp add --transport http xiaoqu https://mcp.<域名>/mcp --header "Authorization: Bearer "`。 -- ak 明文只出现在服务端签发响应和运行时;服务端保存 SHA-256 哈希及加密密文,含 ak 的客户端配置文件不得提交版本库。 +- ak 明文只出现在服务端签发/ensure 响应和运行时;服务端保存 SHA-256 哈希及 AES-GCM 加密密文(二次获取用),含 ak 的客户端配置文件不得提交版本库。 ### 5.2 Web 管理后台(四个页面) @@ -155,9 +155,10 @@ xq CLI ─────┘ (Go 单二进制, 同为 MCP 客户端) ┌── - **`POST /api/open/token/ensure`**(get-or-create): - 入参:`mobile`、`email`(至少一个,手机号优先)、`scopeCode`(作用域编码,必填)。 - 人员只认企业账号(`enterprise_type` 0/1,NULL 保留,与后台用户列表同口径);查不到 → 404。 -- 该人员在该作用域下**已存在**有效 Token(正常且未过期)→ `created=false`,只回 `keyPrefix`/`expireTime` 等元信息,明文不回传。 - - **不存在** → 自动创建永久 Token(一作用域一 Token,memo 标记 `open-api 自动签发`),`created=true` 返回明文,仅此一次。 - - 全程写 `audit_log`(`tool_name=open.token.ensure`,`client_type=open-api`,参数摘要脱敏)。 + - 该人员在该作用域下**已存在**有效 Token(正常且未过期)→ `created=false`,**解密 `key_cipher` 回传明文**(二次获取,2026-09-07 决策:服务端保存 SHA-256 哈希 + AES-GCM 加密密文,密钥由配置 `app.token-cipher-key` 派生)。 + - 已存在但**无密文**(V3 之前的历史数据)→ 自动轮换:禁用旧把、签发新把,按 `created=true` 返回新明文;解密失败 → 500,不回退。 + - **不存在** → 自动创建永久 Token(一作用域一 Token,memo 标记 `open-api 自动签发`),`created=true` 返回明文。 + - 全程写 `audit_log`(`tool_name=open.token.ensure`,`client_type=open-api`,参数摘要脱敏,不记录明文)。 ## 6. 技术栈与工程结构 @@ -186,7 +187,7 @@ xiaoqu-agent-service/ | 表 | 关键字段 | 说明 | |---|---|---| -| `agent_api_key` | user_id、key_prefix、key_hash(SHA-256)、status、expire_time(可空=永久)、last_used_time | token;user_id 逻辑关联 m0.user | +| `agent_api_key` | user_id、key_prefix、key_hash(SHA-256)、key_cipher(AES-GCM 密文)、status、expire_time(可空=永久)、last_used_time | token;user_id 逻辑关联 m0.user | | `scope` | name、code、memo、status | 作用域 | | `scope_tool` | scope_id、tool_id | 作用域-工具 | | `token_scope` | token_id、scope_id | token-作用域 | @@ -199,7 +200,7 @@ xiaoqu-agent-service/ ## 8. 安全基线 - dev/内网测试阶段允许裸跑 HTTP(客户端 URL 用 `http://`);**对外/生产必须 HTTPS**(Nginx 终结 TLS,ak 在 HTTP 下明文可被抓包) -- ak 只存哈希;审计不记录 ak;限流按 token 维度(防 agent 死循环打爆库) +- ak 存 SHA-256 哈希 + AES-GCM 加密密文(不存明文;密钥独立配置 `app.token-cipher-key`);审计不记录 ak;限流按 token 维度(防 agent 死循环打爆库) - 数据访问全部只读账号;scope 最小化发放,新工具默认不可用 - 可选:Nginx IP 白名单(客户端出口固定时) @@ -240,7 +241,7 @@ xiaoqu-agent-service/ - 不依赖老 jar,Entity 自行生成 → 与老系统零代码依赖 - 前后端一体打包单 jar → 可逆决定,将来可拆 - 一作用域一 Token(2026-09-04)→ 收回某能力不需整把吊销;数据模型不变(token_scope 每把只写一行) -- 长期 Token 仅按用户首次创建返回明文一次;后续复用元信息,不对外提供二次获取明文接口(服务端仅用于禁用/审计与治理) +- ensure 支持二次获取明文(2026-09-07,推翻此前"明文不可回吐"红线):服务端存 SHA-256 哈希 + AES-GCM 密文(`app.token-cipher-key` 派生密钥);已存在 Token 解密回传明文,历史无密文 Token 自动轮换 → 调用方丢了明文无需人工重新签发;代价是拿到 `X-Open-Key` 者可取回任意用户 ak 明文,密钥必须仅限受信任后端保管 - Open API 鉴权用配置文件静态密钥(dev)→ 服务间调用方少,落库+轮换留给生产阶段 - 列表类接口一律服务端分页,pageSize/rows 上限 100(超出截断为 100)→ 防止一次性拉全表,MCP 工具与后台 API 同一尺度 diff --git a/xiaoqu-agent-service/doc/xiaoqu-ai-mcp-per-user-token-design.md b/xiaoqu-agent-service/doc/xiaoqu-ai-mcp-per-user-token-design.md index 9cac50d..e8d2fc1 100644 --- a/xiaoqu-agent-service/doc/xiaoqu-ai-mcp-per-user-token-design.md +++ b/xiaoqu-agent-service/doc/xiaoqu-ai-mcp-per-user-token-design.md @@ -24,7 +24,7 @@ ### 1.3 明确不做 - 不把一个固定 Token 写入 App、公共配置文件或所有用户共用。 -- 不把数据库中保存的 Token 哈希当作客户端 Token 返回;明文只在签发响应中出现一次。 +- 不把数据库中保存的 Token 哈希当作客户端 Token 返回;明文仅通过签发/ensure 响应经加密密文解密返回,不落日志。 - 不让客户端提交任意 `user_id`、`tenant_id` 来冒充用户身份。 ## 2. 现状分析 @@ -38,7 +38,7 @@ | `xiaoqu-ai/claw-code-parity/rust/crates/claw-mobile/src/mcp_http.rs` | Rust HTTP | JSON-RPC HTTP/SSE MCP 客户端 | 修改 | | `xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AkAuthFilter.java` | 服务鉴权 | 仅解析 `Authorization: Bearer ` | 保持兼容 | | `xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AuthService.java` | 服务鉴权 | AK SHA-256 哈希查询、用户加载、过期检查 | 修改/复用 | -| `xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminTokenService.java` | 服务 Token | 管理员为指定用户签发 AK,明文只返回一次 | 扩展复用 | +| `xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminTokenService.java` | 服务 Token | 管理员为指定用户签发 AK,保存哈希 + AES-GCM 密文 | 扩展复用 | ### 2.2 当前缺口 @@ -96,7 +96,7 @@ 响应 200: - `created`: bool — 本次是否新建。 -- `token`: string | null — **仅 `created=true` 时返回明文,且只出现这一次**;`created=false` 时恒为 null。 +- `token`: string — **Token 明文,两条路径均返回**(2026-09-07 起支持二次获取);`created=true` 为新签发的明文,`created=false` 为解密既有密文得到的明文。 - `keyPrefix`: string — Token 前缀(如 `xqak_abcd1234`),用于标识和对账。 - `expireTime`: datetime | null — 现行为 null(永久,靠禁用回收)。 - `userId` / `userName` / `scopeCode`: 归属信息。 @@ -111,7 +111,7 @@ 服务端处理(已实现): - 复用 `agent_api_key` 哈希存储与 `token_scope` 绑定;一作用域一 Token。 -- 已存在有效 Token(正常且未过期)只回元信息——**明文不可回吐,服务端只存 SHA-256**。 +- 已存在有效 Token(正常且未过期)→ 解密 `key_cipher` 回传明文(服务端保存 SHA-256 + AES-GCM 密文,密钥由 `app.token-cipher-key` 派生);历史无密文 Token 自动轮换(禁用旧把、签发新把)。 - 不存在则自动创建永久 Token,memo 标记 `open-api 自动签发()`。 - 全程写 `audit_log`(`tool_name=open.token.ensure`、`client_type=open-api`,手机号/邮箱脱敏,不记录 Token)。 diff --git a/xiaoqu-agent-service/doc/xiaoqu-mcp-integration-setup.md b/xiaoqu-agent-service/doc/xiaoqu-mcp-integration-setup.md index 8a15666..d8a1c71 100644 --- a/xiaoqu-agent-service/doc/xiaoqu-mcp-integration-setup.md +++ b/xiaoqu-agent-service/doc/xiaoqu-mcp-integration-setup.md @@ -44,9 +44,9 @@ Content-Type: application/json - `X-Open-Key` 只能配置在受信任后端,不能下发 App。 - 业务后端必须从已验证用户会话查询手机号/邮箱,不能信任客户端或模型传入的身份字段。 -- 首次创建返回 Token 明文;xiaoqu-mcp 同时保存 SHA-256 哈希和 KMS/独立密钥加密密文。 -- 已存在 Token 不回传明文,重复调用返回元信息并复用。 -- Token 密文缺失或解密失败必须报错;不得回退到哈希值或公共 Token。 +- 首次创建返回 Token 明文;xiaoqu-mcp 同时保存 SHA-256 哈希和 AES-GCM 加密密文(密钥由 `app.token-cipher-key` 派生)。 +- 已存在 Token 解密回传明文(二次获取),重复调用幂等复用同一把 Token;历史无密文 Token 自动轮换(禁用旧把、签发新把)。 +- Token 密文解密失败必须报错;不得回退到哈希值或公共 Token。 ## 4. 推荐实现 diff --git a/xiaoqu-agent-service/sql/rollback/V3__token_cipher.down.sql b/xiaoqu-agent-service/sql/rollback/V3__token_cipher.down.sql new file mode 100644 index 0000000..9708767 --- /dev/null +++ b/xiaoqu-agent-service/sql/rollback/V3__token_cipher.down.sql @@ -0,0 +1,3 @@ +-- 回滚 V3:删除 key_cipher 列。回滚后 ensure 对存量 Token 无法二次获取明文(回到只发新把模式)。 +ALTER TABLE agent_api_key + DROP COLUMN key_cipher; diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminTokenService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminTokenService.java index f20544e..efc796c 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminTokenService.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminTokenService.java @@ -10,6 +10,7 @@ import me.iiv.agent.mapper.svc.AgentApiKeyMapper; import me.iiv.agent.mapper.svc.AgentScopeMapper; import me.iiv.agent.mapper.svc.TokenScopeMapper; import me.iiv.agent.security.AuthService; +import me.iiv.agent.security.TokenCipher; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,18 +26,21 @@ public class AdminTokenService { private final AgentApiKeyMapper tokenMapper; private final TokenScopeMapper relationMapper; private final AgentScopeMapper scopeMapper; + private final TokenCipher tokenCipher; public AdminTokenService(UserMapper userMapper, AgentApiKeyMapper tokenMapper, - TokenScopeMapper relationMapper, AgentScopeMapper scopeMapper) { + TokenScopeMapper relationMapper, AgentScopeMapper scopeMapper, + TokenCipher tokenCipher) { this.userMapper = userMapper; this.tokenMapper = tokenMapper; this.relationMapper = relationMapper; this.scopeMapper = scopeMapper; + this.tokenCipher = tokenCipher; } /** * 签发 Token:一作用域一 Token。选 N 个作用域生成 N 把,每把独立绑定单个作用域, - * 便于独立吊销、独立审计,明文逐把返回(仅本次可见)。 + * 便于独立吊销、独立审计,明文逐把返回;服务端同时保存 SHA-256 哈希和 AES-GCM 密文(供二次获取)。 */ @Transactional(transactionManager = "svcTransactionManager") public List issue(Long userId, String memo, List scopeIds, Date expireTime, Long adminId) { @@ -56,6 +60,7 @@ public class AdminTokenService { token.setUserId(userId); token.setKeyPrefix(plaintext.substring(0, 13)); token.setKeyHash(AuthService.sha256Hex(plaintext)); + token.setKeyCipher(tokenCipher.encrypt(plaintext)); token.setMemo(trim(memo, 255)); token.setStatus((byte) 1); token.setExpireTime(expireTime); diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/AgentApiKey.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/AgentApiKey.java index d8e8f02..179f1f2 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/AgentApiKey.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/AgentApiKey.java @@ -47,6 +47,11 @@ public class AgentApiKey implements Serializable { */ private String keyHash; + /** + * ak 明文 AES-GCM 加密密文(二次获取用),空=历史数据未存 + */ + private String keyCipher; + /** * 用途备注(如 Kimi 用 / Claude 用) */ diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenTokenController.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenTokenController.java index 049f38b..8a2e760 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenTokenController.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenTokenController.java @@ -21,8 +21,8 @@ public class OpenTokenController { } /** - * 确保某人员在某作用域下有有效 Token:存在则返回元信息(created=false,无明文), - * 不存在则自动创建永久 Token 并返回明文(created=true,仅此一次可见)。 + * 确保某人员在某作用域下有有效 Token:存在则解密回传明文(created=false,二次获取), + * 不存在则自动创建永久 Token 并返回明文(created=true);历史无密文 Token 自动轮换后按创建返回。 */ @PostMapping("/token/ensure") public EnsureResult ensure(@RequestBody EnsureRequest body) { diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenTokenService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenTokenService.java index f9fd985..1e6c67e 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenTokenService.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenTokenService.java @@ -9,6 +9,7 @@ import me.iiv.agent.entity.svc.AgentScope; import me.iiv.agent.mapper.m0.UserMapper; import me.iiv.agent.mapper.svc.AgentScopeMapper; import me.iiv.agent.security.AuthContext; +import me.iiv.agent.security.TokenCipher; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,8 +20,9 @@ import java.util.Date; /** * Open API:按 手机号/邮箱 + 作用域编码 确保 Token 存在(get-or-create)。 - * 存在有效 Token 只回元信息(服务端只存哈希,明文不可回吐); - * 不存在则自动创建永久 Token,明文仅本次返回。 + * 存在有效 Token 时解密 key_cipher 回传明文(二次获取,2026-09-07 决策); + * 历史数据无密文的 Token 自动轮换(禁用旧把、签发新把); + * 不存在则自动创建永久 Token。两条路径均返回明文。 */ @Service public class OpenTokenService { @@ -33,13 +35,16 @@ public class OpenTokenService { private final AgentScopeMapper scopeMapper; private final AdminTokenService tokenService; private final AuditService auditService; + private final TokenCipher tokenCipher; public OpenTokenService(UserMapper userMapper, AgentScopeMapper scopeMapper, - AdminTokenService tokenService, AuditService auditService) { + AdminTokenService tokenService, AuditService auditService, + TokenCipher tokenCipher) { this.userMapper = userMapper; this.scopeMapper = scopeMapper; this.tokenService = tokenService; this.auditService = auditService; + this.tokenCipher = tokenCipher; } @Transactional(transactionManager = "svcTransactionManager") @@ -65,9 +70,21 @@ public class OpenTokenService { AgentApiKey existing = tokenService.findActiveToken(user.getId(), scope.getId()); if (existing != null) { - audit(user, existing, body, "success", "已存在有效 Token,返回元信息", start); - return new OpenTokenController.EnsureResult(false, null, existing.getKeyPrefix(), - existing.getExpireTime(), user.getId(), user.getName(), scope.getCode()); + if (StringUtils.hasText(existing.getKeyCipher())) { + String plaintext; + try { + plaintext = tokenCipher.decrypt(existing.getKeyCipher()); + } catch (IllegalStateException e) { + audit(user, existing, body, "error", "Token 密文解密失败", start); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Token 密文解密失败"); + } + audit(user, existing, body, "success", "已存在有效 Token,解密返回明文", start); + return new OpenTokenController.EnsureResult(false, plaintext, existing.getKeyPrefix(), + existing.getExpireTime(), user.getId(), user.getName(), scope.getCode()); + } + // 历史数据无密文:禁用旧把并签发新把(轮换),保证总能拿到明文 + tokenService.status(existing.getId(), (byte) 0, SYSTEM_CREATOR_ID); + audit(user, existing, body, "success", "旧 Token 无密文,自动轮换", start); } AdminTokenService.IssuedToken issued = tokenService.issueSingle(user.getId(), diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/TokenCipher.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/TokenCipher.java new file mode 100644 index 0000000..0cce928 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/TokenCipher.java @@ -0,0 +1,78 @@ +package me.iiv.agent.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * ak 明文加密器:AES-GCM,密钥由配置 app.token-cipher-key 经 SHA-256 派生(配置任意长度字符串即可)。 + * 密文格式:Base64( iv(12B) ‖ cipher+tag ),供 Open API 二次获取明文时解密。 + * 配置为空时加解密直接报错(视为功能未开启,不回退到任何弱行为)。 + */ +@Component +public class TokenCipher { + + private static final int IV_LEN = 12; + private static final int TAG_BITS = 128; + + private final byte[] key; + private final SecureRandom random = new SecureRandom(); + + public TokenCipher(@Value("${app.token-cipher-key:}") String cipherKey) { + if (cipherKey == null || cipherKey.isBlank()) { + this.key = null; + } else { + try { + this.key = MessageDigest.getInstance("SHA-256") + .digest(cipherKey.trim().getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new IllegalStateException("SHA-256 不可用", e); + } + } + } + + public String encrypt(String plaintext) { + byte[] iv = new byte[IV_LEN]; + random.nextBytes(iv); + try { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(requireKey(), "AES"), + new GCMParameterSpec(TAG_BITS, iv)); + byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + byte[] packed = new byte[IV_LEN + encrypted.length]; + System.arraycopy(iv, 0, packed, 0, IV_LEN); + System.arraycopy(encrypted, 0, packed, IV_LEN, encrypted.length); + return Base64.getEncoder().encodeToString(packed); + } catch (Exception e) { + throw new IllegalStateException("ak 加密失败", e); + } + } + + public String decrypt(String packedBase64) { + try { + byte[] packed = Base64.getDecoder().decode(packedBase64); + byte[] iv = new byte[IV_LEN]; + System.arraycopy(packed, 0, iv, 0, IV_LEN); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(requireKey(), "AES"), + new GCMParameterSpec(TAG_BITS, iv)); + return new String(cipher.doFinal(packed, IV_LEN, packed.length - IV_LEN), StandardCharsets.UTF_8); + } catch (IllegalStateException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("ak 密文解密失败", e); + } + } + + private byte[] requireKey() { + if (key == null) throw new IllegalStateException("未配置 app.token-cipher-key,ak 加解密不可用"); + return key; + } +} diff --git a/xiaoqu-agent-service/src/main/resources/application-dev.yml b/xiaoqu-agent-service/src/main/resources/application-dev.yml index 70c0a2e..2d698a7 100644 --- a/xiaoqu-agent-service/src/main/resources/application-dev.yml +++ b/xiaoqu-agent-service/src/main/resources/application-dev.yml @@ -5,6 +5,9 @@ app: open: # Open API(/api/open/**)服务间静态密钥,dev 明文;调用方放 header X-Open-Key api-key: xqok_4b44012f12b9fa54d6ec11690e6da57563a75c891df60961 + # ak 明文 AES-GCM 加密密钥(任意长度字符串,SHA-256 派生为密钥),供 ensure 二次获取明文; + # dev 明文(同库密码惯例),生产必须改用环境变量且与 dev 不同值 + token-cipher-key: d70da46f5f0db2016d88d5ee4b1d6af35867b7700b97105fec9b3ad567d474a5 datasource: m0: # 权限库 xiaoqu_comples_d(user/agent/authority/propery/agent_manage_district_auth) driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/xiaoqu-agent-service/src/main/resources/db/migration/V3__token_cipher.sql b/xiaoqu-agent-service/src/main/resources/db/migration/V3__token_cipher.sql new file mode 100644 index 0000000..0c3c65f --- /dev/null +++ b/xiaoqu-agent-service/src/main/resources/db/migration/V3__token_cipher.sql @@ -0,0 +1,3 @@ +-- ak 二次获取(2026-09-07):签发时保存 AES-GCM 加密密文,ensure 已存在 Token 时解密回传明文 +ALTER TABLE agent_api_key + ADD COLUMN key_cipher VARCHAR(512) NULL COMMENT 'ak 明文 AES-GCM 加密密文(Base64: iv‖cipher‖tag),空=历史数据未存' AFTER key_hash; diff --git a/xiaoqu-agent-service/src/main/resources/db/migration/checksums.sha256 b/xiaoqu-agent-service/src/main/resources/db/migration/checksums.sha256 index e693a0a..1b754bd 100644 --- a/xiaoqu-agent-service/src/main/resources/db/migration/checksums.sha256 +++ b/xiaoqu-agent-service/src/main/resources/db/migration/checksums.sha256 @@ -1,2 +1,3 @@ f8ec43a4fda81382d5675bc59fe7f4339f2e38a5b4241c260c3569e33b43a26d V1__init.sql 0ad892601e891a18712b1b6a613c392c94e63492dc14f4f779375c092a3acab6 V2__p4_admin.sql +0b6e407106156911f5fe83a13b342eeec5986e2e1e5b37b6797431a8720e1332 V3__token_cipher.sql diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/admin/AdminTokenServiceTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/admin/AdminTokenServiceTest.java index 2352750..12e2562 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/admin/AdminTokenServiceTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/admin/AdminTokenServiceTest.java @@ -9,6 +9,7 @@ import me.iiv.agent.mapper.svc.AgentApiKeyMapper; import me.iiv.agent.mapper.svc.AgentScopeMapper; import me.iiv.agent.mapper.svc.TokenScopeMapper; import me.iiv.agent.security.AuthService; +import me.iiv.agent.security.TokenCipher; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -33,6 +34,7 @@ class AdminTokenServiceTest { @Mock AgentApiKeyMapper tokenMapper; @Mock TokenScopeMapper tokenScopeMapper; @Mock AgentScopeMapper scopeMapper; + @Mock TokenCipher tokenCipher; private User stubUser() { User user = new User(); @@ -43,11 +45,13 @@ class AdminTokenServiceTest { } private AdminTokenService service() { - return new AdminTokenService(userMapper, tokenMapper, tokenScopeMapper, scopeMapper); + org.mockito.Mockito.lenient().when(tokenCipher.encrypt(any())) + .thenAnswer(invocation -> "cipher:" + invocation.getArgument(0)); + return new AdminTokenService(userMapper, tokenMapper, tokenScopeMapper, scopeMapper, tokenCipher); } @Test - void issueReturnsPlaintextOnceAndStoresOnlyHash() { + void issueReturnsPlaintextAndStoresHashAndCipher() { stubUser(); AgentScope scope = new AgentScope(); scope.setId(3L); @@ -68,6 +72,7 @@ class AdminTokenServiceTest { assertTrue(issued.get(0).plaintext().matches("xqak_[0-9a-f]{48}")); assertEquals(AuthService.sha256Hex(issued.get(0).plaintext()), stored.getKeyHash()); assertNotEquals(issued.get(0).plaintext(), stored.getKeyHash()); + assertEquals("cipher:" + issued.get(0).plaintext(), stored.getKeyCipher()); assertEquals(11L, issued.get(0).token().id()); } diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/config/FlywayMigrationTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/config/FlywayMigrationTest.java index 9790ad6..e457950 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/config/FlywayMigrationTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/config/FlywayMigrationTest.java @@ -19,7 +19,7 @@ class FlywayMigrationTest { flyway.migrate(); - assertEquals("2", flyway.info().current().getVersion().getVersion()); + assertEquals("3", flyway.info().current().getVersion().getVersion()); assertEquals(255, passwordHashLength(dataSource)); } @@ -28,12 +28,13 @@ class FlywayMigrationTest { JdbcDataSource dataSource = dataSource("legacy"); try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) { statement.execute("CREATE TABLE admin_user (id BIGINT AUTO_INCREMENT PRIMARY KEY, password_hash CHAR(64) NOT NULL)"); + statement.execute("CREATE TABLE agent_api_key (id BIGINT AUTO_INCREMENT PRIMARY KEY, key_hash CHAR(64) NOT NULL)"); } Flyway flyway = flyway(dataSource, true); flyway.migrate(); - assertEquals("2", flyway.info().current().getVersion().getVersion()); + assertEquals("3", flyway.info().current().getVersion().getVersion()); assertEquals(255, passwordHashLength(dataSource)); } diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/open/OpenTokenServiceTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/open/OpenTokenServiceTest.java index a166b3e..de01b4f 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/open/OpenTokenServiceTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/open/OpenTokenServiceTest.java @@ -7,6 +7,7 @@ import me.iiv.agent.entity.svc.AgentApiKey; import me.iiv.agent.entity.svc.AgentScope; import me.iiv.agent.mapper.m0.UserMapper; import me.iiv.agent.mapper.svc.AgentScopeMapper; +import me.iiv.agent.security.TokenCipher; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -37,6 +38,7 @@ class OpenTokenServiceTest { @Mock AgentScopeMapper scopeMapper; @Mock AdminTokenService tokenService; @Mock AuditService auditService; + @Mock TokenCipher tokenCipher; OpenTokenService service; AgentScope scope; @@ -44,7 +46,7 @@ class OpenTokenServiceTest { @BeforeEach void setUp() { - service = new OpenTokenService(userMapper, scopeMapper, tokenService, auditService); + service = new OpenTokenService(userMapper, scopeMapper, tokenService, auditService, tokenCipher); scope = new AgentScope(); scope.setId(3L); scope.setCode("operation"); @@ -62,24 +64,62 @@ class OpenTokenServiceTest { } @Test - void existingActiveTokenReturnsMetadataWithoutPlaintext() { + void existingTokenWithCipherReturnsDecryptedPlaintext() { stubScopeAndUser(); AgentApiKey existing = new AgentApiKey(); existing.setId(11L); existing.setKeyPrefix("xqak_abcd1234"); + existing.setKeyCipher("packed-cipher"); when(tokenService.findActiveToken(7L, 3L)).thenReturn(existing); + when(tokenCipher.decrypt("packed-cipher")).thenReturn("xqak_abcd1234ffff"); OpenTokenController.EnsureResult result = service.ensure( new OpenTokenController.EnsureRequest("13800001111", null, "operation")); assertFalse(result.created()); - assertNull(result.token()); + assertEquals("xqak_abcd1234ffff", result.token()); assertEquals("xqak_abcd1234", result.keyPrefix()); assertEquals(7L, result.userId()); org.mockito.Mockito.verify(tokenService, never()) .issueSingle(anyLong(), anyString(), anyLong(), isNull(), anyLong()); } + @Test + void existingTokenWithoutCipherRotatesAndReturnsNewPlaintext() { + stubScopeAndUser(); + AgentApiKey existing = new AgentApiKey(); + existing.setId(11L); + existing.setKeyPrefix("xqak_abcd1234"); + when(tokenService.findActiveToken(7L, 3L)).thenReturn(existing); + AdminTokenService.TokenView view = new AdminTokenService.TokenView( + 12L, "xqak_efgh5678", "open-api 自动签发(operation)", (byte) 1, + null, null, List.of(3L), List.of("运营")); + when(tokenService.issueSingle(eq(7L), anyString(), eq(3L), isNull(), eq(0L))) + .thenReturn(new AdminTokenService.IssuedToken("xqak_" + "b".repeat(48), view)); + + OpenTokenController.EnsureResult result = service.ensure( + new OpenTokenController.EnsureRequest("13800001111", null, "operation")); + + org.mockito.Mockito.verify(tokenService).status(11L, (byte) 0, 0L); + assertTrue(result.created()); + assertTrue(result.token().matches("xqak_[0-9a-f]{48}")); + } + + @Test + void decryptFailureReturns500() { + stubScopeAndUser(); + AgentApiKey existing = new AgentApiKey(); + existing.setId(11L); + existing.setKeyPrefix("xqak_abcd1234"); + existing.setKeyCipher("bad-cipher"); + when(tokenService.findActiveToken(7L, 3L)).thenReturn(existing); + when(tokenCipher.decrypt("bad-cipher")).thenThrow(new IllegalStateException("ak 密文解密失败")); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.ensure( + new OpenTokenController.EnsureRequest("13800001111", null, "operation"))); + assertEquals(500, error.getStatusCode().value()); + } + @Test void missingTokenCreatesPermanentOneAndReturnsPlaintext() { stubScopeAndUser(); diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/security/TokenCipherTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/security/TokenCipherTest.java new file mode 100644 index 0000000..c71dde9 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/security/TokenCipherTest.java @@ -0,0 +1,50 @@ +package me.iiv.agent.security; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class TokenCipherTest { + + private final TokenCipher cipher = new TokenCipher("test-cipher-key"); + + @Test + void encryptDecryptRoundTrip() { + String plaintext = "xqak_25939fab31f812eb4e566197f53f16482d3e1f588b8bcaca"; + String packed = cipher.encrypt(plaintext); + + assertNotEquals(plaintext, packed); + assertEquals(plaintext, cipher.decrypt(packed)); + } + + @Test + void samePlaintextEncryptsDifferentlyEachTime() { + String plaintext = "xqak_" + "a".repeat(48); + assertNotEquals(cipher.encrypt(plaintext), cipher.encrypt(plaintext)); + } + + @Test + void wrongKeyCannotDecrypt() { + String packed = cipher.encrypt("xqak_secret"); + TokenCipher other = new TokenCipher("another-key"); + assertThrows(IllegalStateException.class, () -> other.decrypt(packed)); + } + + @Test + void tamperedCipherRejected() { + String packed = cipher.encrypt("xqak_secret"); + byte[] raw = java.util.Base64.getDecoder().decode(packed); + raw[raw.length - 1] ^= 0x01; + String tampered = java.util.Base64.getEncoder().encodeToString(raw); + assertThrows(IllegalStateException.class, () -> cipher.decrypt(tampered)); + } + + @Test + void blankKeyDisablesCipher() { + TokenCipher disabled = new TokenCipher(" "); + assertThrows(IllegalStateException.class, () -> disabled.encrypt("xqak_x")); + assertThrows(IllegalStateException.class, () -> disabled.decrypt("AAAA")); + } +}