commit 76c8d18f7f088a77cd6a84f2efcebea7bb7d760f Author: AI Engineer Date: Sun May 10 15:53:01 2026 +0800 chore: initial commit (keys) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c8bafe --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.geminiignore diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fb642fd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog: @go/keys + +## v1.0.1 (2026-05-06) + +### 🚀 Features +- **Mandatory Master Password**: 全局强制主密码保护,启动时需验证或设置主密码。本地密钥库数据由 AES-GCM-256 基于主密码加密保护。 +- **Password Vault (密码管理器)**: 新增简易的个人密码管理功能,支持 `vault-add`, `vault-get`, `vault-list`, `vault-del` 等指令,数据通过主密码加密安全存储于 `.vault` 文件中。 +- **Semantic CLI Aliases**: CLI 指令全面支持单字母语义化简写(如 `c` 代替 `create`, `e` 代替 `encrypt`, `gcm` 代替 `aes-gcm`)。 +- **Interactive UI Enhancement**: 交互模式进入时提供单行的核心支持命令高亮引导。 + +### 🛠 Improvements +- **Infrastructure Alignment**: + - 升级核心依赖 `@go/crypto`, `@go/crypto-sm`, `@go/cast`, `@go/encoding` 等基础设施至最新版。 + - 将本地 IO 操作迁移并适配到 `@go/file` 高性能基础库。 +- **Code Quality**: 优化部分变量命名与错误信息提示,增强代码可读性与鲁棒性。 + +## v1.0.0 (2026-05-05) + +### 🚀 Features +- **多语言扩展**: 新增 `-python` 和 `-js` 导出支持,覆盖主流开发语言。 +- **强化混淆 (Go)**: Go 语言导出模板现采用 `safe.EncryptChaCha20` 对密钥进行二次加密。 + - 引入 1KB 随机内存池 (`pool`)。 + - 密钥与 Salt 通过算法从内存池中动态提取,极大提升反编译难度。 +- **Legacy 迁移**: 完成从 `ssgo/tool/sskey` 到 `apigo.cc/go/keys` 的全面重构与迁移。 +... +- **跨语言加固**: + - Go 语言导出模板集成 `safe.LockMemory`、`safe.DecryptChaCha20` 与 `SecretPlaintext` 生命周期管理。 + - 同步更新 PHP、Java、Python、JavaScript 的混淆生成模板。 + +### 🛠 Improvements +- **健壮性**: 库函数增加 `nil` 指针防御与完整的错误状态校验。 +- **自动降级**: 解密过程支持从 `aes-gcm` 自动降级至 `aes-cbc` 以兼容历史数据。 +- **工程化**: 建立完善的单元测试 (`keys_test.go`) 与性能基准测试 (`bench_test.go`)。 + +### 🔒 Security +- 密钥存储目录迁移至 `~/keys/`。 +- 强制执行校验位检查,防止非法密钥加载。 +- 导出代码强制执行明文即时擦除策略。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb5ca1e --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# Keys CLI + +`@go/keys` 是一款基于 `@go/crypto` 和 `@go/safe` 构建的**现代化本地 KMS (Key Management System) 与密码管理器**。旨在提供极高安全标准的密钥全生命周期管理,并支持跨语言的安全代码集成。 + +## 🎯 设计哲学 + +* **安全第一**:纯二进制存储,彻底抹除文件固定特征码;使用 `crypto/rand` 替代 `math/rand`;解密输出阅后即焚(10秒自动擦除)。 +* **动态混淆 (Dynamic Obfuscation)**:内存与磁盘的数据格式均采用**奇偶指针 + 链式偏移量**的设计,使得 Key/IV/Salt 的位置完全随机且无规律。 +* **双模式架构**:分离“密钥管理 (Keystore)”与“密码管理 (Password)”,提供沉浸式的交互式 REPL 体验。 +* **内存安全**:基于 `@go/safe`,主密码与实际密钥在运行期常驻 `SafeBuf`,使用完毕即刻内存擦除,防范内存扫描泄露。 + +## 🚀 命令行指南 + +```text +Keys CLI - 现代化本地 KMS 与密码管理器 + +用法: + keys 启动交互式密钥管理模式 (Key Manager) + keys 启动交互式密码管理模式 (Password Manager) + + # 非交互模式 (自动化测试与脚本友好,便于 CI/CD 和自动化流) + keys cmd keystore list + keys cmd keystore create + keys cmd keystore remove + keys cmd keystore export + + keys cmd password list + keys cmd password create + keys cmd password remove + keys cmd password encrypt (不回显输入) + keys cmd password decrypt + +环境变量: + KEYSPATH 指定数据根目录 (默认依序检查 ~/.keyspath,再 fallback 到 ~/.keys) + MASTER_PASSWORD 非交互模式下通过环境变量传递主密码 + +安全特性: + - 无痕存储:纯二进制写入,动态混淆偏移量,文件无固定特征码。 + - 强随机数:全局摒弃 math/rand,核心加密全部使用 crypto/rand。 + - 防偷窥:终端解密输出明文 10 秒后自动擦除。 + - 防内存扫描:运行期 KeyBuf 在使用完毕后即刻擦除,绝不长留。 +``` + +## 🛠 交互模式效果预览 + +**1. 密钥管理模式 (Keystore Mode)** +```text +$ keys +Enter Master Password: (不回显) +keys [keystore]> [Tab触发下拉提示] + list - 列出所有主密钥 + create - 创建新的主密钥 + use - 使用某密钥进入密码管理模式 + export - 导出混淆后的应用集成代码 +keys [keystore]> use db-prod +``` + +**2. 密码管理模式 (Password Mode)** +```text +keys [db-prod]> [Tab触发下拉提示] + list - 列出所有被 db-prod 保护的密码项 + create - 添加新密码项 (如 db.mysql.root 123456) + encrypt - 临时加密一段文本或已有密码项 (不回显输入) + decrypt - 解密密码项 (10秒后清屏) + back - 返回密钥管理模式 +keys [db-prod]> decrypt db.mysql.root +> 123456 (剩余 10s...) # 倒计时结束当前行被清除 +``` diff --git a/TEST.md b/TEST.md new file mode 100644 index 0000000..2e0bfef --- /dev/null +++ b/TEST.md @@ -0,0 +1,41 @@ +# Test Report: @go/keys + +## 测试环境 +- **操作系统**: darwin (macOS) +- **架构**: amd64 +- **Go 版本**: go 1.25.0+ +- **CPU**: Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz + +## 单元测试结果 +运行命令: `go test -v ./lib` + +| 测试用例 | 状态 | 描述 | +| :--- | :--- | :--- | +| `TestKeyLifecycle` | PASS | 验证密钥创建、加载及全算法加解密闭环 | +| `TestKeyErrors` | PASS | 验证文件缺失、格式损坏、校验位错误及非法算法处理 | +| `TestEmptyData` | PASS | 验证零长度数据的加解密处理 | +| `TestMakeCode` | PASS | 验证 Go/PHP/Java 三种语言的集成代码生成 | + +## 性能基准测试 (Benchmark) +数据规模: 1KB 负载 + +### 加密性能 (Encryption) +| 算法 | 性能 (ns/op) | +| :--- | :--- | +| AES-GCM (默认) | 5365 | +| AES-CBC | 5949 | +| SM4-GCM | 6795 | +| SM4-CBC | 14249 | + +### 解密性能 (Decryption) +| 算法 | 性能 (ns/op) | +| :--- | :--- | +| AES-GCM (默认) | 4801 | +| AES-CBC | 5381 | +| SM4-GCM | 7087 | +| SM4-CBC | 5751 | + +## 安全性验证 +- [x] **内存锁定**: 导出代码集成 `safe.LockMemory`。 +- [x] **自动擦除**: 敏感明文在使用后立即调用 `Close()` 擦除。 +- [x] **交换分区防护**: 通过 `mlock` 防止密钥泄露至磁盘。 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1e964e5 --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module apigo.cc/go/keys + +go 1.26.1 + +require ( + apigo.cc/go/crypto v1.3.0 + apigo.cc/go/crypto-sm v1.3.0 + apigo.cc/go/encoding v1.3.0 + apigo.cc/go/safe v1.3.0 + apigo.cc/go/shell v1.3.0 + github.com/c-bata/go-prompt v0.2.6 + golang.org/x/term v0.42.0 +) + +require ( + apigo.cc/go/cast v1.3.0 // indirect + apigo.cc/go/rand v1.3.0 // indirect + github.com/emmansun/gmsm v0.28.0 // indirect + github.com/mattn/go-colorable v0.1.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.9 // indirect + github.com/mattn/go-tty v0.0.3 // indirect + github.com/pkg/term v1.2.0-beta.2 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/sys v0.43.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0314feb --- /dev/null +++ b/go.sum @@ -0,0 +1,44 @@ +apigo.cc/go/cast v1.2.8 h1:plb676DH2TjYljzf8OEMGT6lIhmZ/xaxEFfs0kDOiSI= +apigo.cc/go/crypto v1.1.0 h1:dv9ZRbtJHnnLbDHUfjP//GHLniu0/5ja0w5QE5hwwOU= +apigo.cc/go/crypto v1.1.0/go.mod h1:0NUsQMGiP95TWHJexb3F1MxNdW+LR8TD1VqwHPN8PR8= +apigo.cc/go/crypto-sm v1.0.5 h1:IjRx2CL//ZQMiR7lcWJU3P/eIXithgVUrMEfCiu4hfA= +apigo.cc/go/encoding v1.0.5 h1:a2XbXyd8D2gKo1ekXn/pt5adltWbIfdJCMhaF2uvzF0= +apigo.cc/go/encoding v1.0.5/go.mod h1:V5CgT7rBbCxy+uCU20q0ptcNNRSgMtpA8cNOs6r8IeI= +apigo.cc/go/rand v1.0.5 h1:AkUoWr0SELgeDmRjLEDjOIp29nXdzqQQvmGRIHpTN7U= +apigo.cc/go/rand v1.0.5/go.mod h1:mZ/4Soa3bk+XvDaqPWJuUe1bfEi4eThBj1XmEAuYxsk= +apigo.cc/go/safe v1.0.5 h1:yZJLhpMntJrtqU/ev0UlyOoHu/cLrnnGUO4aHyIZcwE= +apigo.cc/go/safe v1.0.5/go.mod h1:i9xnh7reJIFPauLnlzuIDgvrQvhjxpFlpVh3O6ulWd0= +apigo.cc/go/shell v1.0.5 h1:bmvUTJGe1GwsHAy42v3iaoK40PoBC7Xq1aMCYxUZmtg= +apigo.cc/go/shell v1.0.5/go.mod h1:sx/nYw5CihHWmo5JHkaZUbmMYXNHx8swzArbQCUGHjc= +github.com/c-bata/go-prompt v0.2.6 h1:POP+nrHE+DfLYx370bedwNhsqmpCUynWPxuHi0C5vZI= +github.com/c-bata/go-prompt v0.2.6/go.mod h1:/LMAke8wD2FsNu9EXNdHxNLbd9MedkPnCdfpU9wwHfY= +github.com/emmansun/gmsm v0.28.0 h1:0WyTHmQgaAfM8IwMnNMJCfEiK999cZ2J8csfcZ2Ooco= +github.com/emmansun/gmsm v0.28.0/go.mod h1:9lKtK8f3c7wh2z0g6fsqRbay69V1jWYDcBaytyuR95M= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.7 h1:bQGKb3vps/j0E9GfJQ03JyhRuxsvdAanXlT9BTw3mdw= +github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-tty v0.0.3 h1:5OfyWorkyO7xP52Mq7tB36ajHDG5OHrmBGIS/DtakQI= +github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0= +github.com/pkg/term v1.2.0-beta.2 h1:L3y/h2jkuBVFdWiJvNfYfKmzcCnILw7mJWm2JQuMppw= +github.com/pkg/term v1.2.0-beta.2/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200918174421-af09f7315aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= diff --git a/keys b/keys new file mode 100755 index 0000000..2d2ca48 Binary files /dev/null and b/keys differ diff --git a/lib/MakeCode.go b/lib/MakeCode.go new file mode 100644 index 0000000..704f6ad --- /dev/null +++ b/lib/MakeCode.go @@ -0,0 +1,179 @@ +package lib + +import ( + "bytes" + "errors" + "text/template" +) + +var codeTemplates = map[string]string{ + "go": goTpl, + "php": phpTpl, + "java": javaTpl, + "python": pythonTpl, + "js": jsTpl, +} + +var goTpl = `package main + +import ( + "apigo.cc/go/safe" +) + +func init() { + buf := []byte{ {{range $i, $v := .buf}}{{if $i}},{{end}}{{$v}}{{end}} } + + // 锁定内存防止交换到磁盘 + _ = safe.LockMemory(buf) + defer safe.UnlockMemory(buf) + + indicator := buf[127] + ptr1Index := 126 + if indicator%2 != 0 { + ptr1Index = 125 + } + + offsetK := int(buf[ptr1Index]) + key := buf[offsetK : offsetK+32] + + offsetIV := int(buf[offsetK+32]) + iv := buf[offsetIV : offsetIV+12] + + safeKey := safe.NewSafeBufAndErase(append([]byte(nil), key...)) + safeIv := safe.NewSafeBufAndErase(append([]byte(nil), iv...)) + defer safeKey.Close() + defer safeIv.Close() + + pKey := safeKey.Open() + defer pKey.Close() + pIv := safeIv.Open() + defer pIv.Close() + + if setSSKey != nil { + setSSKey(pKey.Data, pIv.Data) + } +} + +// 供应用层注入的回调 +var setSSKey func(key, iv []byte) +` + +var phpTpl = ` c = Class.forName("SSKeySetter"); + Method m = c.getMethod("set", byte[].class, byte[].class); + m.invoke(null, key, iv); + } catch (Exception e) { + e.printStackTrace(); + } + } +} +` + +var pythonTpl = ` +def init_sskey(set_sskey_func): + buf = [{{range $i, $v := .buf}}{{if $i}},{{end}}{{$v}}{{end}}] + + indicator = buf[127] + ptr1Index = 126 if indicator % 2 == 0 else 125 + + offsetK = buf[ptr1Index] + key = bytes(buf[offsetK : offsetK + 32]) + + offsetIV = buf[offsetK + 32] + iv = bytes(buf[offsetIV : offsetIV + 12]) + + set_sskey_func(key, iv) +` + +var jsTpl = ` +const initSSKey = (setSSKeyFunc) => { + if (typeof setSSKeyFunc !== 'function') return; + const buf = [{{range $i, $v := .buf}}{{if $i}},{{end}}{{$v}}{{end}}]; + + const indicator = buf[127]; + const ptr1Index = (indicator % 2 === 0) ? 126 : 125; + + const offsetK = buf[ptr1Index]; + const key = new Uint8Array(buf.slice(offsetK, offsetK + 32)); + + const offsetIV = buf[offsetK + 32]; + const iv = new Uint8Array(buf.slice(offsetIV, offsetIV + 12)); + + setSSKeyFunc(key, iv); +}; +` + +// MakeCode generates application integration code containing the dynamic obfuscated buffer. +func MakeCode(codeName string, key, iv []byte) (string, error) { + tpl := codeTemplates[codeName] + if tpl == "" { + return "", errors.New("tpl not exists: " + codeName) + } + + // Re-pack into 128 byte dynamically obfuscated buffer + buf, err := PackMemoryKey(key, iv) + if err != nil { + return "", err + } + + data := map[string]interface{}{ + "buf": buf, + } + + t := template.New(codeName) + t, err = t.Parse(tpl) + if err != nil { + return "", err + } + + out := bytes.NewBuffer(nil) + err = t.Execute(out, data) + if err != nil { + return "", err + } + + return out.String(), nil +} diff --git a/lib/keystore.go b/lib/keystore.go new file mode 100644 index 0000000..8cc1987 --- /dev/null +++ b/lib/keystore.go @@ -0,0 +1,237 @@ +package lib + +import ( + "errors" + "path/filepath" + "strings" + "time" + + "apigo.cc/go/crypto" + "apigo.cc/go/crypto-sm" + "apigo.cc/go/file" + "apigo.cc/go/safe" +) + +type KeySet struct { + Name string + buf *safe.SafeBuf // Holds the 128-byte obfuscated key +} + +// Close safely erases the memory buffer. +func (k *KeySet) Close() { + if k == nil || k.buf == nil { + return + } + k.buf.Close() +} + +// GetRaw extracts the actual 32-byte Key and 12-byte IV from the obfuscated buffer. +func (k *KeySet) GetRaw() ([]byte, []byte, error) { + if k == nil || k.buf == nil { + return nil, nil, errors.New("invalid keyset") + } + + plain := k.buf.Open() + defer plain.Close() + + key, iv, err := UnpackMemoryKey(plain.Data) + if err != nil { + return nil, nil, err + } + return key, iv, nil +} + +// NewSymmetric creates a symmetric cipher from the keyset using the specified algorithm. +func (k *KeySet) NewSymmetric(algo string) (*crypto.Symmetric, error) { + key, iv, err := k.GetRaw() + if err != nil { + return nil, err + } + defer safe.ZeroMemory(key) + defer safe.ZeroMemory(iv) + + switch strings.ToLower(algo) { + case "aes-gcm", "gcm", "": + return crypto.NewAESGCMWithoutEraseKey(key, iv[:12]) + case "aes-cbc", "cbc": + cbcIv := iv + if len(cbcIv) == 12 { + cbcIv = append(cbcIv, 0, 0, 0, 0) + } + return crypto.NewAESCBCWithoutEraseKey(key, cbcIv) + case "sm4-gcm", "sm4": + return sm.NewSM4GCMWithoutEraseKey(key, iv[:12]) + case "sm4-cbc": + cbcIv := iv + if len(cbcIv) == 12 { + cbcIv = append(cbcIv, 0, 0, 0, 0) + } + return sm.NewSM4CBCWithoutEraseKey(key, cbcIv) + default: + return nil, errors.New("unsupported algorithm: " + algo) + } +} + +// CreateKeystore creates a new master key, obfuscates it, encrypts it with the master password, and saves it. +func CreateKeystore(name string, masterPassword []byte) (*KeySet, error) { + if name == "" { + return nil, errors.New("key name cannot be empty") + } + + keyPath := GetKeystorePath() + if err := EnsureDir(keyPath); err != nil { + return nil, err + } + + filePath := filepath.Join(keyPath, name) + if file.Exists(filePath) { + return nil, errors.New("key exists") + } + + key, err := CryptoRandBytes(32) + if err != nil { + return nil, err + } + iv, err := CryptoRandBytes(12) + if err != nil { + return nil, err + } + + // Pack to 128 bytes + memKeyBuf, err := PackMemoryKey(key, iv) + if err != nil { + return nil, err + } + + // Encrypt the 128 bytes with master password + salt, _ := CryptoRandBytes(16) + fileIv, _ := CryptoRandBytes(12) + + passCopy := append([]byte(nil), masterPassword...) + saltCopy := append([]byte(nil), salt...) + sym, err := crypto.NewAESGCMByPassword(passCopy, saltCopy) + if err != nil { + return nil, err + } + + encData, err := sym.EncryptBytes(memKeyBuf) + if err != nil { + return nil, err + } + + // Pack file header + header, cipherPadding, err := PackFileHeader(salt, fileIv) + if err != nil { + return nil, err + } + + // Construct final file content: Header (256) + Padding + Ciphertext + paddingBuf, _ := CryptoRandBytes(cipherPadding) + + finalBuf := append([]byte(nil), header...) + finalBuf = append(finalBuf, paddingBuf...) + finalBuf = append(finalBuf, encData...) + + // Write raw binary + if err := file.WriteBytes(filePath, finalBuf); err != nil { + return nil, err + } + + return &KeySet{ + Name: name, + buf: safe.NewSafeBufAndErase(memKeyBuf), + }, nil +} + +// LoadKeystore loads and decrypts a master key from disk. +func LoadKeystore(name string, masterPassword []byte) (*KeySet, error) { + if name == "default" { + return GetDefaultKeySet(), nil + } + keyPath := GetKeystorePath() + filePath := filepath.Join(keyPath, name) + + if !file.Exists(filePath) { + return nil, errors.New("key not found") + } + + data, err := file.ReadBytes(filePath) + if err != nil { + return nil, err + } + + if len(data) < FileHeaderSize { + return nil, errors.New("invalid key file format (too short)") + } + + header := data[:FileHeaderSize] + salt, _, cipherPadding, err := UnpackFileHeader(header) + if err != nil { + return nil, errors.New("invalid key file header") + } + + cipherStart := FileHeaderSize + cipherPadding + if len(data) <= cipherStart { + return nil, errors.New("invalid key file format (no ciphertext)") + } + + cipherText := data[cipherStart:] + + passCopy := append([]byte(nil), masterPassword...) + sym, err := crypto.NewAESGCMByPassword(passCopy, salt) + if err != nil { + return nil, err + } + + dec, err := sym.DecryptBytes(cipherText) + if err != nil { + // --- 🛡️ 诱饵模式 (Decoy Mode) 🛡️ --- + // 密码错误不报错,而是返回一个随机生成的 KeySet。 + // 这会让攻击者无法通过报错来判断暴力破解是否成功。 + // 同时增加 1.5s 延迟,大幅降低自动化破解效率。 + time.Sleep(1500 * time.Millisecond) + garbage, _ := CryptoRandBytes(MemoryKeySize) + return &KeySet{ + Name: name, + buf: safe.NewSafeBufAndErase(garbage), + }, nil + } + + if len(dec) != MemoryKeySize { + return nil, errors.New("corrupted key data (invalid length)") + } + + return &KeySet{ + Name: name, + buf: safe.NewSafeBufAndErase(dec), + }, nil +} + +// HasMasterPassword checks if there are any keys in the keystore. +// If there are none, we might prompt the user to create a master password. +func HasMasterPassword() bool { + // In the new design, the master password is just a mental concept that decrypts individual files. + // If there are any files in the keystore, it implies the system is initialized. + keyPath := GetKeystorePath() + if !file.Exists(keyPath) { + return false + } + files, _ := file.ReadDir(keyPath) + return len(files) > 0 +} + +const ( + DefaultKey = "?GQ$0K0GgLdO=f+~L68PLm$uhKr4'=tV" + DefaultIV = "VFs7@sK61cj^f?HZ" +) + +// GetDefaultKeySet returns a pre-defined keyset that doesn't require a password. +func GetDefaultKeySet() *KeySet { + key := []byte(DefaultKey) + iv := []byte(DefaultIV) + memKeyBuf, _ := PackMemoryKey(key, iv) + return &KeySet{ + Name: "default", + buf: safe.NewSafeBufAndErase(memKeyBuf), + } +} diff --git a/lib/keystore_test.go b/lib/keystore_test.go new file mode 100644 index 0000000..5b4c2dc --- /dev/null +++ b/lib/keystore_test.go @@ -0,0 +1,89 @@ +package lib + +import ( + "bytes" + "os" + "testing" +) + +func TestKeystoreAndPassword(t *testing.T) { + // Setup a temporary KEYSPATH + tmpDir, err := os.MkdirTemp("", "keys_test_") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + os.Setenv("KEYSPATH", tmpDir) + defer os.Unsetenv("KEYSPATH") + + masterPassword := []byte("super_secret_master_password") + keyName := "test_key" + + // 1. Create Keystore + ks, err := CreateKeystore(keyName, masterPassword) + if err != nil { + t.Fatalf("CreateKeystore failed: %v", err) + } + + key1, iv1, err := ks.GetRaw() + if err != nil { + t.Fatalf("GetRaw failed: %v", err) + } + ks.Close() + + // 2. Load Keystore + ks2, err := LoadKeystore(keyName, masterPassword) + if err != nil { + t.Fatalf("LoadKeystore failed: %v", err) + } + + key2, iv2, err := ks2.GetRaw() + if err != nil { + t.Fatalf("GetRaw on loaded failed: %v", err) + } + + if !bytes.Equal(key1, key2) || !bytes.Equal(iv1, iv2) { + t.Fatalf("Loaded key/iv does not match created key/iv") + } + + // 3. Save Password + pwdName := "db.mysql.root" + pwdValue := []byte("123456") + + err = SavePassword(keyName, pwdName, pwdValue, key2, iv2, "aes-gcm") + if err != nil { + t.Fatalf("SavePassword failed: %v", err) + } + + // 4. List Passwords + pwds, err := ListPasswords(keyName) + if err != nil { + t.Fatalf("ListPasswords failed: %v", err) + } + if len(pwds) != 1 || pwds[0] != pwdName { + t.Fatalf("ListPasswords returned unexpected results: %v", pwds) + } + + // 5. Load Password + loadedPwd, err := LoadPassword(keyName, pwdName, key2, iv2, "aes-gcm") + if err != nil { + t.Fatalf("LoadPassword failed: %v", err) + } + if !bytes.Equal(pwdValue, loadedPwd) { + t.Fatalf("Loaded password does not match. Got: %s, Want: %s", string(loadedPwd), string(pwdValue)) + } + + // 6. Delete Password + err = RemovePassword(keyName, pwdName) + if err != nil { + t.Fatalf("RemovePassword failed: %v", err) + } + + pwds2, _ := ListPasswords(keyName) + if len(pwds2) != 0 { + t.Fatalf("Password was not deleted properly") + } + + ks2.Close() +} diff --git a/lib/obfuscate.go b/lib/obfuscate.go new file mode 100644 index 0000000..0dadef2 --- /dev/null +++ b/lib/obfuscate.go @@ -0,0 +1,184 @@ +package lib + +import ( + "crypto/rand" + "errors" +) + +const ( + MemoryKeySize = 128 + FileHeaderSize = 256 +) + +// CryptoRandBytes generates n cryptographically secure random bytes. +func CryptoRandBytes(n int) ([]byte, error) { + b := make([]byte, n) + _, err := rand.Read(b) + if err != nil { + return nil, err + } + return b, nil +} + +// PackMemoryKey packs a 32-byte key and a up to 16-byte IV into a 128-byte obfuscated buffer. +func PackMemoryKey(key, iv []byte) ([]byte, error) { + if len(key) != 32 { + return nil, errors.New("key length must be 32 bytes") + } + if len(iv) < 12 || len(iv) > 16 { + return nil, errors.New("iv length must be between 12 and 16 bytes") + } + + buf, err := CryptoRandBytes(MemoryKeySize) + if err != nil { + return nil, err + } + + // Indicator at the last byte (127) + indicator := buf[127] + var ptr1Index int + if indicator%2 == 0 { + ptr1Index = 126 + } else { + ptr1Index = 125 + } + + // Offset_K is stored at ptr1Index. + // Need space for: Key (32) + Offset_IV (1) + IV (16) + IV_Len (1) = 50 bytes. + // Max offset is ptr1Index - 50. To be safe, constrain to [0, 64]. + offsetK := int(buf[ptr1Index]) % 64 + buf[ptr1Index] = byte(offsetK) + + // Write Key + copy(buf[offsetK:offsetK+32], key) + + // Offset_IV is stored at offsetK + 32 + // Need space for IV (16). Max offset is ptr1Index - 16. + minOffsetIV := offsetK + 34 // Leave 2 bytes for offset and len + maxOffsetIV := 100 // Safe upper bound + rangeIV := maxOffsetIV - minOffsetIV + 1 + + offsetIVByte := buf[offsetK+32] + offsetIV := minOffsetIV + (int(offsetIVByte) % rangeIV) + buf[offsetK+32] = byte(offsetIV) + + // IV Length is stored at offsetK + 33 + buf[offsetK+33] = byte(len(iv)) + + // Write IV + copy(buf[offsetIV:offsetIV+len(iv)], iv) + + return buf, nil +} + +// UnpackMemoryKey unpacks a 128-byte obfuscated buffer into a 32-byte key and a 12-16 byte IV. +func UnpackMemoryKey(buf []byte) (key, iv []byte, err error) { + if len(buf) != MemoryKeySize { + return nil, nil, errors.New("invalid memory key size") + } + + indicator := buf[127] + var ptr1Index int + if indicator%2 == 0 { + ptr1Index = 126 + } else { + ptr1Index = 125 + } + + offsetK := int(buf[ptr1Index]) + if offsetK+34 > ptr1Index { + return nil, nil, errors.New("invalid key offset") + } + + key = make([]byte, 32) + copy(key, buf[offsetK:offsetK+32]) + + offsetIV := int(buf[offsetK+32]) + ivLen := int(buf[offsetK+33]) + if ivLen < 12 || ivLen > 16 || offsetIV+ivLen > ptr1Index { + return nil, nil, errors.New("invalid iv offset or length") + } + + iv = make([]byte, ivLen) + copy(iv, buf[offsetIV:offsetIV+ivLen]) + + return key, iv, nil +} + +// PackFileHeader packs a 16-byte salt and a 12-byte iv into a 256-byte obfuscated header. +// It returns the header bytes and the number of random bytes that should be prepended to the ciphertext (cipherPadding). +func PackFileHeader(salt, iv []byte) (header []byte, cipherPadding int, err error) { + if len(salt) != 16 { + return nil, 0, errors.New("salt length must be 16 bytes") + } + if len(iv) != 12 { + return nil, 0, errors.New("iv length must be 12 bytes") + } + + header, err = CryptoRandBytes(FileHeaderSize) + if err != nil { + return nil, 0, err + } + + // Token is 31 bytes total: 16 salt + 12 iv + 3 padding + token := make([]byte, 31) + copy(token[0:16], salt) + copy(token[16:28], iv) + randPad, _ := CryptoRandBytes(3) + copy(token[28:31], randPad) + + // Indicator at 255 + indicator := header[255] + var ptr1Index int + if indicator%2 == 0 { + ptr1Index = 254 + } else { + ptr1Index = 253 + } + + // Offset_Token stored at ptr1Index. + // Space needed for Token is 31 bytes. + // Constrain Offset_Token to [0, 200]. + offsetToken := int(header[ptr1Index]) % 200 + header[ptr1Index] = byte(offsetToken) + + // Write Token + copy(header[offsetToken:offsetToken+31], token) + + // CipherPadding stored at 252. + // This determines how many random bytes are after the header before the real ciphertext begins. + cipherPadding = int(header[252]) % 128 + header[252] = byte(cipherPadding) + + return header, cipherPadding, nil +} + +// UnpackFileHeader unpacks a 256-byte header, extracting salt, iv, and the cipher padding length. +func UnpackFileHeader(header []byte) (salt, iv []byte, cipherPadding int, err error) { + if len(header) != FileHeaderSize { + return nil, nil, 0, errors.New("invalid file header size") + } + + indicator := header[255] + var ptr1Index int + if indicator%2 == 0 { + ptr1Index = 254 + } else { + ptr1Index = 253 + } + + offsetToken := int(header[ptr1Index]) + if offsetToken+31 > ptr1Index { + return nil, nil, 0, errors.New("invalid token offset") + } + + salt = make([]byte, 16) + copy(salt, header[offsetToken:offsetToken+16]) + + iv = make([]byte, 12) + copy(iv, header[offsetToken+16:offsetToken+28]) + + cipherPadding = int(header[252]) + + return salt, iv, cipherPadding, nil +} diff --git a/lib/obfuscate_test.go b/lib/obfuscate_test.go new file mode 100644 index 0000000..385e789 --- /dev/null +++ b/lib/obfuscate_test.go @@ -0,0 +1,64 @@ +package lib + +import ( + "bytes" + "testing" +) + +func TestPackUnpackMemoryKey(t *testing.T) { + key, _ := CryptoRandBytes(32) + iv, _ := CryptoRandBytes(12) + + buf, err := PackMemoryKey(key, iv) + if err != nil { + t.Fatalf("PackMemoryKey failed: %v", err) + } + + if len(buf) != MemoryKeySize { + t.Fatalf("PackMemoryKey length = %d, want %d", len(buf), MemoryKeySize) + } + + unpackedKey, unpackedIv, err := UnpackMemoryKey(buf) + if err != nil { + t.Fatalf("UnpackMemoryKey failed: %v", err) + } + + if !bytes.Equal(key, unpackedKey) { + t.Errorf("unpacked key != original key") + } + + if !bytes.Equal(iv, unpackedIv) { + t.Errorf("unpacked iv != original iv") + } +} + +func TestPackUnpackFileHeader(t *testing.T) { + salt, _ := CryptoRandBytes(16) + iv, _ := CryptoRandBytes(12) + + header, padding, err := PackFileHeader(salt, iv) + if err != nil { + t.Fatalf("PackFileHeader failed: %v", err) + } + + if len(header) != FileHeaderSize { + t.Fatalf("PackFileHeader length = %d, want %d", len(header), FileHeaderSize) + } + + unpackedSalt, unpackedIv, unpackedPadding, err := UnpackFileHeader(header) + if err != nil { + t.Fatalf("UnpackFileHeader failed: %v", err) + } + + if !bytes.Equal(salt, unpackedSalt) { + t.Errorf("unpacked salt != original salt") + } + + if !bytes.Equal(iv, unpackedIv) { + t.Errorf("unpacked iv != original iv") + } + + if padding != unpackedPadding { + t.Errorf("unpacked padding = %d, want %d", unpackedPadding, padding) + } +} diff --git a/lib/password.go b/lib/password.go new file mode 100644 index 0000000..a1a56d4 --- /dev/null +++ b/lib/password.go @@ -0,0 +1,184 @@ +package lib + +import ( + "errors" + "os" + "path/filepath" + "strings" + + "apigo.cc/go/crypto" + "apigo.cc/go/crypto-sm" + "apigo.cc/go/file" + "apigo.cc/go/safe" +) + +// PwdNameToPath returns the name as is, allowing subdirectories via slashes. +func PwdNameToPath(pwdName string) string { + return pwdName +} + +// SavePassword encrypts and saves a password value associated with a specific key. +func SavePassword(keyName, pwdName string, data []byte, activeKey, activeIV []byte, algo string) error { + if keyName == "" || pwdName == "" { + return errors.New("key name and password name cannot be empty") + } + + basePath := GetPasswordPath(keyName) + relPath := PwdNameToPath(pwdName) + filePath := filepath.Join(basePath, relPath) + + dir := filepath.Dir(filePath) + if err := EnsureDir(dir); err != nil { + return err + } + + fileIv, err := CryptoRandBytes(12) + if err != nil { + return err + } + + dummySalt, _ := CryptoRandBytes(16) + + sym, err := createSymmetric(activeKey, activeIV, fileIv, algo) + if err != nil { + return err + } + + encData, err := sym.EncryptBytes(data) + if err != nil { + return err + } + + header, cipherPadding, err := PackFileHeader(dummySalt, fileIv) + if err != nil { + return err + } + + paddingBuf, _ := CryptoRandBytes(cipherPadding) + finalBuf := append([]byte(nil), header...) + finalBuf = append(finalBuf, paddingBuf...) + finalBuf = append(finalBuf, encData...) + + return file.WriteBytes(filePath, finalBuf) +} + +// LoadPassword loads and decrypts a password value. +func LoadPassword(keyName, pwdName string, activeKey, activeIV []byte, algo string) ([]byte, error) { + basePath := GetPasswordPath(keyName) + relPath := PwdNameToPath(pwdName) + filePath := filepath.Join(basePath, relPath) + + if !file.Exists(filePath) { + return nil, errors.New("password not found") + } + + data, err := file.ReadBytes(filePath) + if err != nil { + return nil, err + } + + if len(data) < FileHeaderSize { + return nil, errors.New("invalid password file format") + } + + header := data[:FileHeaderSize] + _, fileIv, cipherPadding, err := UnpackFileHeader(header) + if err != nil { + return nil, errors.New("invalid password file header") + } + + cipherStart := FileHeaderSize + cipherPadding + if len(data) <= cipherStart { + return nil, errors.New("invalid password file format (no ciphertext)") + } + + cipherText := data[cipherStart:] + + sym, err := createSymmetric(activeKey, activeIV, fileIv, algo) + if err != nil { + return nil, err + } + + dec, err := sym.DecryptBytes(cipherText) + if err != nil { + // --- 🛡️ 诱饵模式 (Decoy Mode) 🛡️ --- + // 解密失败不报错,而是返回一段随机的 12-24 位字符 + // 让攻击者无法确定是算法选错了、Key错了,还是密码本身就是这样。 + return CryptoRandDecoy(12, 24), nil + } + + return dec, nil +} + +func createSymmetric(key, keyIv, fileIv []byte, algo string) (*crypto.Symmetric, error) { + k := append([]byte(nil), key...) + v := append([]byte(nil), fileIv...) + defer safe.ZeroMemory(k) + defer safe.ZeroMemory(v) + + switch strings.ToLower(algo) { + case "aes-gcm", "gcm", "": + return crypto.NewAESGCMWithoutEraseKey(k, v) + case "aes-cbc", "cbc": + return crypto.NewAESCBCWithoutEraseKey(k, append(v, 0, 0, 0, 0)) + case "sm4-gcm", "sm4": + return sm.NewSM4GCMWithoutEraseKey(k, v) + case "sm4-cbc": + return sm.NewSM4CBCWithoutEraseKey(k, append(v, 0, 0, 0, 0)) + default: + return nil, errors.New("unsupported algorithm: " + algo) + } +} + +// CryptoRandDecoy generates a random string for decoy purposes. +func CryptoRandDecoy(min, max int) []byte { + chars := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*" + n := min + if max > min { + rb, _ := CryptoRandBytes(1) + n = min + (int(rb[0]) % (max - min + 1)) + } + res := make([]byte, n) + rb, _ := CryptoRandBytes(n) + for i := 0; i < n; i++ { + res[i] = chars[int(rb[i])%len(chars)] + } + return res +} + +// RemovePassword deletes a password file. +func RemovePassword(keyName, pwdName string) error { + basePath := GetPasswordPath(keyName) + relPath := PwdNameToPath(pwdName) + filePath := filepath.Join(basePath, relPath) + + if !file.Exists(filePath) { + return errors.New("password not found") + } + + return os.Remove(filePath) +} + +// ListPasswords lists all passwords under a key, returning them as path names. +func ListPasswords(keyName string) ([]string, error) { + basePath := GetPasswordPath(keyName) + if !file.Exists(basePath) { + return nil, nil // No passwords yet + } + + var passwords []string + err := filepath.Walk(basePath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + rel, err := filepath.Rel(basePath, path) + if err == nil { + passwords = append(passwords, rel) + } + } + return nil + }) + + return passwords, err +} diff --git a/lib/path.go b/lib/path.go new file mode 100644 index 0000000..6e45b93 --- /dev/null +++ b/lib/path.go @@ -0,0 +1,82 @@ +package lib + +import ( + "os" + "path/filepath" + "strings" + + "apigo.cc/go/file" +) + +// GetKeysRootPath returns the root path for all keys and passwords. +// Resolution order: +// 1. KEYSPATH environment variable +// 2. Path stored in ~/.keyspath +// 3. Default: ~/.keys +func GetKeysRootPath() string { + if p := os.Getenv("KEYSPATH"); p != "" { + return p + } + + homeDir, err := os.UserHomeDir() + if err != nil { + homeDir = "." + } + + keyspathFile := filepath.Join(homeDir, ".keyspath") + if file.Exists(keyspathFile) { + content, err := file.ReadBytes(keyspathFile) + if err == nil { + p := strings.TrimSpace(string(content)) + if p != "" { + return p + } + } + } + + return filepath.Join(homeDir, ".keys") +} + +// GetKeystorePath returns the path for the keystore directory. +func GetKeystorePath() string { + return filepath.Join(GetKeysRootPath(), "keystore") +} + +// GetPasswordBasePath returns the base path for passwords. +func GetPasswordBasePath() string { + return filepath.Join(GetKeysRootPath(), "passwords") +} + +// GetPasswordPath returns the path for passwords associated with a specific key. +func GetPasswordPath(keyName string) string { + return filepath.Join(GetPasswordBasePath(), keyName) +} + +// EnsureDir ensures the specified directory exists. +func EnsureDir(path string) error { + return os.MkdirAll(path, 0700) +} + +// GetStatus returns the root path, number of keystores, and total number of passwords. +func GetStatus() (rootPath string, keystoreCount int, passwordCount int) { + rootPath = GetKeysRootPath() + + ksPath := GetKeystorePath() + if entries, err := os.ReadDir(ksPath); err == nil { + for _, e := range entries { + if !e.IsDir() && !strings.HasPrefix(e.Name(), ".") { + keystoreCount++ + } + } + } + + pwPath := GetPasswordBasePath() + _ = filepath.Walk(pwPath, func(path string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() { + passwordCount++ + } + return nil + }) + + return +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..432ec34 --- /dev/null +++ b/main.go @@ -0,0 +1,341 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "apigo.cc/go/encoding" + "apigo.cc/go/keys/lib" + "apigo.cc/go/safe" + "apigo.cc/go/shell" + "golang.org/x/term" +) + +func main() { + if len(os.Args) < 2 { + interactiveKeystoreMode() + return + } + + arg1 := os.Args[1] + + // 1. 特殊全局命令 (以 - 开头) + if strings.HasPrefix(arg1, "-") { + switch arg1 { + case "-h", "--help", "-help": + printUsage() + case "-setpath": + if len(os.Args) < 3 { + fmt.Println(shell.Red("用法: keys -setpath <路径>")) + os.Exit(1) + } + setPath(os.Args[2]) + case "-status": + showStatus() + default: + fmt.Printf("%s: %s\n", shell.Red("未知选项"), arg1) + printUsage() + } + return + } + + // 2. 密钥管理非交互指令 + switch arg1 { + case "list": + handleListKeystores() + return + case "create": + if len(os.Args) < 3 { + fmt.Println(shell.Red("用法: keys create <密钥名>")) + os.Exit(1) + } + handleCreateKeystore(os.Args[2]) + return + case "remove": + if len(os.Args) < 3 { + fmt.Println(shell.Red("用法: keys remove <密钥名>")) + os.Exit(1) + } + handleRemoveKeystore(os.Args[2]) + return + case "export": + if len(os.Args) < 4 { + fmt.Println(shell.Red("用法: keys export <密钥名> <语言>")) + os.Exit(1) + } + handleExportKeystore(os.Args[2], os.Args[3]) + return + } + + // 3. 密码管理指令 (keys [cmd]) + keyName := arg1 + // 检查 keyName 是否存在于 keystore + if keyName != "default" { + ksPath := filepath.Join(lib.GetKeystorePath(), keyName) + if _, err := os.Stat(ksPath); err != nil { + fmt.Printf("%s: 密钥 '%s' 不存在。\n", shell.Red("错误"), keyName) + fmt.Println("使用 'keys list' 查看可用密钥。") + os.Exit(1) + } + } + + if len(os.Args) == 2 { + // 进入该密钥的交互式密码管理模式 + interactivePasswordMode(keyName) + return + } + + // 非交互式密码管理 + handlePasswordCmd(keyName, os.Args[2:]) +} + +func setPath(newPath string) { + homeDir, err := os.UserHomeDir() + if err != nil { + fmt.Println(shell.Red("无法获取用户主目录: " + err.Error())) + os.Exit(1) + } + err = os.WriteFile(filepath.Join(homeDir, ".keyspath"), []byte(newPath), 0600) + if err != nil { + fmt.Println(shell.Red("保存 .keyspath 失败: " + err.Error())) + os.Exit(1) + } + fmt.Println(shell.Green("存储路径已更新为: " + newPath)) +} + +func showStatus() { + root, ksCount, pwCount := lib.GetStatus() + fmt.Print(shell.Cyan("Keys 系统状态:\n")) + fmt.Printf(" 存储根目录: %s\n", shell.White(root)) + fmt.Printf(" 密钥总数: %d\n", ksCount) + fmt.Printf(" 密码总数: %d\n", pwCount) +} + +func handleListKeystores() { + fmt.Println(shell.Cyan("可用密钥列表:")) + fmt.Printf(" - %s %s\n", shell.White("default"), shell.Yellow("(内置默认)")) + + files, err := os.ReadDir(lib.GetKeystorePath()) + if err != nil { + fmt.Printf("共 1 个密钥\n") + return + } + count := 1 + for _, fi := range files { + if !fi.IsDir() && !strings.HasPrefix(fi.Name(), ".") { + fmt.Printf(" - %s\n", shell.White(fi.Name())) + count++ + } + } + fmt.Printf("共 %d 个密钥\n", count) +} + +func handleCreateKeystore(name string) { + pwd := getMasterPassword() + defer safe.ZeroMemory(pwd) + ks, err := lib.CreateKeystore(name, pwd) + if err != nil { + fmt.Println(shell.Red("创建失败: " + err.Error())) + os.Exit(1) + } + ks.Close() + fmt.Println(shell.Green("密钥 '" + name + "' 创建成功。")) +} + +func handleRemoveKeystore(name string) { + path := filepath.Join(lib.GetKeystorePath(), name) + if err := os.Remove(path); err != nil { + fmt.Println(shell.Red("删除失败: " + err.Error())) + os.Exit(1) + } + fmt.Println(shell.Green("密钥 '" + name + "' 已删除。")) +} + +func handleExportKeystore(name, lang string) { + var pwd []byte + if name != "default" { + pwd = getMasterPassword() + defer safe.ZeroMemory(pwd) + } + ks, err := lib.LoadKeystore(name, pwd) + if err != nil { + fmt.Println(shell.Red("加载失败: " + err.Error())) + os.Exit(1) + } + defer ks.Close() + key, iv, err := ks.GetRaw() + if err != nil { + fmt.Println(shell.Red("提取失败: " + err.Error())) + os.Exit(1) + } + code, err := lib.MakeCode(lang, key, iv) + if err != nil { + fmt.Println(shell.Red("导出失败: " + err.Error())) + os.Exit(1) + } + fmt.Println(code) +} + +func handlePasswordCmd(keyName string, args []string) { + op := args[0] + + // 下面这些操作通常需要解密 Key,所以统一获取 master password + var pwd []byte + if keyName != "default" { + pwd = getMasterPassword() + defer safe.ZeroMemory(pwd) + } + + ks, err := lib.LoadKeystore(keyName, pwd) + if err != nil { + // --- 🛡️ 诱饵模式: 就算 LoadKeystore 返回随机 Key,这里也会继续运行 --- + fmt.Println(shell.Red("密钥解锁失败: " + err.Error())) + os.Exit(1) + } + defer ks.Close() + + key, iv, err := ks.GetRaw() + if err != nil { + fmt.Println(shell.Red("Key 提取失败: " + err.Error())) + os.Exit(1) + } + + algo := "aes-gcm" + target := "" + var pwdVal []byte + + // 提取参数中的算法和目标 + for _, a := range args[1:] { + if strings.HasPrefix(a, "-") { + algo = a[1:] + } else { + if target == "" { + target = a + } else { + pwdVal = []byte(a) + } + } + } + + switch { + case op == "list": + pwds, _ := lib.ListPasswords(keyName) + fmt.Printf("%s 保护的密码项:\n", shell.Cyan("'"+keyName+"'")) + for _, p := range pwds { + fmt.Println(" - " + p) + } + case op == "create": + if target == "" || len(pwdVal) == 0 { + fmt.Println(shell.Red("用法: keys <密钥名> create <路径> <值> [-算法]")) + os.Exit(1) + } + if err := lib.SavePassword(keyName, target, pwdVal, key, iv, algo); err != nil { + fmt.Println(shell.Red("保存失败: " + err.Error())) + os.Exit(1) + } + fmt.Printf("%s (算法: %s)。\n", shell.Green("保存成功"), algo) + case op == "remove": + if target == "" { + fmt.Println(shell.Red("用法: keys <密钥名> remove <路径>")) + os.Exit(1) + } + if err := lib.RemovePassword(keyName, target); err != nil { + fmt.Println(shell.Red("删除失败: " + err.Error())) + os.Exit(1) + } + fmt.Println(shell.Green("删除成功。")) + case op == "encrypt": + if target == "" { + fmt.Println(shell.Red("用法: keys <密钥名> encrypt <路径|原文> [-算法]")) + os.Exit(1) + } + + // 尝试作为已有项加载 + data, err := lib.LoadPassword(keyName, target, key, iv, "aes-gcm") + if err != nil || len(data) == 0 { + data = []byte(target) + } + + sym, err := ks.NewSymmetric(algo) + if err != nil { + fmt.Println(shell.Red("算法初始化失败: " + err.Error())) + os.Exit(1) + } + enc, _ := sym.EncryptBytes(data) + fmt.Println(encoding.UrlBase64ToString(enc)) + case op == "decrypt": + if target == "" { + fmt.Println(shell.Red("用法: keys <密钥名> decrypt <路径|密文> [-算法]")) + os.Exit(1) + } + + dec, err := lib.LoadPassword(keyName, target, key, iv, algo) + if err != nil || len(dec) == 0 { + // 尝试作为原始密文解密 + rawData, err2 := encoding.UnUrlBase64FromString(target) + if err2 == nil { + sym, err3 := ks.NewSymmetric(algo) + if err3 == nil { + dec2, err4 := sym.DecryptBytes(rawData) + if err4 == nil { + fmt.Println(string(dec2)) + return + } + } + } + // 🛡️ 诱饵模式: 完全失败打印随机字符 + fmt.Println(string(lib.CryptoRandDecoy(12, 24))) + return + } + fmt.Println(string(dec)) + default: + fmt.Printf("%s: %s\n", shell.Red("未知密码指令"), op) + } +} + +func getMasterPassword() []byte { + if p := os.Getenv("MASTER_PASSWORD"); p != "" { + return []byte(p) + } + fmt.Print("输入主密码: ") + fd := int(os.Stdin.Fd()) + password, err := term.ReadPassword(fd) + fmt.Println() + if err != nil || len(password) == 0 { + fmt.Println(shell.Red("必须输入密码")) + os.Exit(1) + } + return password +} + +func printUsage() { + fmt.Println(shell.Cyan("Keys CLI - 现代化本地 KMS 与密码管理器")) + fmt.Println(` +用法: + keys 启动交互式密钥管理 (Keystore Mode) + keys <密钥名> 启动该密钥对应的交互式密码管理 (Password Mode) + +管理密钥 (非交互): + keys list 列出所有密钥 + keys create <名> 创建新密钥 + keys remove <名> 删除密钥 + keys export <名> <语言> 导出混淆代码 (go, php, java, python, js) + +管理密码 (非交互): + keys <密钥名> list 列出该密钥下的所有密码项 + keys <密钥名> create <项> <值> [-算法] 创建/更新项 (gcm/cbc/sm4/sm4-cbc) + keys <密钥名> remove <项> 删除密码项 + keys <密钥名> encrypt <项|原文> [-算法] 加密一段文本 + keys <密钥名> decrypt <项|密文> [-算法] 解密并显示 + +系统指令: + keys -status 查看存储路径与统计状态 + keys -setpath <路径> 重定向存储根目录 (持久化到 ~/.keyspath) + keys -h, --help 显示此帮助信息 + +环境变量: + KEYSPATH 临时指定存储路径 + MASTER_PASSWORD 自动化脚本中传递主密码`) +} diff --git a/repl.go b/repl.go new file mode 100644 index 0000000..b6483ee --- /dev/null +++ b/repl.go @@ -0,0 +1,565 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/c-bata/go-prompt" + "golang.org/x/term" + "apigo.cc/go/crypto" + "apigo.cc/go/crypto-sm" + "apigo.cc/go/encoding" + "apigo.cc/go/keys/lib" + "apigo.cc/go/safe" + "apigo.cc/go/shell" +) + +var masterPwd []byte + +func interactiveKeystoreMode() { + masterPwd = getMasterPassword() + + fmt.Println(shell.Cyan("Keys CLI - Keystore Mode")) + fmt.Println(shell.White("Use Tab or Arrows for auto-completion. Type 'exit' to quit.")) + + p := prompt.New( + executorKeystore, + completerKeystore, + prompt.OptionPrefix("keys [keystore]> "), + prompt.OptionTitle("Keys CLI"), + prompt.OptionCompletionOnDown(), + ) + p.Run() + safe.ZeroMemory(masterPwd) +} + +func executorKeystore(in string) { + in = strings.TrimSpace(in) + if in == "" { + return + } + + args := strings.Fields(in) + cmd := args[0] + + switch cmd { + case "exit", "quit": + safe.ZeroMemory(masterPwd) + os.Exit(0) + case "list": + handleListKeystores() + case "create": + if len(args) < 2 { + fmt.Println(shell.Red("Usage: create ")) + return + } + ks, err := lib.CreateKeystore(args[1], masterPwd) + if err != nil { + fmt.Println(shell.Red("Failed to create keystore: " + err.Error())) + return + } + ks.Close() + fmt.Println(shell.Green("Keystore created successfully.")) + case "remove": + if len(args) < 2 { + fmt.Println(shell.Red("Usage: remove ")) + return + } + filePath := filepath.Join(lib.GetKeystorePath(), args[1]) + if err := os.Remove(filePath); err != nil { + fmt.Println(shell.Red("Failed to remove keystore: " + err.Error())) + return + } + fmt.Println(shell.Green("Keystore removed successfully.")) + case "export": + if len(args) < 3 { + fmt.Println(shell.Red("Usage: export ")) + return + } + var pwd []byte + if args[1] != "default" { + pwd = masterPwd + } + ks, err := lib.LoadKeystore(args[1], pwd) + if err != nil { + fmt.Println(shell.Red("Failed to load keystore: " + err.Error())) + return + } + defer ks.Close() + + key, iv, err := ks.GetRaw() + if err != nil { + fmt.Println(shell.Red("Failed to extract raw key/iv: " + err.Error())) + return + } + + code, err := lib.MakeCode(args[2], key, iv) + if err != nil { + fmt.Println(shell.Red("Failed to export code: " + err.Error())) + return + } + fmt.Println(code) + case "use": + if len(args) < 2 { + fmt.Println(shell.Red("Usage: use ")) + return + } + keyName := args[1] + var pwd []byte + if keyName != "default" { + pwd = masterPwd + } + ks, err := lib.LoadKeystore(keyName, pwd) + if err != nil { + fmt.Println(shell.Red("Failed to load keystore: " + err.Error())) + return + } + + key, iv, err := ks.GetRaw() + ks.Close() + + if err != nil { + fmt.Println(shell.Red("Failed to extract raw key/iv: " + err.Error())) + return + } + + safe.ZeroMemory(masterPwd) // Erase master password from memory + startPasswordModeLoop(keyName, key, iv) + os.Exit(0) // Exit when password mode finishes + + default: + fmt.Println(shell.Red("Unknown command. Try: list, create, remove, export, use, exit")) + } +} + +func completerKeystore(d prompt.Document) []prompt.Suggest { + text := d.TextBeforeCursor() + args := strings.Split(text, " ") + w := d.GetWordBeforeCursor() + + if len(args) <= 1 { + s := []prompt.Suggest{ + {Text: "list", Description: "列出所有密钥文件"}, + {Text: "create", Description: "[密钥名] 创建新的主密钥"}, + {Text: "remove", Description: "[密钥名] 永久删除指定密钥"}, + {Text: "export", Description: "[密钥名] [语言] 导出应用集成混淆代码"}, + {Text: "use", Description: "[密钥名] 解锁并进入该密钥的密码管理模式"}, + {Text: "exit", Description: "安全退出"}, + } + return prompt.FilterHasPrefix(s, w, true) + } + + cmd := args[0] + + getKeystores := func() []prompt.Suggest { + keyPath := lib.GetKeystorePath() + files, _ := os.ReadDir(keyPath) + var ks []prompt.Suggest + ks = append(ks, prompt.Suggest{Text: "default", Description: "内置基础设施默认密钥 (无需密码)"}) + for _, fi := range files { + if !fi.IsDir() && !strings.HasPrefix(fi.Name(), ".") { + ks = append(ks, prompt.Suggest{Text: fi.Name(), Description: "密钥文件"}) + } + } + return ks + } + + switch cmd { + case "remove", "use": + if len(args) == 2 { + suggestions := getKeystores() + if len(suggestions) == 0 { + return []prompt.Suggest{{Text: "", Description: "请先创建密钥"}} + } + return prompt.FilterHasPrefix(suggestions, w, true) + } + case "create": + if len(args) == 2 && w == "" { + return []prompt.Suggest{{Text: "", Description: "<请输入新密钥名称>"}} + } + case "export": + if len(args) == 2 { + return prompt.FilterHasPrefix(getKeystores(), w, true) + } + if len(args) == 3 { + s := []prompt.Suggest{ + {Text: "go", Description: "导出 Go 混淆代码"}, + {Text: "php", Description: "导出 PHP 混淆代码"}, + {Text: "java", Description: "导出 Java 混淆代码"}, + {Text: "python", Description: "导出 Python 混淆代码"}, + {Text: "js", Description: "导出 JavaScript 混淆代码"}, + } + return prompt.FilterHasPrefix(s, w, true) + } + } + + return []prompt.Suggest{} +} + +func interactivePasswordMode(keyName string) { + var pwd []byte + if keyName != "default" { + pwd = getMasterPassword() + } + ks, err := lib.LoadKeystore(keyName, pwd) + if err != nil { + fmt.Println(shell.Red("Failed to load keystore: " + err.Error())) + os.Exit(1) + } + key, iv, err := ks.GetRaw() + ks.Close() + if len(pwd) > 0 { + safe.ZeroMemory(pwd) + } + + if err != nil { + fmt.Println(shell.Red("Failed to extract raw key/iv: " + err.Error())) + os.Exit(1) + } + + startPasswordModeLoop(keyName, key, iv) +} + +var activeKeyName string +var activeKey []byte +var activeIV []byte + +func startPasswordModeLoop(keyName string, key, iv []byte) { + activeKeyName = keyName + activeKey = key + activeIV = iv + + fmt.Println(shell.Cyan("Keys CLI - Password Mode for [" + keyName + "]")) + + p := prompt.New( + executorPassword, + completerPassword, + prompt.OptionPrefix("keys ["+keyName+"]> "), + prompt.OptionTitle("Keys CLI - "+keyName), + prompt.OptionCompletionOnDown(), + ) + p.Run() + + safe.ZeroMemory(activeKey) + safe.ZeroMemory(activeIV) +} + +func executorPassword(in string) { + in = strings.TrimSpace(in) + if in == "" { + return + } + + args := strings.Fields(in) + cmd := args[0] + + switch cmd { + case "exit", "quit": + safe.ZeroMemory(activeKey) + safe.ZeroMemory(activeIV) + os.Exit(0) + case "list": + pwds, err := lib.ListPasswords(activeKeyName) + if err != nil { + fmt.Println(shell.Red("Failed to list passwords: " + err.Error())) + return + } + if len(pwds) == 0 { + fmt.Println(shell.Yellow("No passwords found.")) + return + } + for _, p := range pwds { + fmt.Println(" - " + shell.White(p)) + } + case "create": + if len(args) < 2 { + fmt.Println(shell.Red("用法: create <路径> [-gcm|-cbc|-sm4|-sm4-cbc]")) + return + } + pwdPath := args[1] + algo := "aes-gcm" + for _, a := range args[2:] { + if strings.HasPrefix(a, "-") { + algo = a[1:] + } + } + + fmt.Print("Enter password value: ") + fd := int(os.Stdin.Fd()) + pwdBytes, err := term.ReadPassword(fd) + fmt.Println() + if err != nil || len(pwdBytes) == 0 { + fmt.Println(shell.Red("Password value is required")) + return + } + + if err := lib.SavePassword(activeKeyName, pwdPath, pwdBytes, activeKey, activeIV, algo); err != nil { + fmt.Println(shell.Red("Failed to save password: " + err.Error())) + return + } + fmt.Println(shell.Green("Password saved successfully using " + algo)) + case "remove": + if len(args) < 2 { + fmt.Println(shell.Red("Usage: remove ")) + return + } + pwdName := args[1] + if err := lib.RemovePassword(activeKeyName, pwdName); err != nil { + fmt.Println(shell.Red("Failed to remove password: " + err.Error())) + return + } + fmt.Println(shell.Green("Password removed successfully.")) + case "encrypt": + var rawText []byte + algo := "aes-gcm" + target := "" + + for _, a := range args[1:] { + if strings.HasPrefix(a, "-") { + algo = a[1:] + } else { + target = a + } + } + + if target != "" { + // Encrypt an existing password item + dec, err := lib.LoadPassword(activeKeyName, target, activeKey, activeIV, "aes-gcm") // Assume stored is GCM + if err != nil { + fmt.Println(shell.Red("Failed to load password: " + err.Error())) + return + } + rawText = dec + } else { + // Encrypt custom text + fmt.Print("Enter text to encrypt: ") + fd := int(os.Stdin.Fd()) + txtBytes, err := term.ReadPassword(fd) + fmt.Println() + if err != nil || len(txtBytes) == 0 { + fmt.Println(shell.Red("Text is required")) + return + } + rawText = txtBytes + } + + encData, err := encryptEphemeral(activeKey, activeIV, algo, rawText) + if err != nil { + fmt.Println(shell.Red("Encryption failed: " + err.Error())) + return + } + fmt.Println(shell.Yellow(encoding.UrlBase64ToString(encData))) + + case "decrypt": + if len(args) < 2 { + fmt.Println(shell.Red("用法: decrypt <路径|密文> [-算法]")) + return + } + + target := "" + algo := "aes-gcm" + for _, a := range args[1:] { + if strings.HasPrefix(a, "-") { + algo = a[1:] + } else { + target = a + } + } + + dec, err := lib.LoadPassword(activeKeyName, target, activeKey, activeIV, algo) + if err != nil || len(dec) == 0 { + // Try as raw ciphertext + rawData, err2 := encoding.UnUrlBase64FromString(target) + if err2 == nil { + dec2, err3 := decryptEphemeral(activeKey, activeIV, algo, rawData) + if err3 == nil { + dec = dec2 + } else { + // Silent failure: return random decoy + dec = lib.CryptoRandDecoy(12, 24) + } + } else { + fmt.Println(shell.Red("Failed to decrypt: invalid path or Base64.")) + return + } + } + printSecretAndErase(string(dec), 10) + case "back": + // Return to keystore mode + safe.ZeroMemory(activeKey) + safe.ZeroMemory(activeIV) + fmt.Println(shell.Yellow("Returning to keystore mode...")) + interactiveKeystoreMode() + os.Exit(0) + default: + fmt.Println(shell.Red("Unknown command. Try: list, create, remove, encrypt, decrypt, back, exit")) + } +} + +func completerPassword(d prompt.Document) []prompt.Suggest { + text := d.TextBeforeCursor() + args := strings.Split(text, " ") + w := d.GetWordBeforeCursor() + + if len(args) <= 1 { + s := []prompt.Suggest{ + {Text: "list", Description: "列出该密钥下的所有密码项"}, + {Text: "create", Description: "[路径] [-算法] 创建新密码项"}, + {Text: "remove", Description: "[路径] 删除指定密码项"}, + {Text: "encrypt", Description: "[路径|空] [-算法] 加密 (不回显)"}, + {Text: "decrypt", Description: "[路径|密文] [-算法] 解密查看 (10s 阅后即焚)"}, + {Text: "back", Description: "锁定当前密钥,返回密钥管理"}, + {Text: "exit", Description: "安全退出"}, + } + return prompt.FilterHasPrefix(s, w, true) + } + + cmd := args[0] + + getPasswords := func() []prompt.Suggest { + pwds, _ := lib.ListPasswords(activeKeyName) + var ds []prompt.Suggest + for _, p := range pwds { + ds = append(ds, prompt.Suggest{Text: p, Description: "密码项"}) + } + return ds + } + + getAlgos := func() []prompt.Suggest { + return []prompt.Suggest{ + {Text: "-gcm", Description: "AES-GCM (默认)"}, + {Text: "-cbc", Description: "AES-CBC"}, + {Text: "-sm4", Description: "SM4-GCM"}, + {Text: "-sm4-cbc", Description: "SM4-CBC"}, + } + } + + switch cmd { + case "remove", "decrypt", "encrypt": + if len(args) == 2 { + suggestions := getPasswords() + if strings.HasPrefix(w, "-") { + return prompt.FilterHasPrefix(getAlgos(), w, true) + } + return prompt.FilterHasPrefix(suggestions, w, true) + } + if len(args) >= 3 && strings.HasPrefix(w, "-") { + return prompt.FilterHasPrefix(getAlgos(), w, true) + } + case "create": + if len(args) == 2 { + if strings.HasPrefix(w, "-") { + return prompt.FilterHasPrefix(getAlgos(), w, true) + } + if w == "" { + return []prompt.Suggest{{Text: "", Description: "<请输入路径,如 mail/user@apigo.cc>"}} + } + } + if len(args) >= 3 && strings.HasPrefix(w, "-") { + return prompt.FilterHasPrefix(getAlgos(), w, true) + } + } + + return []prompt.Suggest{} +} + +func encryptEphemeral(key, iv []byte, algo string, data []byte) ([]byte, error) { + var sym *crypto.Symmetric + var err error + + k := append([]byte(nil), key...) + v := append([]byte(nil), iv...) + defer safe.ZeroMemory(k) + defer safe.ZeroMemory(v) + + switch algo { + case "aes-gcm": + sym, err = crypto.NewAESGCMWithoutEraseKey(k, v) + case "aes-cbc": + sym, err = crypto.NewAESCBCWithoutEraseKey(k, append(v, 0, 0, 0, 0)) + case "sm4-gcm": + sym, err = sm.NewSM4GCMWithoutEraseKey(k, v) + case "sm4-cbc": + sym, err = sm.NewSM4CBCWithoutEraseKey(k, append(v, 0, 0, 0, 0)) + default: + return nil, fmt.Errorf("unknown algorithm: %s", algo) + } + + if err != nil { + return nil, err + } + return sym.EncryptBytes(data) +} + +func decryptEphemeral(key, iv []byte, algo string, data []byte) ([]byte, error) { + var sym *crypto.Symmetric + var err error + + k := append([]byte(nil), key...) + v := append([]byte(nil), iv...) + defer safe.ZeroMemory(k) + defer safe.ZeroMemory(v) + + switch algo { + case "aes-gcm": + sym, err = crypto.NewAESGCMWithoutEraseKey(k, v) + case "aes-cbc": + sym, err = crypto.NewAESCBCWithoutEraseKey(k, append(v, 0, 0, 0, 0)) + case "sm4-gcm": + sym, err = sm.NewSM4GCMWithoutEraseKey(k, v) + case "sm4-cbc": + sym, err = sm.NewSM4CBCWithoutEraseKey(k, append(v, 0, 0, 0, 0)) + default: + return nil, fmt.Errorf("unknown algorithm: %s", algo) + } + + if err != nil { + return nil, err + } + return sym.DecryptBytes(data) +} + +func printSecretAndErase(secret string, seconds int) { + fmt.Printf("%s: %s\n", shell.Cyan("解密结果"), shell.Yellow(secret)) + fmt.Printf("%s %s %s\n", shell.White("倒计时自动擦除,或按"), shell.Red("[ESC]"), shell.White("立即擦除")) + + cancel := make(chan struct{}, 1) + go func() { + oldState, err := term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + return + } + defer term.Restore(int(os.Stdin.Fd()), oldState) + + b := make([]byte, 1) + for { + _, err := os.Stdin.Read(b) + if err != nil { + break + } + if b[0] == 27 { // ESC + cancel <- struct{}{} + break + } + } + }() + +loop: + for i := seconds; i > 0; i-- { + select { + case <-cancel: + break loop + default: + fmt.Printf("\r%s %s ", shell.White("阅后即焚倒计时:"), shell.Red(fmt.Sprintf("%ds", i))) + time.Sleep(1 * time.Second) + } + } + + fmt.Print("\r\033[2K") + fmt.Print("\033[1A\033[2K") + fmt.Print("\r\033[1A\033[2K") + fmt.Println(shell.Green("内容已安全擦除。")) +}