fix(security): jwt.key 移出可浏览工作区(存项目根,自动从 data 迁移)

工作空间文件管理器的根目录是 dataDir(s.m.dir),而 jwt.key 原来就存在
dataDir/jwt.key —— 会被列出/下载(签名密钥泄露→可伪造 token)/删除(重启重生
成→全体登出)。

- loadOrCreateJWTKey(keyDir, dataDir):密钥改存 keyDir/jwt.key;若旧 data/jwt.key
  仍在,启动时迁移(保留密钥不登出、删旧文件从工作区消失)
- New() 增加 keyDir 参数;生产传 config.BaseDir()(项目根),在 dataDir 之外,
  wsResolve 的路径限定使其不可列/读/下载/删
- 测试传临时目录,隔离且不触发迁移;.gitignore 忽略根目录 /jwt.key

注:项目根在 Docker 里不持久化,容器重建会重新生成密钥、需重新登录(单人系统可接受)。
This commit is contained in:
Autumn-27 committed 2026-08-11 10:56:39 -04:00
1 parent 1bc629ce4c
commit 289cf6a24f
6 files changed
+28 -9

No files matched your search

+3
View File
@@ -39,5 +39,8 @@ Thumbs.db
/server/webui/dist/ /server/webui/dist/
/dist/ /dist/
# JWT 签名密钥(运行时生成,存项目根,绝不提交)
/jwt.key
# Docker 部署生成的本地环境(含密码/密钥,不提交) # Docker 部署生成的本地环境(含密码/密钥,不提交)
.env .env
+1 -1
View File
@@ -80,7 +80,7 @@ func main() {
skillDir = abs skillDir = abs
} }
log.Printf("[config] skill 目录: %s", skillDir) log.Printf("[config] skill 目录: %s", skillDir)
srv := server.New(ctx, mgr, skillDir, *dataDir) srv := server.New(ctx, mgr, skillDir, *dataDir, config.BaseDir())
httpSrv := &http.Server{ httpSrv := &http.Server{
Addr: *addr, Addr: *addr,
Handler: srv.Handler(), Handler: srv.Handler(),
+20 -4
View File
@@ -22,10 +22,26 @@ const (
keyChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" keyChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
) )
// loadOrCreateJWTKey reads the 32-byte signing key from dataDir/jwt.key. // loadOrCreateJWTKey reads the 32-byte signing key from keyDir/jwt.key. keyDir is
// On first run it generates a cryptographically random key and persists it. // the project base dir (next to the executable), NOT the browsable workspace root
func loadOrCreateJWTKey(dataDir string) ([]byte, error) { // (dataDir) — the signing key must never be listable/downloadable via the file
path := filepath.Join(dataDir, jwtKeyFilename) // manager. Legacy installs kept it at dataDir/jwt.key; if present there and not yet
// at the new location, it is migrated (key preserved, so sessions stay valid) and
// the old file removed so it disappears from the workspace. On first run a random
// key is generated and persisted.
func loadOrCreateJWTKey(keyDir, dataDir string) ([]byte, error) {
path := filepath.Join(keyDir, jwtKeyFilename)
// one-time migration out of the old in-workspace location.
if legacy := filepath.Join(dataDir, jwtKeyFilename); legacy != path {
if _, err := os.Stat(path); os.IsNotExist(err) {
if data, rerr := os.ReadFile(legacy); rerr == nil {
if werr := os.WriteFile(path, data, 0o600); werr == nil {
_ = os.Remove(legacy)
log.Printf("[auth] JWT key 已从 %s 迁移到 %s(移出可浏览工作区)", legacy, path)
}
}
}
}
if data, err := os.ReadFile(path); err == nil && len(strings.TrimSpace(string(data))) >= 32 { if data, err := os.ReadFile(path); err == nil && len(strings.TrimSpace(string(data))) >= 32 {
return []byte(strings.TrimSpace(string(data))), nil return []byte(strings.TrimSpace(string(data))), nil
} }
+1 -1
View File
@@ -22,7 +22,7 @@ func TestCoreTaskLifecyclePG(t *testing.T) {
} }
defer m.Close() defer m.Close()
td := t.TempDir() td := t.TempDir()
s := New(context.Background(), m, td, td) s := New(context.Background(), m, td, td, td)
h := s.Handler() h := s.Handler()
tok, err := signJWT(s.jwtKey) tok, err := signJWT(s.jwtKey)
if err != nil { if err != nil {
+1 -1
View File
@@ -19,7 +19,7 @@ func TestMgmtAPI(t *testing.T) {
t.Skip("postgres unavailable — skipping management API test") t.Skip("postgres unavailable — skipping management API test")
} }
td := t.TempDir() td := t.TempDir()
s := New(context.Background(), m, td, td) s := New(context.Background(), m, td, td, td)
h := s.Handler() h := s.Handler()
tok, err := signJWT(s.jwtKey) tok, err := signJWT(s.jwtKey)
if err != nil { if err != nil {
+2 -2
View File
@@ -97,8 +97,8 @@ type triggeredRun struct {
mergeable bool // true for finding/goal event triggers (merge by taskID) mergeable bool // true for finding/goal event triggers (merge by taskID)
} }
func New(ctx context.Context, m *Manager, skillDir string, dataDir string) *Server { func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDir string) *Server {
key, err := loadOrCreateJWTKey(dataDir) key, err := loadOrCreateJWTKey(keyDir, dataDir)
if err != nil { if err != nil {
log.Fatalf("[auth] JWT key: %v", err) log.Fatalf("[auth] JWT key: %v", err)
} }