feat: 模块化内核(编译期依赖图定序)+ 密钥防火墙 + 插件三态生命周期 (#10)

* feat(kernel): 模块化内核 + 编译期依赖图定序

把单体 init 序列拆成「模块自声明依赖 + 编译期生成依赖图 + 运行期只读图」。

## 为什么

原 cmd/homed/main.go 里 17 步 init 是**手写顺序**,依赖关系只存在于
注释和人脑里。实测踩到的坑(见 docs/zh/kernel-modularization-design.md):
 · nlp.defaultParser 被 initONNXParser 写、被 distiller 读,两者相距 60 行,
   只靠 ticker 10min 才没炸(隐式依赖,无声明)
 · 加一个模块要人肉找"该插在哪",插错的表现是运行期 nil 解引用
 · 关停是 defer ks.CloseAll(),而热更新路径 os.Exit(42) **跳过 defer**
   ⇒ docStore/distiller/textMem 的 flush 全部丢失(每次热更新都发生)

## 怎么改

- internal/kernelboot/:KernelState(一个结构体收全部包级共享变量,字段
  nil 安全)、Module 接口、graphfile(依赖图编解码 + 与二进制 hash 对账)、
  scheduler(按 BFS 层并行 Init)、syncbus(层边界写通知)、
  lifecycle(Quiesce/Resume 可逆伪关机,区别于一次性 CloseAll)
- internal/kernelmod/<name>/:每个模块一个文件夹,Requires/Provides 自声明;
  all/all.go 是唯一的 import 清单(Go 循环导入约束)
- cmd/homed-depgraph:编译期生成依赖图,Makefile 注入 digest
- 运行期只**读**图,不计算顺序(顺序是编译期决定的,源码里没有中央清单)

## 判据

kernelboot 8 条 + 图失败模式 7 条(全部拒绝并给准确错误)+ 模块目录数
与注册表一致性。变异测试 15/15 咬住,含:
 · 删掉 AddCloser 的锁 → -race 报数据竞争
 · Scheduler 同批跑去重 → 203 vs 200
 · 图 hash 不匹配 → 拒绝启动

## 顺带修的缺陷

热更新路径漏调模块关闭 ⇒ 显式 stopKernelModules(),并用 AST 判据钉住
"必须在 os.Exit 之前"(字符串检查在本仓已骗过我三次)。

实测:冷启动 1630ms(5 层/17 模块,原 17 步串行);生产 22 插件全部建链。

* feat(security): 密钥防火墙 + 配置加密 + close 纪律

## 密钥防火墙

模型只见别名、不见明文;插件工具调用入口(StageHost.ExecuteTool)做入向
回填。三维能力:
 · 出向:工具输出里的密钥被替换成 [[secret:name]] 别名后才进上下文
 · 入向:模型传别名,入口处填回真值(插件完全无感知)
 · 存储:config.db 里不存明文(0644 的库可读),敏感值独立 0600 文件

模式库「宁漏勿误伤」:宁可漏掉一次替换,也不把正常文本误当密钥。

## 配置加密

- internal/security/{codec,key,keyring,store,global}.go:AES-256-GCM,
  enc:v1: 前缀,密钥轮换(OldKeys 解码 + 新钥加密)
- harden.go:数据目录权限收紧
- embedded.go:支持内嵌主密钥(可选,仍保留外部 key 文件路径)
- EncryptExistingSecrets / AuditSecrets:启动时幂等迁移 + 审计

## close 纪律(用户明确要求:只要调 close 就要有前置检查)

internal/safeclose:Safe / SafeFunc。全仓 37 处裸 close(chan) → 0。
关键教训(都写进注释和判据):
 · `select { case <-ch: default: close(ch) }` **不是**并发安全的
   (两个 goroutine 可同时命中 default)—— healthcheck 原先是这种写法
 · `sync.Once` **不可重入**:X.Do(func(){ Safe(&X, ...) }) 会自锁,
   症状是 go test 直接挂住(3 个包 600s 超时),栈里只有嵌套的 Once.Do
 · map 里按请求持有的 channel(mcp/stdio.go、clawhubadapter/sidecar.go)
   改成 *pendingRPC 包装,每通道一个 once

## 判据

security 全量 + TestNoBareCloseOutsideSafeclose(AST 级扫描,含注释剥离)
+ TestNoNestedSelfOnceDeadlock(正则修正:\w+ 不匹配点号,捕获组要含 '.')。
变异 2/2 咬住。全量 52 包 ok。

* feat(plugin+deploy): 插件三态生命周期、数据目录迁移、发行包修复

## 插件三态生命周期(用户要求:自动启动 / 启用但不自动启动 / 禁用)

- Manifest 新增 requires_facilities / auto_start(*bool,用于区分
  "未声明"与"显式 false")
- plugin-host 模块(internal/kernelmod/pluginhost):用 kernelboot.Scheduler
  按依赖时机拉起,**不再手写两阶段**
- 运行态停启 StopPlugin/StartPlugin **可逆**:不写 disabled_plugins
  (不改用户决定)、不置 shuttingDown(之后还能加载)、会摘掉注册面
- 与 StopAll 的区别写进注释 —— StopAll 置 shuttingDown 后**永久无法加载**,
  那是进程退出用的,误用于"暂停"就是关了就死

## 数据目录迁移(伪关机 → 迁移 → 唤醒)

- kernelboot/lifecycle.go:Quiesce/Resume(可逆,区别于一次性 CloseAll),
  逆序暂停、逆序恢复、Pause 失败回滚
- kernelboot/migrate.go:BeginMigration/End 编排(先停插件 → 再停内核写者
  → 等在途归零)
- distiller/textmem 增 Pause/Resume 门(不是 cancel —— cancel 不可逆,
  恢复要重建整个对象)
- 模块 migration:体检 + 生成可执行迁移脚本

★ 迁移脚本的三个实测教训(都写成了判据):
 1. 无条件改 /etc/systemd/system ⇒ 演练把**生产** -data 改成了演练目录。
    加 --dry-run(零改动)+ 强制校验 systemd 当前 -data == OLD
 2. 本机没装 rsync,脚本在"服务已停、数据未搬"处硬失败 ⇒ cp -a 回退,
    并明确警告不保硬链接/ACL(生产 plugindev 有大量 git 硬链接)

## 发行包修复(全部经实测确认)

 1. package-linux.sh **完全漏打 depgraph.bin** ⇒ 内核 exit 1
    ("生产构建(内嵌 hash 非空)但读不到依赖图")
 2. build_homed 不走 Makefile ⇒ 单独修 Makefile 无效,必须同时生成图+注入
 3. build.sh 的 LDFLAGS 用**历史模块路径 gitcode.com** ⇒ -X 静默 no-op
    ⇒ --version 显示 unknown
 4. Makefile -ldflags 用**单引号** ⇒ 注入了字面 $GRAPH_HASH

## ★ build.sh 的版本号必须校验(真实事故)

原 `VERSION="$(git describe --tags --dirty)"`。无 tag 分支上它返回**分支名**
`backup/pre-identity-rewrite-20261005-51-g03d87a4e-dirty`,被注入 meta.Version
⇒ 插件握手判据 MajorVersion() 把整串当主版本 ⇒ **22 个子进程插件全部
握手失败**("主版本不匹配")。内核活着、systemd active、日志每条都像
插件的问题 —— 实际是发行包带着非法版本号。

修法:git describe 只在形如 vX.Y.Z 时采用,否则回退 meta.Version(权威源),
并对最终值做形态守卫。deploy/packaging/version_guard_test.go 3 条判据,
变异 2/2 咬住。

## deb 的 systemd unit 改为 guard 模式

ExecStart=/usr/bin/homed --role=guard --data /var/lib/homeagent
(worker exit 42 → guard 重拉,不经过 systemd、不计失败)
含 RestartSteps/RestartMaxDelaySec/TimeoutStopSec + 硬化
(NoNewPrivileges/ProtectSystem/ProtectHome/DevicePolicy/ReadWritePaths)。

## 其他

- internal/plugin/proc:非法 JSON 帧的报错**可定位**(内容摘要 + 可能原因 +
  修法)。生产实测 1038 条非法帧(插件往 stdout 打日志 = 污染协议通道)
- 示例插件 4 个 14 处 fmt.Printf → log.Printf
- hmapdev 模板同步修复;scaffold 判据剥离注释(模板注释里就含 fmt.Printf)

* chore: 收尾(主密钥轮换命令、IPC/知识库加固、SDK 对齐)

- cmd/homed-key-rotate:把 security 里的 RotateKey/RotateKeyFile/RekeySecrets
  串成一条**可执行**的运维路径。没有它,E-1 就只是库里的能力而不是
  "出事时能做的事",而轮换的价值恰恰在于能执行。
- cmd/homed:kmod_runtime(模块装配入口)、plugins_scheduled(failback
  白名单)、memcandidate、bootstrap_order_test / shutdown_flush_test
- internal/ipc、internal/devicebridge、internal/knowledge:配套加固
- third_party/homeagent-sdk/sdk:PluginMgrAPI 增三态能力(mgrStop/mgrStart/
  mgrIsRuntimeStopped)+ settings 对齐

判据:bootstrap_order_test(启动顺序不依赖手写序列)、
shutdown_flush_test(停机的两条路径必须对称 —— exit 42 会跳过 defer,
热更新路径下模块关闭/flush 曾全部丢失)。

* test(security): 测试夹具改用不可误认的占位值(修 push protection 拦截)

GitHub secret scanning 拦住了推送:三处测试夹具用了"看起来像真密钥"的
字面量:

  internal/security/prefilter_test.go:39
  internal/security/store_test.go:177
  internal/security/survey_test.go:20

它们全是**假密钥**(`AKIAIOSFODNN7EXAMPLE` 是 AWS 官方文档的标准示例,
其余是 `abcdefghij...` 这类可读占位符),但扫描器无法区分 —— 它只看形态。

★ 不绕过(不用 unblock URL、不加 allowlist):直接改夹具。
原因:一旦允许"假密钥可以通过",真密钥混进来时就没有最后一道门了。

改法:保留**模式库匹配所需的结构**,把可识别内容换成 FAKE:
  glpat-abcdefghijklmnopqrst       → glpat-FAKE-TEST-TOKEN-NOT-REAL
  sk-abcdefghij...012345           → sk-FAKE-TEST-KEY-NOT-REAL-0000000
  AKIAIOSFODNN7EXAMPLE             → AKIA0000FAKE0000TEST   (保持 20 字符)
  ASIAIOSFODNN7EXAMPLE             → ASIA0000FAKE0000TEST
  xoxb-123456789012-abcdefghijklmnop → xoxb-FAKE-TEST-TOKEN-NOT-REAL

★ 中途踩到一次:AWS 键的模式是 `\b(AKIA|ASIA)[0-9A-Z]{16}\b`(共 20 字符),
我第一次替换成 19 字符的 `AKIAFAKETESTKEY0000` ⇒ 模式匹配不上 ⇒
TestSurvey_WhatIsCaught / TestRecognizesCommonShapes /
TestPrefilterNeverBlocksRealMatches 三条当场变红。
判据正确地拒绝了"为了过扫描器而削弱覆盖"。

顺带核实:全仓 grep 无任何真实密钥泄漏。

* fix(packaging): package-linux.sh 的版本解析与 build.sh 统一

上一版只修了 build.sh,漏了 package-linux.sh —— 它仍只看 git describe:

  VERSION="${VERSION:-$(git describe --tags --dirty)}"

无 tag 分支上 git describe 返回**分支名**
(backup/pre-identity-rewrite-20261005-56-g98850e1),守卫会正确拒绝,
但要求人**手动传 VERSION** 才能打包。而 build.sh 早已能自动回退 meta.Version
⇒ 同一个版本,走 build.sh 打得出来、走 package-linux.sh 打不出来。

危害不只是"要手动传参":运维为了跑通会去掉守卫,而分支名一旦进
meta.Version 就是**插件协议主版本**的来源 ⇒ 22 个子进程插件全部握手失败
(这是真实发生过的事故,见 3efa4e5)。

修法:两个脚本用同一套规则(git describe 仅在 vX.Y.Z 形态时采用,
否则回退 meta.Version),并对最终值做形态守卫。

判据:TestBothPackagingScriptsShareVersionRules —— 逐个读两个脚本,
要求都能从 meta/meta.go 取权威版本、都校验 git describe 形态。
变异(把 package-linux.sh 改回旧规则)咬住并准确指出是哪个脚本。

实测:不传 VERSION 跑 package-linux.sh,输出
  [PKG] warn: git describe 给出非版本串(...),改用 meta.Version=1.4.0
  Version: 1.4.0

* fix(test): 跨仓判据在 CI 上应 skip 而非 fail(修 PR 双红)

CI 上 "Go build / vet / test" 与 "Build linux/amd64" 双红:

  --- FAIL: TestHmapdevTemplateImplementsAllPluginMgrMethods
      bridge_sync_test.go:170: 读模板: open
      ../../../third_party/homeagent-sdk/tools/hmapdev/templates/proc_main.go.tmpl:
      no such file or directory

## 根因:判据依赖了一个**被 gitignore 的路径**

`.gitignore:33` 有 `third_party/homeagent-sdk/tools/`(SDK 仓自治,本仓不跟踪)。
于是:
  · 本机(完整 SDK 检出)有该文件 ⇒ 判据通过
  · CI(只 checkout 本仓)没有该文件 ⇒ Fatalf ⇒ **必红**

我提交时只在本机跑测试、没看 CI,所以这个问题拖到 PR 才暴露。

## 修法

文件缺失时 `t.Skipf` 而不是 `t.Fatalf`。

★ 这不是"放弃检查":
  · 跨仓一致性由 **SDK 仓自己的判据**守着
    (tools/hmapdev/scaffold_stdout_test.go 等),那里才是模板的真实归属地
  · 本判据在本机开发时**仍然生效**(已验证:有文件时 PASS,缺文件时 SKIP)

## 验证

 · 本机(有文件)  → PASS(判据真跑)
 · 模拟 CI(移走 tools/)→ SKIP(不再 fail)
 · 全量 53 包 ok / 0 fail

## 顺带

同目录还有 e2e_template_test.go 也读 tools/,但它是 `//go:build` 门控的
e2e 判据,CI 默认不跑,无需处理。

---------

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
This commit is contained in:
JianFeeeee
2026-10-17 19:18:24 +08:00
committed by GitHub
parent 03d87a4e21
commit a68d6a428c
266 changed files with 30185 additions and 1424 deletions

667
internal/security/store.go Normal file
View File

@ -0,0 +1,667 @@
// Package security 是内核的**敏感信息防火模块**(Secret Firewall)。
//
// 设计文档:docs/zh/kernel-secret-firewall-design.md
//
// ## 它做什么
//
// ① 拦截 LLM 出向前扫到明文 → 换成 @{别名}
// ② 加密落盘 明文 AES-GCM 持久化(不落明文)
// ③ 别名 ★ 哈希键:sec_ + HMAC-SHA256(masterKey, plain)[:8]
// ④ 回填 插件调用(toolcall / outputch)前,把 @{别名} 换回明文
//
// ## 三条不可让步的约束(文档 §3)
//
// S1 不提供"取回明文"的任何接口 —— 只能**替代引用**。
// 一旦有取回路径,LLM 就能把明文写回上下文,整层作废。
// S3 别名必须 HMAC 派生,**不能裸哈希**:别名会进上下文/日志/记忆,
// 裸哈希对弱口令可被字典枚举(实测 hunter2 当场命中)。
// 也**不加 salt** —— 加了就失去合并能力(确定性是特性)。
// S10 变量系统**全局唯一**:主 / 子 / 驻留子 agent 同一套。
//
// ## 为什么是"全局单例"
//
// 已核:驻留子共享父的 ProviderManager(resident.go:185),而它全进程只建一份
// (bootstrap.go:249)⇒ 安全模块挂在 provider 层就天然是进程级单例,
// **不需要任何跨 agent 同步**。
package security
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
// AliasPrefix 是所有变量别名的前缀。
const AliasPrefix = "sec_"
// aliasHexLen 是别名里哈希的十六进制长度(8 字节)。
//
// 为何选 8 字节:碰撞概率对本场景可忽略,且别名会进上下文,短才省预算。
// 实测别名形如 `sec_3718c87a11e9478b`(20 字符)。
const aliasHexLen = 16
// refPattern 匹配文本里的变量引用 `@{sec_xxx}`。
//
// 为什么用 @{...} 而不是 ${...}:后者与 shell 变量语法冲突,
// agent 极易误用;@{...} 不与任何常见语法撞车。
var refPattern = regexp.MustCompile(`@\{([A-Za-z0-9_]{1,64})\}`)
// Sentinel errors(调用方靠它们区分处置,不靠字符串匹配)。
var (
// ErrNoMasterKey 表示没有可用密钥 ⇒ **拒绝产出别名**。
//
// 为何要拒绝而不是退化:没有密钥就只能用裸哈希,而裸哈希会泄露
// secret 内容(可字典枚举)。**宁可漏报,不可降级**。
ErrNoMasterKey = errors.New("security: 无可用主密钥")
// ErrUnknownAlias 表示引用了未登记的别名。
ErrUnknownAlias = errors.New("security: 未知变量别名")
)
// Entry 是一条变量记录。
type Entry struct {
Alias string `json:"alias"`
Sealed string `json:"sealed"` // AES-256-GCM(base64, nonce 前置)
Source string `json:"source"` // intercepted | submitted
Note string `json:"note,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at,omitempty"`
// Hits 是命中次数(可观测:同一变量被用了多少次)。
Hits int `json:"hits,omitempty"`
}
// Store 是变量库(进程级单例)。
type Store struct {
mu sync.RWMutex
path string
data map[string]*Entry
// keyFn 返回主导主密钥(**当前**用来加密的那把)。为 nil 或报错时
// **不做任何别名产出**。
keyFn func() ([]byte, error)
// keys 是密钥环(E-1 密钥轮换)。
//
// keys[0] 是当前主密钥(与 keyFn 同源);其余是**只用于解密**的历史密钥。
// 轮换时:ciphertext 用新钥重加密(此时新钥先在头部),
// 旧钥保留在环里,好在过渡期能解开“尚未轮到”的旧主密钥密文。
//
// ★ 为何必须有环:只换 keyFn 会让所有旧密文立刻解不开
// (响亮失败但服务不可用);而保持旧别名又要求旧密钥**不能立刻丢弃**
// —— 因为新密文是按新钥加密的,两者必须同时可读才能平滑迁移。
keys []keyGen
// enabled 支持显式关闭(本仓「显式才是特权」)。
enabled bool
// onIntercept 是拦截回调(可观测:每次拦截都通知出去)。
onIntercept func(Evt)
}
// keyGen 是一代主密钥(含代号,便于审计“这条密文是哪一代加的”)。
type keyGen struct {
Key []byte
// ID 是代号(短哈希),仅用于日志/审计,**不参与加解密**。
ID string
}
// Evt 是一次拦截事件(不含明文)。
type Evt struct {
Alias string `json:"alias"`
Kind string `json:"kind"` // 模式名
Where string `json:"where"` // input | tool_result:<tool>
Len int `json:"len"`
Merged bool `json:"merged"` // true = 复用已有别名(同变量合并)
At string `json:"at"`
}
// Options 构造参数。
type Options struct {
// Path 是变量库落盘路径(注意:**落盘**,不是内存)。
Path string
// KeyFn 返回主密钥(32 字节 AES-256)。
KeyFn func() ([]byte, error)
// OldKeys 是历史主密钥(解密用,E-1 密钥轮换)。
//
// 轮换后旧密文仍需可读 ⇒ 旧钥必须在环里;未轮换时为空。
// 顺序无关(尝试顺序 = 当前钥后依次)。
OldKeys [][]byte
// Enabled 是否启用;默认 true。
Enabled bool
// OnIntercept 拦截回调(可 nil)。
OnIntercept func(Evt)
}
// New 构造变量库。
func New(opt Options) *Store {
s := &Store{
path: opt.Path,
data: map[string]*Entry{},
keyFn: opt.KeyFn,
enabled: opt.Enabled,
onIntercept: opt.OnIntercept,
}
// 建初始密钥环:当前钥在头,历史钥在后。
if opt.KeyFn != nil {
if k, err := opt.KeyFn(); err == nil && len(k) == 32 {
s.keys = append(s.keys, keyGen{Key: k, ID: keyID(k)})
}
}
for _, k := range opt.OldKeys {
if len(k) == 32 {
s.keys = append(s.keys, keyGen{Key: k, ID: keyID(k)})
}
}
return s
}
// keyID 给一把密钥一个**不可逆的短代号**(仅用于日志/审计)。
//
// 不直接打印密钥,也不打印可被拿去比对完整密钥的东西。
func keyID(k []byte) string {
sum := sha256.Sum256(k)
return hex.EncodeToString(sum[:4])
}
// Load 读入已落盘的变量库。
//
// 权限不正确时**响亮失败**(响亮失败,不静默降级):
// 该文件含加密后的凭据,属高价值资产。
func (s *Store) Load() error {
s.mu.Lock()
defer s.mu.Unlock()
b, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
return nil // 首次启动
}
return err
}
if fi, err := os.Stat(s.path); err == nil {
if perm := fi.Mode().Perm(); perm != 0o600 {
return fmt.Errorf(
"security: 变量库权限不安全:%s 是 %04o,必须是 0600(它含加密凭据)",
s.path, perm)
}
}
var entries []*Entry
if err := json.Unmarshal(b, &entries); err != nil {
return fmt.Errorf("security: 变量库损坏(拒绝静默重建): %w", err)
}
for _, e := range entries {
if e != nil && e.Alias != "" {
s.data[e.Alias] = e
}
}
return nil
}
// saveLocked 落盘并确权(调用方持锁)。
func (s *Store) saveLocked() error {
list := make([]*Entry, 0, len(s.data))
for _, e := range s.data {
list = append(list, e)
}
b, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
if err := os.Chmod(tmp, 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// ---------------- 别名(哈希键) ----------------
// Alias 由明文派生**确定性**别名。
//
// ★ 必须是 HMAC:别名会进上下文/日志/记忆。裸 SHA256 对弱口令可被字典枚举
// (实测:明文 hunter2 → 裸哈希前 8 字节作别名,5 个常见弱口令里当场命中)。
//
// ★ 必须不加 salt:加了同一明文会得到不同别名 ⇒ 失去"同变量合并"。
// 这里"确定性"是**特性**,安全靠 HMAC 的密钥而不是 salt。
func (s *Store) Alias(plain string) (string, error) {
key, err := s.key()
if err != nil {
return "", err
}
// ★ 轮换后同一明文必须继续得到**已有别名**:
// 别名若随新主密钥改变,记忆/上下文里的旧 @{sec_...} 会失效,
// 且同一个凭据会出现两份变量。先在现有条目中按密钥环解密比对;
// 找到就复用旧别名,找不到才用当前钥派生新别名。
cands := s.keysForDecrypt()
if len(cands) == 0 {
cands = [][]byte{key}
}
s.mu.RLock()
for alias, e := range s.data {
if got, openErr := openWithRing(cands, e.Sealed); openErr == nil && got == plain {
s.mu.RUnlock()
return alias, nil
}
}
s.mu.RUnlock()
m := hmac.New(sha256.New, key)
m.Write([]byte(plain))
return AliasPrefix + hex.EncodeToString(m.Sum(nil)[:aliasHexLen/2]), nil
}
func (s *Store) key() ([]byte, error) {
if len(s.keys) > 0 {
return s.keys[0].Key, nil
}
if s.keyFn == nil {
return nil, ErrNoMasterKey
}
k, err := s.keyFn()
if err != nil {
return nil, err
}
if len(k) != 32 {
return nil, fmt.Errorf("security: 主密钥必须是 32 字节(AES-256),实际 %d", len(k))
}
return k, nil
}
// keysForDecrypt 返回**按尝试顺序**排列的解密候选密钥。
//
// 当前钥优先(Hits 高的常态路径一击即中),历史钥依次尝试。
func (s *Store) keysForDecrypt() [][]byte {
if len(s.keys) == 0 {
return nil
}
out := make([][]byte, 0, len(s.keys))
for _, g := range s.keys {
out = append(out, g.Key)
}
return out
}
// openWithRing 用密钥环解开一条密文:依次尝试,返回第一个成功的明文。
//
// ★ 为何不是“记录每条密文属于哪一代”:那需要**改 Entry 结构**(加世代字段),
// 而密文本身已经能被正确的那把钥唯一解开(AES-GCM 带认证)。
// 逐个尝试的成本是常数级(代数量 <= 2 是常态),换来的是**零 schema 迁移**。
func openWithRing(cands [][]byte, sealed string) (string, error) {
if len(cands) == 0 {
return "", ErrNoMasterKey
}
var lastErr error
for _, k := range cands {
plain, err := open(k, sealed)
if err == nil {
return plain, nil
}
lastErr = err
}
return "", lastErr
}
// RotateKey 平滑轮换变量库主密钥,并保持**已有别名不变**。
//
// 事务顺序:先用旧密钥解开全部条目 → 用新密钥重加密到内存副本 →
// 一次性落盘 → 最后切换当前密钥。任一步失败都不切换当前密钥,
// 避免出现“内存已换、磁盘没换”的半轮换状态。
//
// oldKey 会留在进程内的历史环里,直到下一次进程重启;调用方必须把旧钥
// 作为 OldKeys 在重启时注入(后续 keyring 接入会自动化这个步骤)。
func (s *Store) RotateKey(newKey []byte) error {
if len(newKey) != 32 {
return fmt.Errorf("security: 新主密钥必须是 32 字节,实际 %d", len(newKey))
}
s.mu.Lock()
defer s.mu.Unlock()
oldCandidates := s.keysForDecrypt()
if len(oldCandidates) == 0 {
if old, err := s.key(); err == nil {
oldCandidates = [][]byte{old}
}
}
if len(oldCandidates) == 0 {
return ErrNoMasterKey
}
copyData := make(map[string]*Entry, len(s.data))
for alias, e := range s.data {
plain, err := openWithRing(oldCandidates, e.Sealed)
if err != nil {
return fmt.Errorf("security: 轮换时解密变量 %s 失败,未切换密钥: %w", alias, err)
}
sealed, err := seal(newKey, plain)
if err != nil {
return fmt.Errorf("security: 轮换时加密变量 %s 失败,未切换密钥: %w", alias, err)
}
cp := *e
cp.Sealed = sealed
cp.UpdatedAt = nowISO()
copyData[alias] = &cp
}
oldCurrent := oldCandidates[0]
s.data = copyData
s.keys = append([]keyGen{{Key: newKey, ID: keyID(newKey)}, {Key: oldCurrent, ID: keyID(oldCurrent)}}, s.keys[1:]...)
// keyFn 也切到新钥(未来没有用 New 构造的特殊调用路径仍一致)。
s.keyFn = func() ([]byte, error) { return newKey, nil }
if err := s.saveLocked(); err != nil {
return fmt.Errorf("security: 轮换落盘失败,当前内存已保留双钥环: %w", err)
}
return nil
}
// ---------------- 加密 ----------------
func seal(key []byte, plain string) (string, error) {
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
return encodeB64(gcm.Seal(nonce, nonce, []byte(plain), nil)), nil
}
func open(key []byte, enc string) (string, error) {
raw, err := decodeB64(enc)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(raw) < gcm.NonceSize() {
return "", fmt.Errorf("security: 密文太短")
}
plain, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
if err != nil {
return "", fmt.Errorf("security: 解密失败(密钥不对或数据损坏): %w", err)
}
return string(plain), nil
}
// ---------------- 核心:拦截(入向明文 → 别名) ----------------
// Replace 把文本里的**明文**换成别名(LLM 出向用)。
//
// 返回替换后的文本与拦截事件。
// ★ 同明文自动合并:因为别名是哈希键,同一明文天然得到同一别名。
func (s *Store) Replace(text, where string, detect []Pattern) (string, []Evt) {
if !s.enabled || strings.TrimSpace(text) == "" {
return text, nil
}
// ★ 拿不到密钥就不产出别名(宁可漏报,不用裸哈希)
if _, err := s.key(); err != nil {
return text, nil
}
var evts []Evt
out := text
for _, p := range detect {
// ★ 前置过滤:不含该模式必含的字面量就直接跳过正则。
//
// 性能理由(实测):本文件所有模式都以 `\b` 开头,而 Go 的 RE2
// 对 `\b` 要逐 rune 做 Unicode 词边界判定 —— 50KB 文本上单条
// 660µs,9 条合计 ~6ms。而绝大多数文本根本不含凭据前缀,
// 用 strings.Contains 一挡,常态路径从毫秒级降到微秒级。
//
// ★ 正确性:prefilter 只用来**排除**(不含就一定不匹配),
// 绝不用来**认定** —— 认定仍交给正则。所以它不会造成漏报。
if !p.matchesPrefilter(out) {
continue
}
out = p.RE.ReplaceAllStringFunc(out, func(hit string) string {
secret, prefix := splitKeyValue(hit)
alias, err := s.Alias(secret)
if err != nil {
return hit // 派生失败:原样保留(不产出坏别名)
}
merged, err := s.Put(alias, secret, "intercepted", "自动拦截 @"+where)
if err != nil {
return hit
}
evts = append(evts, Evt{
Alias: alias, Kind: p.Name, Where: where,
Len: len(secret), Merged: merged, At: nowISO(),
})
return prefix + fmt.Sprintf(
"【信息被安全模块自动拦截,请使用变量 @{%s} 进行调用】", alias)
})
}
return out, evts
}
// Put 登记一条变量(幂等:同别名只更新,不重复插)。
//
// 返回 merged=true 表示这是**合并到已有别名**(同变量合并能力)。
func (s *Store) Put(alias, plain, source, note string) (merged bool, err error) {
key, err := s.key()
if err != nil {
return false, err
}
sealed, err := seal(key, plain)
if err != nil {
return false, err
}
s.mu.Lock()
defer s.mu.Unlock()
prev, exists := s.data[alias]
if exists {
prev.Hits++
prev.UpdatedAt = nowISO()
if note != "" {
prev.Note = note
}
_ = s.saveLocked()
return true, nil
}
s.data[alias] = &Entry{
Alias: alias, Sealed: sealed, Source: source,
Note: note, CreatedAt: nowISO(), Hits: 1,
}
return false, s.saveLocked()
}
// Submit 由 agent **主动提交**一条明文,返回其别名。
//
// 用途:agent 知道后面要用某个凭据,可先登记(此时它可能还没在文本里出现过)。
func (s *Store) Submit(plain, note string) (string, error) {
alias, err := s.Alias(plain)
if err != nil {
return "", err
}
if _, err := s.Put(alias, plain, "submitted", note); err != nil {
return "", err
}
return alias, nil
}
// Forget 由 agent **主动删除**一条变量(用户要求:不自动淘汰,但可主动删)。
func (s *Store) Forget(alias string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.data[alias]; !ok {
return ErrUnknownAlias
}
delete(s.data, alias)
return s.saveLocked()
}
// ---------------- 核心:回填(别名 → 明文,插件入向用) ----------------
// Fill 把文本里的 `@{alias}` 换回明文(路由到插件前用)。
//
// ★ 这是"插件能用密钥"的唯一通道,也是"插件无感知"的实现:
// 插件只看到已经填好的明文,不知道有替换这回事。
//
// 返回 (替换后文本, 替换了几处, 用到的别名)。
// 未知别名**原样保留**:不报错也不吞掉,以免把正常文本(恰好像引用)改坏。
func (s *Store) Fill(text string) (string, int, []string) {
if strings.TrimSpace(text) == "" {
return text, 0, nil
}
cands := s.keysForDecrypt()
if len(cands) == 0 {
// 环为空时退回 keyFn(未用 New 构造的路径,如测试里 SetGlobal)
if k, err := s.key(); err == nil {
cands = [][]byte{k}
}
}
if len(cands) == 0 {
return text, 0, nil
}
var used []string
n := 0
out := refPattern.ReplaceAllStringFunc(text, func(m string) string {
sub := refPattern.FindStringSubmatch(m)
if len(sub) != 2 {
return m
}
alias := sub[1]
s.mu.RLock()
e, ok := s.data[alias]
s.mu.RUnlock()
if !ok {
return m // 未知别名:原样保留
}
plain, err := openWithRing(cands, e.Sealed)
if err != nil {
return m
}
n++
used = append(used, alias)
return plain
})
return out, n, used
}
// FillMap 对 map 的字符串值做回填(工具参数是 map)。
//
// 递归处理嵌套 map / slice —— 工具参数常常是嵌套结构。
func (s *Store) FillMap(args map[string]interface{}) (int, []string) {
var total int
var all []string
var walk func(v interface{}) interface{}
walk = func(v interface{}) interface{} {
switch t := v.(type) {
case string:
out, n, used := s.Fill(t)
total += n
all = append(all, used...)
return out
case map[string]interface{}:
for k := range t {
t[k] = walk(t[k])
}
return t
case []interface{}:
for i := range t {
t[i] = walk(t[i])
}
return t
default:
return v
}
}
for k := range args {
args[k] = walk(args[k])
}
return total, all
}
// ---------------- 查询(★ 永不返回明文) ----------------
// Info 是变量的元信息(不含明文)。
type Info struct {
Alias string `json:"alias"`
Source string `json:"source"`
Note string `json:"note,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at,omitempty"`
Hits int `json:"hits,omitempty"`
}
// List 列出全部变量元信息。
//
// ★ 不提供 Get/明文接口 —— 见 S1。要看值就在命令里用 @{alias}。
func (s *Store) List() []Info {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Info, 0, len(s.data))
for _, e := range s.data {
out = append(out, Info{
Alias: e.Alias, Source: e.Source, Note: e.Note,
CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, Hits: e.Hits,
})
}
return out
}
// Count 返回变量条数(供状态显示与规模告警)。
func (s *Store) Count() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.data)
}
// ---------------- 辅助 ----------------
// splitKeyValue 把 `key=value` / `key = "value"` 拆成前缀与值,
// 使替换后保留键名(否则人看不出发生了什么)。
//
// ★ 键名后常带空格,所以必须 trim 后再判;初版把"含空格"当成
// "不像键名"就放弃拆分,导致 `api_key = "xxx"` 整段被当 secret(键名丢失)。
func splitKeyValue(hit string) (secret, prefix string) {
for _, sep := range []string{"=", ":"} {
i := strings.Index(hit, sep)
if i <= 0 {
continue
}
// 排除 URL 的 `://`
if sep == ":" && i+1 < len(hit) && hit[i+1] == '/' {
continue
}
key := strings.TrimSpace(hit[:i])
if key == "" || len(key) > 40 || strings.ContainsAny(key, "/@ \t\n") {
continue
}
val := strings.Trim(strings.TrimSpace(hit[i+1:]), "\"'`")
return val, key + sep + " "
}
return hit, ""
}
func nowISO() string { return time.Now().Format(time.RFC3339) }
// sha256Sum / hexEncode 是给判据用的薄封装(仅测试引用)。
func sha256Sum(b []byte) [32]byte { return sha256.Sum256(b) }
func hexEncode(b []byte) string { return hex.EncodeToString(b) }