Files
HomeAgent/internal/security/store.go
JianFeeeee 8cc8bca380 test(kernel): 架构防腐判据 + 修 migrateEntry 的并发缺陷
## 判据(internal/kernelmod/all/layering_test.go)

六条,全部经变异验证:

  TestCoreDoesNotImportKernelboot        core 不依赖装配引擎
  TestPluginDoesNotImportKernelboot      plugin 不依赖装配引擎
  TestSdkDoesNotImportKernelboot         sdk 不依赖装配引擎
  TestSecurityDoesNotImportKernelboot    security 不依赖装配引擎
  TestKstateDoesNotDependOnCore          kstate 不依赖 core("core 能读挂载点"的另一面)
  TestSecuritySingletonGuardStillExists  单例判据文件不得被删
  TestAgentHasNoPromptSectionsField      Agent 不得再持有提示词段字段
  TestPluginSettersAreInjection          防过度收敛:plugin 不得读 kstate
  TestPromptSectionsOnlyFromKstate       内核只从挂载点读插件段

用 `go list -deps` 而不是源码扫描:传递依赖能抓到间接环。

## 为什么需要"防过度收敛"这一条

kernelplan v1 曾主张把 plugin 的 12 个 SetXxx 改成"读 kstate"。实测做不到:

    kstate.State.PluginRegistry 的类型是 *plugin.Registry
    ⇒ kstate 依赖 plugin
    ⇒ plugin import kstate 就是 import cycle(已用探针实证)

更重要的是——它们**本来就不是缺陷**:每个 Setter 只有一个调用点,
调用方持有 ks、显式传参;少了任何一个插件拿 nil 立刻报错,
不存在"静默降级"(那才是包级单例的病症)。

TestPluginSettersAreInjection 防的是后来者看到一堆 Setter 就想合并成
全局注册表,那会把"显式注入"退化成"隐式全局"。

## 顺带修一个真缺陷

migrateEntry 在**解锁之后**调 saveLocked()。而 saveLocked 遍历 s.data
序列化 —— 解锁后再调就是在读一个可能被并发改写的 map:

  · data race(-race 可复现)
  · 可能写出半更新的快照(部分条目是新密文、部分是旧密文)

函数名里的 Locked 就是要求"调用方已持锁"。改为持锁期内调用。
判据:go test ./internal/security/ -race 全绿。

## 验证

  go build ./...              PASS
  go vet ./...                PASS
  gofmt -l                    干净
  go test ./... -count=1      63 包全过,0 FAIL
  go test ./internal/security/ -race   PASS
  干净检出(git worktree)     build + 判据全绿

## kernelplan.md 同步

T5 标记为"计划错误,已撤销",并记录实测依据 —— 避免下次又有人
想"顺手收敛掉"这十几个 Setter。
2026-11-04 12:28:43 +08:00

791 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
// codec 是加解密执行者(**唯一**的 AES-GCM 实现)。
//
// ★ 2026-11-04(T3.2):此前 Store 自带一份 seal/open 实现,
// 与 Codec 是**两套重复的 AES-256-GCM**(算法、nonce 布局、密钥环
// 逻辑全都一样,只是 Codec 多了 enc:v1: 前缀与域隔离)。
//
// 用户裁定:
//
// > store也应该调用security的加密能力!
//
// 现在 Store 只做"我用哪个域、我的密钥环是什么",加解密一律委托。
// 这让"加密算法只有一处实现",也修掉一个真实隐患:
// 两套实现曾在 nonce 布局上各自演化(一处前置、一处也前置但无前缀),
// 任何一侧改动都不会被另一侧的判据发现。
codec *Codec
codecMu sync.Mutex // 保护 codec 的惰性重建(keys 环在轮换时会变)
// 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_...} 会失效,
// 且同一个凭据会出现两份变量。先在现有条目中按密钥环解密比对;
// 找到就复用旧别名,找不到才用当前钥派生新别名。
s.mu.RLock()
for alias, e := range s.data {
if got, _, openErr := s.openWithStoreRing(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 带认证)。
// ★ 2026-11-04(T3.2):`openWithRing` / `seal` / `open` 已删除。
//
// 它们是 Store 自带的 AES-GCM 实现,与 Codec 完全重复(算法、nonce 布局、
// 密钥环逻辑一致,只是 Codec 多了 enc:v1: 前缀与域隔离)。
//
// 现在唯一的实现是 Codec:
//
// 写入 → s.sealWithRing(新格式,带前缀)
// 读取 → s.openWithStoreRing(兼容新旧两种格式)
// 轮换 → NewLegacyRingCodec(old) 解 + NewLegacyRingCodec(new) 加密
// 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
}
// 旧环解、新钥加密。
//
// ★ 这里刻意**不用** s.openWithStoreRing:它读 s.keys,而此刻
// s.keys 还没换(下面才换)—— 但语义上要明确"用旧环解",
// 所以显式构造。
oldCodec := NewLegacyRingCodec(storeCodecDomain, oldCandidates)
newCodec := NewLegacyRingCodec(storeCodecDomain, [][]byte{newKey})
copyData := make(map[string]*Entry, len(s.data))
for alias, e := range s.data {
var plain string
var oerr error
if IsEncrypted(e.Sealed) {
plain, oerr = oldCodec.Open(e.Sealed)
} else {
plain, oerr = oldCodec.OpenLegacy(e.Sealed)
}
if oerr != nil {
return fmt.Errorf("security: 轮换时解密变量 %s 失败,未切换密钥: %w", alias, oerr)
}
sealed, err := newCodec.Seal(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
}
// ---------------- 加密 ----------------
// codecOf 返回当前密钥环对应的编解码器(惰性构建)。
//
// ★ 为什么惰性而不是构造期建一次:`keys` 环会在 `RotateKey` 里变化
// (新钥插到头部),那时旧 codec 的 key 就过期了 —— 表现为"轮换后
// 新写入的条目用旧钥加密",而轮换的**全部意义**就是尽快停用旧钥。
// 每次取用都按当前环重建,代价是一次切片拷贝。
func (s *Store) codecOf() *Codec {
s.codecMu.Lock()
defer s.codecMu.Unlock()
keys := make([][]byte, 0, len(s.keys))
for _, g := range s.keys {
keys = append(keys, g.Key)
}
s.codec = NewLegacyRingCodec(storeCodecDomain, keys)
return s.codec
}
// storeCodecDomain 是变量库的加密域。
//
// ★ 必须与 config 的域**不同**:域隔离是安全要求,不是风格。
// 同域意味着"用同一把密钥加密两处不同用途的数据",一旦某处的明文
// 泄漏就能成为另一处的字典攻击素材。
const storeCodecDomain = "security/variables"
// sealWithRing 用密钥环加密(新格式,带 enc:v1: 前缀)。
func (s *Store) sealWithRing(plain string) (string, error) {
c := s.codecOf()
if !c.Enabled() {
return "", ErrNoMasterKey
}
return c.Seal(plain)
}
// openWithStoreRing 解开一条条目密文,**兼容旧格式**。
//
// 返回 (明文, 是否需要迁移回写)。
//
// 两种格式:
//
// enc:v1:... → 新格式,Codec.Open
// 裸 base64 → 旧格式(T3.2 之前写的),Codec.OpenLegacy
//
// ★ 为什么必须区分:`Codec.Open` 对无前缀输入**原样返回明文**
// (那是给 config 的"存量明文兼容"用的)。而 Store 的旧密文恰好也
// 无前缀 —— 直接交给 Open 会把密文当明文返回,症状是"凭据回填出一个
// base64 串",看起来像数据损坏,实际是格式误判。
func (s *Store) openWithStoreRing(sealed string) (plain string, needsMigrate bool, err error) {
c := s.codecOf()
if !c.Enabled() {
return "", false, ErrNoMasterKey
}
if IsEncrypted(sealed) {
p, oerr := c.Open(sealed)
return p, false, oerr
}
// 无前缀:只可能是旧格式密文(Store 从不存明文)。
p, oerr := c.OpenLegacy(sealed)
if oerr != nil {
return "", false, oerr
}
return p, true, 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) {
// ★ 2026-11-04(T3.2):写入走 Codec(带 enc:v1: 前缀),
// 不再自建 seal。旧条目仍按旧格式可读(见 openWithStoreRing)。
sealed, err := s.sealWithRing(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()
}
// migrateEntry 把一条旧格式条目就地升级为新格式(惰性迁移)。
//
// ## 为什么需要惰性迁移
//
// T3.2 之前 Store 写入的是**裸 base64**(无前缀)。切到 Codec 之后新写入
// 带 `enc:v1:` 前缀,但磁盘上已有的条目仍是旧格式。
//
// 三条路可选:
//
// ① 不做迁移 —— 旧格式永远可读(openWithStoreRing 兼容),
// 代价是"两种格式永久共存",判据永不能要求"落盘都是新格式"
// ② 启动时全量迁移 —— 有停机风险(21G 数据目录的教训:迁移窗口越长越危险)
// ③ 惰性迁移 —— 读到旧的就地升级,零停机、零额外扫描
//
// 选 ③。这是本仓已在用的手法(Codec.Rekey 同样是"用到了才升级")。
//
// ## 为什么失败不报错
//
// 迁移失败只影响"下次读时还要再解一次旧格式",不影响本次读取(明文已经
// 在前一步拿到了)。为一处升级失败让整个回填失败,是把可恢复的降级变成
// 不可恢复的中断。
func (s *Store) migrateEntry(alias, sealed string, plain string) {
newSealed, err := s.sealWithRing(plain)
if err != nil {
return
}
if newSealed == sealed {
return
}
s.mu.Lock()
if e, ok := s.data[alias]; ok && e.Sealed == sealed {
e.Sealed = newSealed
// ★ 落盘必须在**持锁期**内调 saveLocked:它遍历 s.data 序列化,
// 解锁后再调就是在读一个可能被并发改写的 map(数据竞争 + 可能写出
// 半更新的快照)。函数名里的 Locked 就是要求这个前提。
//
// 落盘失败也不报错:内存已升级,下次 Save 会带上。
_ = s.saveLocked()
s.mu.Unlock()
return
}
s.mu.Unlock()
}
// ---------------- 核心:回填(别名 → 明文,插件入向用) ----------------
// Fill 把文本里的 `@{alias}` 换回明文(路由到插件前用)。
//
// ★ 这是"插件能用密钥"的唯一通道,也是"插件无感知"的实现:
// 插件只看到已经填好的明文,不知道有替换这回事。
//
// 返回 (替换后文本, 替换了几处, 用到的别名)。
// 未知别名**原样保留**:不报错也不吞掉,以免把正常文本(恰好像引用)改坏。
func (s *Store) Fill(text string) (string, int, []string) {
if strings.TrimSpace(text) == "" {
return text, 0, nil
}
if len(s.keys) == 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, needsMigrate, err := s.openWithStoreRing(e.Sealed)
if err != nil {
return m
}
if needsMigrate {
// 惰性迁移:读到旧格式就地升级(失败静默,下次再试)。
s.migrateEntry(alias, e.Sealed, plain)
}
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) }