chore: initial commit (keys)

This commit is contained in:
AI Engineer 2026-05-10 15:53:01 +08:00
commit 76c8d18f7f
16 changed files with 2143 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.geminiignore

38
CHANGELOG.md Normal file
View File

@ -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/`
- 强制执行校验位检查,防止非法密钥加载。
- 导出代码强制执行明文即时擦除策略。

68
README.md Normal file
View File

@ -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 <keyname> 启动交互式密码管理模式 (Password Manager)
# 非交互模式 (自动化测试与脚本友好,便于 CI/CD 和自动化流)
keys cmd keystore list
keys cmd keystore create <keyname>
keys cmd keystore remove <keyname>
keys cmd keystore export <keyname> <filepath.ext>
keys cmd password <keyname> list
keys cmd password <keyname> create <pwd.path.name> <value>
keys cmd password <keyname> remove <pwd.path.name>
keys cmd password <keyname> encrypt <pwd.path.name | raw_text> (不回显输入)
keys cmd password <keyname> decrypt <pwd.path.name | ciphertext>
环境变量:
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...) # 倒计时结束当前行被清除
```

41
TEST.md Normal file
View File

@ -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` 防止密钥泄露至磁盘。

26
go.mod Normal file
View File

@ -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
)

44
go.sum Normal file
View File

@ -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=

BIN
keys Executable file

Binary file not shown.

179
lib/MakeCode.go Normal file
View File

@ -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 = `<?php
$__sskeyStarter = function () {
if(!function_exists('set_sskey')) return;
$buf = [{{range $i, $v := .buf}}{{if $i}},{{end}}{{$v}}{{end}}];
$indicator = $buf[127];
$ptr1Index = ($indicator % 2 == 0) ? 126 : 125;
$offsetK = $buf[$ptr1Index];
$key = array_slice($buf, $offsetK, 32);
$offsetIV = $buf[$offsetK + 32];
$iv = array_slice($buf, $offsetIV, 12);
set_sskey($key, $iv);
};
$__sskeyStarter();
unset($__sskeyStarter);
`
var javaTpl = `
import java.lang.reflect.Method;
import java.util.Arrays;
public final class SSKeyStarter {
private static boolean inited = false;
public static void init() {
if (inited) return;
inited = true;
byte[] buf = new byte[]{ {{range $i, $v := .buf}}{{if $i}},{{end}}(byte){{$v}}{{end}} };
int indicator = buf[127] & 0xFF;
int ptr1Index = (indicator % 2 == 0) ? 126 : 125;
int offsetK = buf[ptr1Index] & 0xFF;
byte[] key = Arrays.copyOfRange(buf, offsetK, offsetK + 32);
int offsetIV = buf[offsetK + 32] & 0xFF;
byte[] iv = Arrays.copyOfRange(buf, offsetIV, offsetIV + 12);
try {
Class<?> 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
}

237
lib/keystore.go Normal file
View File

@ -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),
}
}

89
lib/keystore_test.go Normal file
View File

@ -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()
}

184
lib/obfuscate.go Normal file
View File

@ -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
}

64
lib/obfuscate_test.go Normal file
View File

@ -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)
}
}

184
lib/password.go Normal file
View File

@ -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
}

82
lib/path.go Normal file
View File

@ -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
}

341
main.go Normal file
View File

@ -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 <keyname> [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 自动化脚本中传递主密码`)
}

565
repl.go Normal file
View File

@ -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 <keyname>"))
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 <keyname>"))
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 <keyname> <lang>"))
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 <keyname>"))
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 <pwd.path.name>"))
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("内容已安全擦除。"))
}