diff --git a/backend/controllers/recovery_context.go b/backend/controllers/recovery_context.go new file mode 100644 index 00000000..a9463fcb --- /dev/null +++ b/backend/controllers/recovery_context.go @@ -0,0 +1,14 @@ +package controllers + +import ( + "context" + "net/http" +) + +// recoveryRequestContext 保留请求的取消信号;兼容无 HTTP 请求的内部调用。 +func recoveryRequestContext(req *http.Request) context.Context { + if req == nil { + return context.Background() + } + return req.Context() +} diff --git a/backend/controllers/recovery_context_test.go b/backend/controllers/recovery_context_test.go new file mode 100644 index 00000000..2bac9b57 --- /dev/null +++ b/backend/controllers/recovery_context_test.go @@ -0,0 +1,94 @@ +package controllers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strconv" + "strings" + "testing" + + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/dto/response" + "git.pcux.cn/xuans/Smail/internal/passwordtest" + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/access" + "git.pcux.cn/xuans/Smail/services/credentials" + appcontext "git.pcux.cn/xuans/Smail/utils/context" + "xorm.io/xorm" +) + +func TestRecoveryGenerationHandlersForwardRequestCancellation(t *testing.T) { + engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "cancel.db")) + if err != nil { + t.Fatal(err) + } + oldDB, oldSecurity := db.Instance, securityRuntime + t.Cleanup(func() { db.Instance = oldDB; securityRuntime = oldSecurity; _ = engine.Close() }) + if err := engine.Sync2(&models.User{}, &models.RecoveryCode{}); err != nil { + t.Fatal(err) + } + user := models.User{Account: "cancel-user", Role: string(access.RoleUser)} + if _, err := engine.Insert(&user); err != nil { + t.Fatal(err) + } + if _, err := engine.Insert(&models.RecoveryCode{UserID: user.ID, CodeHash: "original-hash", CodeVersion: 1}); err != nil { + t.Fatal(err) + } + service, err := credentials.NewService(engine) + if err != nil { + t.Fatal(err) + } + db.Instance = engine + ConfigureSecurityRuntime(nil, nil, nil, service) + actor := &appcontext.Context{UserID: user.ID, Role: access.RoleSuperAdmin, RecentAuth: true, RecoveryCodeConfigured: true} + for _, item := range []struct { + name string + handler func(*appcontext.Context, http.ResponseWriter, *http.Request) + }{{"self", GenerateRecoveryCodes}, {"managed", GenerateManagedRecoveryCodes}} { + t.Run(item.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + request := httptest.NewRequest(http.MethodPost, "/api/security", strings.NewReader(`{"user_id":`+strconv.Itoa(user.ID)+`,"count":10}`)).WithContext(ctx) + recorder := httptest.NewRecorder() + item.handler(actor, recorder, request) + var result response.Response + if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result.ErrorNo == 0 { + t.Fatal("已取消的请求返回了成功") + } + var rows []models.RecoveryCode + if err := engine.Find(&rows); err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].CodeHash != "original-hash" || rows[0].UsedAt != nil { + t.Fatal("已取消请求轮换了恢复码") + } + }) + } + // 资源繁忙必须返回可重试状态,不能把它当作凭据错误。 + release := passwordtest.Hold(t) + defer release() + request := httptest.NewRequest(http.MethodPost, "/api/security", strings.NewReader(`{"user_id":`+strconv.Itoa(user.ID)+`,"count":10}`)) + recorder := httptest.NewRecorder() + GenerateManagedRecoveryCodes(actor, recorder, request) + if recorder.Code != http.StatusServiceUnavailable || recorder.Header().Get("Retry-After") != "1" || !strings.Contains(recorder.Body.String(), "AUTH_BUSY") { + t.Fatal("管理恢复码入口没有传递可重试繁忙状态") + } +} + +func TestRecoveryRequestContextPreservesDeadline(t *testing.T) { + if recoveryRequestContext(nil).Err() != nil { + t.Fatal("无请求的兼容上下文不可用") + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + request := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx) + if recoveryRequestContext(request) != ctx { + t.Fatal("HTTP 上下文被替换") + } +} diff --git a/backend/controllers/security.go b/backend/controllers/security.go index 73f815e1..2e81915d 100644 --- a/backend/controllers/security.go +++ b/backend/controllers/security.go @@ -321,7 +321,7 @@ func GenerateRecoveryCodes(ctx *context.Context, w http.ResponseWriter, req *htt writeSecurityError(w, response.ServerError, "安全凭据服务不可用") return } - codes, err := service.GenerateRecoveryCodes(ctx.UserID, input.Count) + codes, err := service.GenerateRecoveryCodesContext(recoveryRequestContext(req), ctx.UserID, input.Count) if err != nil { if writePasswordBusy(w, err) { return diff --git a/backend/controllers/user_security.go b/backend/controllers/user_security.go index f0dfe142..f584619b 100644 --- a/backend/controllers/user_security.go +++ b/backend/controllers/user_security.go @@ -109,8 +109,11 @@ func GenerateManagedRecoveryCodes(ctx *context.Context, w http.ResponseWriter, r writeSecurityError(w, response.ServerError, "安全凭据服务不可用") return } - codes, err := service.GenerateRecoveryCodes(target.ID, input.Count) + codes, err := service.GenerateRecoveryCodesContext(recoveryRequestContext(req), target.ID, input.Count) if err != nil { + if writePasswordBusy(w, err) { + return + } log.WithContext(ctx).WithError(err).Error("generate managed recovery codes failed") writeSecurityError(w, response.ParamsError, "安全码生成失败") return diff --git a/backend/services/credentials/context_mutex.go b/backend/services/credentials/context_mutex.go new file mode 100644 index 00000000..645bb068 --- /dev/null +++ b/backend/services/credentials/context_mutex.go @@ -0,0 +1,42 @@ +package credentials + +import ( + "context" + "sync" +) + +// contextMutex 保留凭据写入的互斥顺序,同时允许等待中的请求及时取消。 +// 零值可用,初始化后不得复制。 +type contextMutex struct { + once sync.Once + slot chan struct{} +} + +func (m *contextMutex) LockContext(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + m.once.Do(func() { m.slot = make(chan struct{}, 1) }) + select { + case m.slot <- struct{}{}: + if err := ctx.Err(); err != nil { + m.Unlock() + return err + } + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (m *contextMutex) Lock() { + _ = m.LockContext(context.Background()) +} + +func (m *contextMutex) Unlock() { + select { + case <-m.slot: + default: + panic("unlock of unlocked credential mutex") + } +} diff --git a/backend/services/credentials/context_mutex_test.go b/backend/services/credentials/context_mutex_test.go new file mode 100644 index 00000000..49e7eeba --- /dev/null +++ b/backend/services/credentials/context_mutex_test.go @@ -0,0 +1,60 @@ +package credentials + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +func TestContextMutexKeepsMutualExclusion(t *testing.T) { + var mutex contextMutex + var group sync.WaitGroup + count := 0 + for i := 0; i < 12; i++ { + group.Add(1) + go func() { + defer group.Done() + for j := 0; j < 100; j++ { + mutex.Lock() + count++ + mutex.Unlock() + } + }() + } + group.Wait() + if count != 1200 { + t.Fatal("并发写入失去互斥保护") + } +} + +func TestContextMutexCancellationDoesNotLoseLock(t *testing.T) { + var mutex contextMutex + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + if !errors.Is(mutex.LockContext(cancelled), context.Canceled) { + t.Fatal("已取消请求获取了锁") + } + mutex.Lock() + ctx, cancelWait := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancelWait() + if !errors.Is(mutex.LockContext(ctx), context.DeadlineExceeded) { + t.Fatal("等待锁未响应截止时间") + } + mutex.Unlock() + if err := mutex.LockContext(context.Background()); err != nil { + t.Fatal(err) + } + mutex.Unlock() +} + +func TestContextMutexInvalidUnlockPanics(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("未持有锁的解锁没有报错") + } + }() + var mutex contextMutex + mutex.Unlock() +} diff --git a/backend/services/credentials/recovery_context_test.go b/backend/services/credentials/recovery_context_test.go new file mode 100644 index 00000000..6bbf75b0 --- /dev/null +++ b/backend/services/credentials/recovery_context_test.go @@ -0,0 +1,179 @@ +package credentials + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/utils/password" + "xorm.io/xorm/contexts" +) + +func seedRecoveryCode(t *testing.T, s *Service) { + t.Helper() + if _, err := s.engine.Insert(&models.RecoveryCode{UserID: 1, CodeHash: "preserved-hash", CodeVersion: 1}); err != nil { + t.Fatal(err) + } +} + +func assertRecoveryCodePreserved(t *testing.T, s *Service) { + t.Helper() + var rows []models.RecoveryCode + if err := s.engine.Find(&rows); err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].CodeHash != "preserved-hash" || rows[0].UsedAt != nil { + t.Fatal("取消或失败后旧恢复码发生变化") + } +} + +func TestRecoveryGenerationAlreadyCancelledDoesNotHash(t *testing.T) { + s := newFixture(t) + seedRecoveryCode(t, s) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + codes, err := s.generateRecoveryCodes(ctx, 1, 10, func(string) (string, error) { + t.Fatal("取消后不应执行密码计算") + return "", nil + }) + if codes != nil || !errors.Is(err, context.Canceled) { + t.Fatalf("取消结果错误:%v", err) + } + assertRecoveryCodePreserved(t, s) +} + +func TestRecoveryGenerationCancelledBetweenHashesPreservesOldCodes(t *testing.T) { + for _, cancelAt := range []int{1, 5, 10} { + t.Run(time.Duration(cancelAt).String(), func(t *testing.T) { + s := newFixture(t) + seedRecoveryCode(t, s) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + calls := 0 + codes, err := s.generateRecoveryCodes(ctx, 1, 10, func(string) (string, error) { + calls++ + if calls == cancelAt { + cancel() + } + return "test-hash", nil + }) + if codes != nil || !errors.Is(err, context.Canceled) || calls != cancelAt { + t.Fatalf("取消未停止后续计算:调用=%d,错误=%v", calls, err) + } + assertRecoveryCodePreserved(t, s) + }) + } +} + +func TestRecoveryGenerationCancelsWhileWaitingForCredentialLock(t *testing.T) { + s := newFixture(t) + seedRecoveryCode(t, s) + s.mu.Lock() + defer s.mu.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + started := time.Now() + codes, err := s.generateRecoveryCodes(ctx, 1, 10, func(string) (string, error) { return "test-hash", nil }) + if codes != nil || !errors.Is(err, context.DeadlineExceeded) || time.Since(started) > time.Second { + t.Fatalf("等待凭据锁时未及时取消:%v", err) + } + assertRecoveryCodePreserved(t, s) +} + +func TestRecoveryGenerationCancelsWhileWaitingForDatabase(t *testing.T) { + s := newFixture(t) + seedRecoveryCode(t, s) + s.engine.SetMaxOpenConns(1) + conn, err := s.engine.DB().DB.Conn(context.Background()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + codes, err := s.generateRecoveryCodes(ctx, 1, 10, func(string) (string, error) { return "test-hash", nil }) + _ = conn.Close() + if codes != nil || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("数据库等待未取消:%v", err) + } + assertRecoveryCodePreserved(t, s) +} + +type recoveryCancelHook struct { + cancel context.CancelFunc + index int + seen int +} + +func (h *recoveryCancelHook) BeforeProcess(c *contexts.ContextHook) (context.Context, error) { + return c.Ctx, nil +} + +func (h *recoveryCancelHook) AfterProcess(c *contexts.ContextHook) error { + if strings.HasPrefix(c.SQL, "INSERT") { + h.seen++ + if h.seen == h.index { + h.cancel() + } + } + return nil +} + +func TestRecoveryGenerationCancellationRollsBackRevocationAndInserts(t *testing.T) { + for _, cancelAt := range []int{1, 10} { + t.Run(time.Duration(cancelAt).String(), func(t *testing.T) { + s := newFixture(t) + seedRecoveryCode(t, s) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + hook := &recoveryCancelHook{cancel: cancel, index: cancelAt} + s.engine.AddHook(hook) + codes, err := s.generateRecoveryCodes(ctx, 1, 10, func(string) (string, error) { return "test-hash", nil }) + if codes != nil || err == nil || hook.seen != cancelAt { + t.Fatalf("事务取消失败:%v", err) + } + assertRecoveryCodePreserved(t, s) + }) + } +} + +func TestRecoveryGenerationHashFailurePreservesOldCodes(t *testing.T) { + s := newFixture(t) + seedRecoveryCode(t, s) + calls := 0 + codes, err := s.generateRecoveryCodes(context.Background(), 1, 10, func(string) (string, error) { + calls++ + if calls == 3 { + return "", password.ErrBusy + } + return "test-hash", nil + }) + if codes != nil || !errors.Is(err, password.ErrBusy) || calls != 3 { + t.Fatalf("计算失败未保留状态:%v", err) + } + assertRecoveryCodePreserved(t, s) +} + +func TestRecoveryGenerationContextKeepsTenStrongHashes(t *testing.T) { + s := newFixture(t) + codes, err := s.GenerateRecoveryCodesContext(context.Background(), 1, 10) + if err != nil || len(codes) != 10 { + t.Fatalf("十枚恢复码生成失败:%v", err) + } + var rows []models.RecoveryCode + if err := s.engine.Find(&rows); err != nil { + t.Fatal(err) + } + if len(rows) != 10 { + t.Fatal("恢复码数量不一致") + } + seen := make(map[string]bool) + for i, row := range rows { + if !strings.HasPrefix(row.CodeHash, "$argon2id$v=19$m=65536,t=3,p=1$") || row.UsedAt != nil || row.CodeVersion != 1 || seen[codes[i]] { + t.Fatal("密码参数、版本或随机性发生变化") + } + seen[codes[i]] = true + } +} diff --git a/backend/services/credentials/service.go b/backend/services/credentials/service.go index bf250222..10d8e588 100644 --- a/backend/services/credentials/service.go +++ b/backend/services/credentials/service.go @@ -1,16 +1,17 @@ package credentials import ( + "context" "crypto/rand" "encoding/base32" "errors" "fmt" "strings" - "sync" "time" "git.pcux.cn/xuans/Smail/models" "git.pcux.cn/xuans/Smail/utils/password" + log "github.com/sirupsen/logrus" "golang.org/x/text/unicode/norm" "xorm.io/xorm" ) @@ -34,7 +35,7 @@ const ( // Service 统一管理密保问题和离线恢复码。 type Service struct { engine *xorm.Engine - mu sync.Mutex + mu contextMutex now func() time.Time vaultRoot string } @@ -140,6 +141,16 @@ func (s *Service) VerifySecurityQuestion(userID int, answer string) (bool, error // GenerateRecoveryCodes 撤销旧码并生成一组只返回一次的离线恢复码。 func (s *Service) GenerateRecoveryCodes(userID int, count int) ([]string, error) { + return s.GenerateRecoveryCodesContext(context.Background(), userID, count) +} + +// GenerateRecoveryCodesContext 在请求取消时停止后续计算,并回滚尚未提交的恢复码轮换。 +// 单次 Argon2id 计算不可中断,因此每次计算前后检查取消,不降低原有密码强度。 +func (s *Service) GenerateRecoveryCodesContext(ctx context.Context, userID int, count int) ([]string, error) { + return s.generateRecoveryCodes(ctx, userID, count, password.Hash) +} + +func (s *Service) generateRecoveryCodes(ctx context.Context, userID int, count int, hashSecret func(string) (string, error)) (codes []string, resultErr error) { if s == nil || s.engine == nil { return nil, ErrUnavailable } @@ -152,45 +163,73 @@ func (s *Service) GenerateRecoveryCodes(userID int, count int) ([]string, error) if count < minCodeCount || count > maxCodeCount { return nil, fmt.Errorf("recovery code count must be between %d and %d", minCodeCount, maxCodeCount) } - codes := make([]string, 0, count) + if err := ctx.Err(); err != nil { + return nil, err + } + started := time.Now() + phase := "hash" + log.WithFields(log.Fields{"action": "生成离线恢复码", "phase": phase, "count": count}).Info("开始处理离线恢复码") + defer func() { + log.WithFields(log.Fields{"action": "生成离线恢复码", "phase": phase, "count": count, + "elapsed_ms": time.Since(started).Milliseconds(), "success": resultErr == nil, + "cancelled": ctx.Err() != nil}).Info("离线恢复码处理结束") + }() + codes = make([]string, 0, count) items := make([]models.RecoveryCode, 0, count) for i := 0; i < count; i++ { + if err := ctx.Err(); err != nil { + return nil, err + } plain, err := newRecoveryCode() if err != nil { return nil, err } - hash, err := password.Hash(plain) + hash, err := hashSecret(plain) if err != nil { return nil, fmt.Errorf("hash recovery code: %w", err) } codes = append(codes, plain) items = append(items, models.RecoveryCode{UserID: userID, CodeHash: hash, CodeVersion: 1}) } - - s.mu.Lock() + phase = "lock" + log.WithFields(log.Fields{"action": "生成离线恢复码", "phase": phase, "count": count, + "hash_ms": time.Since(started).Milliseconds()}).Info("恢复码密码计算结束") + if err := s.mu.LockContext(ctx); err != nil { + return nil, err + } defer s.mu.Unlock() + phase = "transaction" session := s.engine.NewSession() if session == nil { return nil, ErrUnavailable } defer session.Close() + session.Context(ctx) if err := session.Begin(); err != nil { return nil, fmt.Errorf("begin recovery code transaction: %w", err) } + defer session.Rollback() now := s.now() if _, err := session.Where("user_id = ? AND used_at IS NULL", userID).Cols("used_at").Update(&models.RecoveryCode{UsedAt: &now}); err != nil { _ = session.Rollback() return nil, fmt.Errorf("revoke old recovery codes: %w", err) } for i := range items { + if err := ctx.Err(); err != nil { + return nil, err + } if _, err := session.Insert(&items[i]); err != nil { _ = session.Rollback() return nil, fmt.Errorf("store recovery code: %w", err) } } + if err := ctx.Err(); err != nil { + return nil, err + } if err := session.Commit(); err != nil { return nil, fmt.Errorf("commit recovery codes: %w", err) } + phase = "complete" return codes, nil } diff --git a/docs/恢复码取消与低资源补充验收.md b/docs/恢复码取消与低资源补充验收.md new file mode 100644 index 00000000..cbabb783 --- /dev/null +++ b/docs/恢复码取消与低资源补充验收.md @@ -0,0 +1,92 @@ +# 恢复码取消与低资源补充验收 + +日期:2026-09-21。本记录接续 SQLite 跨引擎迁移补充验收,不能替代全项目生产放行。 + +## 需求确认与任务状态 + +- [✓] 保留上一轮失败证据,九文件提交 `586f2bc` 已推送并核对远端哈希。 +- [✓] 区分恢复码密码计算、数据库事务和 HTTP 请求取消,不把初始化超时写成迁移数据损坏。 +- [✓] 接入请求取消、可取消互斥等待和事务回滚;四个相关 Go 包、全量本地后端及 Linux 专项回归通过,跳过项单独披露。 +- [x] 完整应用的内存目标单变量对照已完成,PostgreSQL → SQLite 中间候选通过。 +- [✓] 最终候选重新编译及云端 PostgreSQL → SQLite 复测通过,实际进程环境核对完成。 +- [✓] 本地运行时回归 81 项、云端完整部署回归 151 项通过,后者零跳过。 +- [✓] 127 份文件本地与远端摘要一致,616 个后端源码摘要一致;八个测试账号锁定,正式六服务和程序未变,测试单元全部停止。 +- [!] 原 64 MiB Go 内存目标的受限环境仍存在 30 秒恢复码生成超时,不能按同条件通过处理。 +- [!] 浏览器、外部邮件、凭据轮换、续期、长时容量和灾备等其他门禁仍未全部关闭。 + +## 超时定位证据 + +不降低 Argon2id 参数,不减少恢复码数量,不放宽探针 30 秒读取超时。密码算法仍为 `m=65536,t=3,p=1`,每轮生成十枚恢复码。 + +直接调用凭据服务的受限诊断使用新建 PostgreSQL 空库、专用账号、192 MiB 进程内存上限和 25% CPU 配额: + +| 诊断实例后缀 | Go 内存目标 | 密码计算至事务开始 | 整体生成 | GC 次数 | +| --- | --- | --- | --- | --- | +| `20260921-082512` | 64 MiB | 23.753 秒 | 23.775 秒 | 138 | +| `20260921-082540` | 128 MiB | 24.079 秒 | 24.093 秒 | 10 | + +这两个独立服务诊断没有显示明显的总耗时改善;数据库事务只占十几至二十几毫秒。不能仅凭 GC 次数差异宣称找到了完整应用的全部性能原因。前两次诊断启动遇到隔离挂载与执行路径错误,未进入应用测试,保留记录且专用账号已锁定。 + +随后用同一个中间候选 `062c15539ed0df446d4050bc15b55f717369b58fba0caa2e8c4f28ceabd5ed0c` 进行完整应用对照: + +| 实例后缀 | Go 内存目标 | 密码计算 | 整体生成 | 结果 | +| --- | --- | --- | --- | --- | +| `20260921-083048` | 64 MiB | 30.208 秒 | 30.209 秒 | 客户端取消;尚未进入数据库事务,取消被服务识别 | +| `20260921-083337` | 128 MiB | 23.601 秒 | 23.619 秒 | 初始化及后续完整迁移通过 | + +两个应用实例均保持 CPU 25%、进程硬上限 192 MiB、交换禁用、`GOMAXPROCS=1`、十枚恢复码和 30 秒读取超时,仅显式调整 Go 内存目标。直接证据确认失败发生在密码计算阶段,而非 PostgreSQL → SQLite 迁移执行阶段。内存目标变化与本次完整应用耗时改善存在对照证据,但不能从这一组样本外推所有负载下的性能。 + +原失败记录继续有效。128 MiB 是另一个明确标注的资源配置,不是把旧配置改写为通过,更不是生产容量或性能承诺。正式服务资源配置没有变动。 + +## 修改边界与反例 + +1. `GenerateRecoveryCodesContext` 接收 HTTP 请求上下文;旧无上下文入口保留,供兼容调用使用。 +2. 每次 Argon2id 计算之间检查取消;单次计算本身不可中断,不改变哈希强度。 +3. 凭据写入锁允许取消等待;取消不能释放其他请求持有的锁,也不能留下不可再用的锁。 +4. 数据库事务使用请求上下文,插入和提交前检查取消;未提交事务统一回滚。 +5. 自助及管理员代生成入口均传递请求上下文;管理员入口遇到密码计算繁忙时返回 `503`、`Retry-After: 1` 和 `AUTH_BUSY`。 +6. 阶段日志只记录数量、耗时、阶段和成功/取消标志,不记录恢复码、哈希、口令或 Cookie。 + +反例覆盖:请求开始前取消、第一/第五/第十次计算后取消、等待凭据锁取消、等待数据库连接取消、第一/最后一次插入后取消、密码计算繁忙、互斥并发及非法解锁。成功路径继续检查十枚随机码及原 Argon2id 参数。 + +本修改保证已观察到取消且尚未提交的事务不会部分轮换恢复码。数据库提交已经成功后发生响应丢失,是另一种状态;不能承诺数据库提交与浏览器收到明文之间具有原子性。 + +## 工具与环境 + +- 隔离验收工具新增显式 Go 内存目标参数,默认仍是原 64 MiB;Go 目标与进程硬上限之间必须保留至少 64 MiB。 +- 验收器读取进程环境时只保留 `GOMEMLIMIT`、`GOMAXPROCS`,不保存其他环境变量。 +- 探针单独记录恢复码阶段和耗时,失败时不再把后续尚未创建的目标库误读为数据库损坏。 +- 中间候选只重编译后端,复用未修改的前端与 Linux 插件资产,不能描述为全资产重建。 +- 每轮使用新空隔离实例和新 SQL 库,不清空正式数据库,不停止正式 Smail 或其他项目,不删除离线证据。 + +中间候选 PostgreSQL → SQLite 实例 `20260921-083337`:内部 PID `794879` 不变,系统重启为 `795359`,故障恢复为 `795477`。三阶段源 PostgreSQL 连接数均为零,目标 SQLite 打开;运行目录只保留 `migrated.db`,没有第二个状态库。旧邮件、新邮件、正文和附件、配置历史原始字节、目标不可用时拒绝启动、恢复后读写均通过。 + +本地证据位于 `test-log/production-remediation-20260921/`,包括 `recovery-timing-*`、`mixed-runtime-evidence/smail-review-runtime-20260921-083048/` 和 `mixed-runtime-evidence/smail-review-runtime-20260921-083337/`。首次扩大本地回归遇到新测试夹具角色类型编译错误,修正后四个相关包均通过;前后日志均保留。 + +## 最终候选与回归结果 + +最终后端候选 SHA-256 为 `87eb782b34ac16473259e4b63caf98dd9dd073c887d0dcad008742bbc913bd35`,对应本轮恢复码修改和互斥非法解锁保护。构建仍复用未修改的前端及 Linux 插件资产,不是全资产重新构建,也未替换正式实例。 + +| 范围 | 结果与边界 | +| --- | --- | +| Windows 本地全量后端 | `go test -json -p 1 ./... -count=1 -timeout=240s` 退出零;62 个包通过,22 个包级跳过;1057 个顶层测试及 831 个子测试通过,34 个顶层测试及 7 个子测试跳过,零失败 | +| Linux 恢复码专项 | `20260921-085040` 两个测试程序退出零,共 19 个测试及子测试通过、零跳过;凭据包十枚强哈希耗时约 24.4 秒 | +| 最终候选应用级迁移 | `20260921-085155` PostgreSQL → SQLite 通过;恢复码 HTTP 耗时 23.772 秒,未放宽 30 秒超时 | +| 本地运行时工具回归 | 81 项通过,证据为 `recovery-runtime-python-tests.log` | +| Linux 全部部署工具回归 | `20260921-201523` 实际执行 151 项、零跳过、退出零;峰值 45.9 MiB、交换为零,正式六服务状态未变 | + +本地全量跳过项包含平台相关的符号链接与权限检查、未启用的真实 SQL 集成、浏览器夹具及 Windows 提权 IPC 等,不视为验收通过。Linux 恢复码专项只补足本修改的运行平台证据,不覆盖全部本地跳过项。 + +最终迁移实例实际进程环境为 `GOMEMLIMIT=128MiB`、`GOMAXPROCS=1`,进程硬上限仍为 192 MiB、CPU 配额仍为 25%,无 OOM。内部重启 PID `796642` 不变,系统重启后为 `797013`,故障恢复后为 `797134`。三阶段源 PostgreSQL 活动连接均为零,目标 SQLite 打开,活动库只有 `migrated.db`;业务与配置历史同库。 + +待重启写入被拒绝、原会话保留、配置历史原始字节一致、历史及新增邮件中文正文和附件的 SMTP/IMAP/POP3 读写均通过。目标不可用时退出码为 1、PID 为零,没有回退源库;恢复访问后读写通过。测试账号锁定,桥接和网络隔离单元停止,无资源采集或清理错误。 + +本轮最后一个迁移方向已获得成功证据,但跨引擎六个方向来自不同阶段候选,不能表述为最终候选在同一轮跑完全部方向。迁移提交及配置切换临界点、SQL 复制中断、历史凭据轮换、静态安全剩余项、真实浏览器、外部投递、自动续期、容量及异机灾备等继续保持未完成。 + +## 独立复核与收尾 + +20 时补充回归后,`recheck_recovery.py` 对八次跨引擎应用级尝试、两个 Linux 专项程序及部署工具回归执行独立复核,合计检查 127 份文件,包括成功与失败日志、当前工具、最终候选及测试二进制;本地与远端摘要全部一致。616 个后端源码文件与最终构建清单一致。旧工具仅按原始证据摘要复核,不要求旧轮次工具与当前工具一致。 + +三次恢复码超时失败完整保留。八个 SQL 专用账号均锁定,PostgreSQL 账号不能登录、建库或提权;测试单元全部停止。正式六服务的 PID、InvocationID、重启次数及活动状态与测试前一致,正式主程序 SHA-256 仍为 `c14ef1c5ba1dc2096c312c8357039a7330c985cbeac90765aed01d8151e05e1f`。用户原 `云端全项目测试报告.md` 未编辑或暂存。 + +当前跟踪文件的凭据特征扫描通过,只代表该扫描器覆盖的当前文件检查,不代表历史凭据撤销或完整安全审计通过。整体验收继续未通过,不能部署为已验收生产版本。 diff --git a/docs/生产补充验收记录.md b/docs/生产补充验收记录.md index 0eca3ea7..43ae0fc4 100644 --- a/docs/生产补充验收记录.md +++ b/docs/生产补充验收记录.md @@ -339,3 +339,13 @@ SQLite 已完成迁移失败不覆盖目标、成功迁移、内部重启、系 PostgreSQL → SQLite 两次在初始化恢复码生成时发生 30 秒 HTTP 读取超时,未进入迁移。没有降低哈希强度、减少码数量或放宽超时条件,不将失败标为通过。该初始化耗时问题和最后一个迁移方向继续待查。 最终本地运行时回归 79 项、Linux 完整部署回归 149 项通过,Linux 零跳过;47 份成功与失败证据、七个工具及 611 个后端源码摘要已独立复核。五个 SQL 测试账号锁定,测试单元全部停止,正式六服务和程序未变;用户原测试报告未编辑。全项目生产验收仍未通过,历史凭据、其余安全告警、真实浏览器、外部投递、自动续期、容量及异机灾备等范围不能由本轮结果代替。 + +### 9 月 21 日恢复码取消与最终 PostgreSQL → SQLite 补充 + +详见 `恢复码取消与低资源补充验收.md`。恢复码生成接入 HTTP 请求取消、可取消凭据锁及事务回滚,管理员代生成入口补齐资源繁忙时的可重试状态。原 64 MiB Go 内存目标的 30 秒超时保留为失败;128 MiB Go 目标是明确区分的另一种资源配置,没有降低密码强度、减少恢复码或放宽超时。 + +最终后端候选 `87eb782b34ac16473259e4b63caf98dd9dd073c887d0dcad008742bbc913bd35` 在 `20260921-085155` 隔离实例完成 PostgreSQL → SQLite 迁移、两类重启、目标故障恢复及真实邮件读写。进程环境值已核对,三阶段源连接为零,目标业务与配置历史同库,只使用一个 SQLite 文件。正式实例未替换。 + +本地全量后端 1888 个测试及子测试通过、41 个跳过、零失败;Linux 恢复码专项 19 个测试及子测试通过、零跳过;本地运行时工具 81 项及云端部署工具 151 项通过,云端零跳过。不同范围和不同候选的结果分别保留,不能叠加为全部生产门禁通过。 + +收尾独立复核 127 份文件本地与远端摘要、616 个后端源码摘要均一致,八个测试数据库账号锁定,三次超时失败证据保留。正式六服务和主程序未变,测试单元全部停止;用户原测试报告没有编辑或暂存。其余安全、续期、浏览器、外部投递、容量和灾备门禁继续未关闭。 diff --git a/test/deployment/test_runtime_cleanup.py b/test/deployment/test_runtime_cleanup.py index 1f9cb5b7..660b88ae 100644 --- a/test/deployment/test_runtime_cleanup.py +++ b/test/deployment/test_runtime_cleanup.py @@ -19,6 +19,20 @@ class RuntimeCleanupTest(unittest.TestCase): probe = 'smail-runtime-probe-20260918-020000' proxy = 'smail-runtime-nginx-20260918-020000' + def test_memory_profiles_keep_explicit_native_reserve(self): + for hard, soft in ((128, 64), (192, 64), (192, 128), (256, 192)): + review.validate_memory_profile(hard, soft) + for hard, soft in ((128, 128), (192, 192), (128, 256), (64, 64), (1024, 64)): + with self.subTest(hard=hard, soft=soft), self.assertRaises(ValueError): + review.validate_memory_profile(hard, soft) + + def test_go_resource_environment_does_not_record_other_variables(self): + self.assertEqual(review.go_environment(b'PASSWORD=not-for-logs\0GOMEMLIMIT=128MiB\0GOMAXPROCS=1\0'), + {'GOMEMLIMIT': '128MiB', 'GOMAXPROCS': '1'}) + self.assertEqual(review.go_environment(b'PASSWORD=not-for-logs\0'), {}) + with self.assertRaises(ValueError): + review.go_environment(b'GOMEMLIMIT=64MiB\0GOMEMLIMIT=128MiB\0') + def test_single_database_integrity_and_legacy_rejection(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/test/tools/runtime_probe.py b/test/tools/runtime_probe.py index fcf46f80..af131449 100644 --- a/test/tools/runtime_probe.py +++ b/test/tools/runtime_probe.py @@ -494,7 +494,12 @@ def main(): result['setup'] = {'empty_database': True, 'token_required': True, 'login': True, 'session': True} # 首次登录先完成正式安全设置,不绕过恢复方式要求直接启用协议。 client.success('/api/v1/security/reauth', {'password': password}) - recovery = client.success('/api/v1/security/credentials/recovery-codes/generate', {'count': 10}) + result['stage'] = 'recovery_codes' + recovery_started = time.monotonic() + try: + recovery = client.success('/api/v1/security/credentials/recovery-codes/generate', {'count': 10}) + finally: + result['recovery_generation_seconds'] = round(time.monotonic() - recovery_started, 3) assert len(recovery['codes']) == 10 recovery.clear() result['security_setup'] = {'recovery_codes_generated': 10, 'plaintext_logged': False} diff --git a/test/tools/runtime_review.py b/test/tools/runtime_review.py index 2bc1f36c..92df35b3 100644 --- a/test/tools/runtime_review.py +++ b/test/tools/runtime_review.py @@ -93,12 +93,32 @@ def stop_database_bridges(bridges, result): result['database_bridge_stopped'] = bool(bridges) and len(closed) == len(bridges) +def validate_memory_profile(hard_mib, soft_mib): + if hard_mib not in (128, 192, 256) or soft_mib not in (64, 128, 192): + raise ValueError('不支持的隔离内存配置') + if soft_mib + 64 > hard_mib: + raise ValueError('Go 内存目标与进程硬上限之间必须保留至少 64 MiB') + + +def go_environment(raw): + # 只保留本轮资源控制项,不能把其他环境变量写入证据。 + result = {} + for item in raw.split(b'\0'): + name, separator, value = item.partition(b'=') + if separator and name in (b'GOMEMLIMIT', b'GOMAXPROCS'): + if name.decode('ascii') in result: + raise ValueError('Go 资源环境项重复') + result[name.decode('ascii')] = value.decode('ascii') + return result + + def main(): parser = argparse.ArgumentParser() parser.add_argument('--main-sha', required=True) parser.add_argument('--binary', type=Path, default=Path('/opt/smail/smail')) parser.add_argument('--nginx-template', type=Path) parser.add_argument('--memory-max-mib', type=int, choices=(128, 192, 256), default=128) + parser.add_argument('--go-memory-limit-mib', type=int, choices=(64, 128, 192), default=64) parser.add_argument('--restore', action='store_true') parser.add_argument('--migration', action='store_true') parser.add_argument('--interruption', action='store_true') @@ -106,6 +126,7 @@ def main(): parser.add_argument('--migration-target-type', choices=('sqlite', 'mysql', 'postgres')) args = parser.parse_args() try: + validate_memory_profile(args.memory_max_mib, args.go_memory_limit_mib) target_kind = migration_kind(args.database_type, args.migration_target_type, args.migration, args.interruption) except ValueError as error: parser.error(str(error)) @@ -189,6 +210,7 @@ def main(): proxy_unit = 'smail-runtime-nginx-' + stamp result = {'target': str(target), 'unit': unit, 'main_sha256': args.main_sha, 'memory_max_mib': args.memory_max_mib, + 'go_memory_limit_mib': args.go_memory_limit_mib, 'cpu_quota_percent': 25, 'source': str(binary), 'live_sha256': live_sha, 'before': before, 'topology': 'nginx-8001-8002' if args.nginx_template else 'direct-80-443'} service_env = [] @@ -259,7 +281,8 @@ def main(): '--property=BindPaths=' + str(root) + ':/opt/smail ' + str(control) + ':/run/smail', '--property=ReadWritePaths=/opt/smail /run/smail', '--property=WorkingDirectory=/opt/smail', '--setenv=SMAIL_ROOT=/opt/smail', - '--setenv=GOMEMLIMIT=64MiB', '--setenv=GOMAXPROCS=1', *service_env, '/opt/smail/smail', + '--setenv=GOMEMLIMIT=' + str(args.go_memory_limit_mib) + 'MiB', + '--setenv=GOMAXPROCS=1', *service_env, '/opt/smail/smail', ], check=True) pid = run('systemctl', 'show', unit, '--property=MainPID', '--value') assert pid != '0' @@ -267,6 +290,8 @@ def main(): assert str(Path('/proc/' + pid + '/ns/net').readlink()) != live_net assert Path('/proc/' + pid + '/root/opt/smail/smail').stat().st_ino == (root / 'smail').stat().st_ino assert (root / 'smail').stat().st_ino != (live / 'smail').stat().st_ino + result['go_environment'] = go_environment(Path('/proc/' + pid + '/environ').read_bytes()) + assert result['go_environment'] == {'GOMEMLIMIT': str(args.go_memory_limit_mib) + 'MiB', 'GOMAXPROCS': '1'} if sql_fixture or migration_target: from runtime_sql_bridge import DatabaseBridge ports = {fixture.port for fixture in (sql_fixture, migration_target) if fixture}