mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
* 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>
668 lines
20 KiB
Go
668 lines
20 KiB
Go
// 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) }
|