// Package credential 密码凭证(argon2 统一 + 登录成功惰性升级,ADR-016)。 // // 背景:两套旧系统密码算法不一致——教务 `md5(password + salt)`(salt 独立列)、 // 教学 `sha1(password + salt)`,均为弱哈希且两侧 salt 语义不同。ADR-016 拍板: // - 目标算法统一 argon2id(golang.org/x/crypto/argon2,纯 Go 实现); // - **惰性升级**:登录时先按 hash_version 路由到旧算法比对,命中后立即用 argon2 // 重哈希写回(调用方在同一事务内完成"验旧 + 写新");**不批量迁移、不强制重置**, // 避免割接窗口阻断老用户(风险登记册"割接期登录失败"); // - hash_version 取值:md5_salt / sha_salt / argon2(迁移期新增列,DDL 见 unified/02-data-model)。 // // 典型使用(identity 域 AuthService.Login): // // res, err := credential.Check(ctx, input, u.PasswordHash, u.Salt, u.HashVersion) // if err != nil { return nil, errors.WithCause(errors.ErrInternal, err) } // if !res.Match { return nil, errors.New(errors.CodeRuleConflict, "账号或密码错误") } // 统一文案,不区分"账号不存在/密码错"(防枚举) // if res.NeedsUpgrade { // // 后半段:argon2 重哈希 + hash_version='argon2' + salt 置 NULL(同事务) // enc, err := credential.HashArgon2(input) // // UPDATE t_user SET password_hash=?, salt=NULL, hash_version='argon2' WHERE canonical_user_id=? // // 升级成功后撤销该用户全部 token(对齐旧系统语义,见架构规格 §7.4 改密链路) // } // // 继续签发 access + refresh package credential import ( "context" "crypto/md5" "crypto/rand" "crypto/sha1" "crypto/subtle" "encoding/base64" "encoding/hex" "fmt" "io" "golang.org/x/crypto/argon2" ) // HashVersion 密码哈希版本(t_user.hash_version 列取值,ADR-016 路由依据)。 // // 取值: // - HashVersionMD5Salt:"md5_salt"——教务旧算法 md5(password + salt),salt 存独立列; // - HashVersionShASalt:"sha_salt"——教学旧算法 sha1(password + salt),salt 存独立列; // - HashVersionArgon2:"argon2"——目标算法(argon2id,参数见参数常量;salt 内嵌于编码串,独立列置 NULL)。 const ( HashVersionMD5Salt = "md5_salt" HashVersionShASalt = "sha_salt" HashVersionArgon2 = "argon2" ) // argon2id 参数(OWASP 2024 推荐下限;纯 CPU 哈希,与 GPU 显存无关, // 双实例同机部署无额外压力)。 // 参数变更 = 全体老 argon2 用户需重登(编码串内嵌参数校验失败), // 仅在安全审计要求时调整,属重大变更。 // 类型说明:argon2.IDKey 签名为 (password, salt []byte, time uint32, memory uint32, threads uint8, keyLen uint32)。 const ( argon2Time uint32 = 3 // 迭代轮数 argon2Memory uint32 = 64 * 1024 // 64MB 内存 argon2Threads uint8 = 4 // 并行度(API 要求 uint8) argon2KeyLen uint32 = 32 // 输出 32 字节(API 要求 uint32) argon2SaltLen int = 16 // 盐 16 字节 ) // ErrUnknownVersion hash_version 取值不在已知集合(迁移脚本漏跑/数据污染)。 // 调用方按"拒绝登录 + 告警"处理(不是密码错误——该用户需运维介入)。 var ErrUnknownVersion = fmt.Errorf("credential: 未知 hash_version(非 %s/%s/%s)", HashVersionMD5Salt, HashVersionShASalt, HashVersionArgon2) // Result 密码校验结果(Check 的出参结构)。 // // 字段: // - Match bool:密码是否匹配(false 时调用方必须返回统一文案"账号或密码错误", // 禁止区分"账号不存在"与"密码错误"——防用户名枚举)。 // - NeedsUpgrade bool:校验成功但仍是旧算法(md5_salt/sha_salt),调用方应紧接着 // 执行升级(argon2 重哈希 + 写 hash_version + 撤销全部 token)。 type Result struct { Match bool NeedsUpgrade bool } // HashArgon2 用 argon2id 生成密码哈希(新注册/改密/惰性升级统一入口)。 // // 入参: // - password string:明文密码(仅存在于内存调用栈,禁止写日志/配置/DB 注释)。 // // 出参: // - string:PHC 格式编码串 "argon2id$v=19$m=65536,t=3,p=4$$" // (单字段自包含 salt + 参数,存 t_user.password_hash,salt 列置 NULL)。 // - error:仅当 crypto/rand 读取盐失败(熵源故障)时返回。 // // 样例: // // enc, _ := credential.HashArgon2("X#9f2k") // // "argon2id$v=19$m=65536,t=3,p=4$BwJ3...$Qm2..." func HashArgon2(password string) (string, error) { salt := make([]byte, argon2SaltLen) if _, err := io.ReadFull(rand.Reader, salt); err != nil { return "", fmt.Errorf("credential: 生成 argon2 salt 失败: %w", err) } dst := argon2.IDKey([]byte(password), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen) saltB64 := base64.RawStdEncoding.EncodeToString(salt) hashB64 := base64.RawStdEncoding.EncodeToString(dst) return fmt.Sprintf("argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", argon2Memory, argon2Time, argon2Threads, saltB64, hashB64), nil } // VerifyArgon2 校验明文密码是否匹配 argon2 编码串。 // // 入参: // - encoded string:HashArgon2 产出的 PHC 编码串(含内嵌参数/salt)。 // - password string:待校验明文密码。 // // 出参: // - bool:true 匹配;false 不匹配**或编码串格式非法**(格式非法按不匹配处理—— // 脏数据不产生 5xx,由数据质量报表另行修复)。 // // 说明:恒定时间比较(subtle.ConstantTimeCompare),防时序侧信道。 func VerifyArgon2(encoded, password string) bool { parts := splitPHC(encoded) // "argon2id" | "v=19" | "m=...,t=...,p=..." | saltB64 | hashB64 if len(parts) != 5 || parts[0] != "argon2id" { return false } salt, err := base64.RawStdEncoding.DecodeString(parts[3]) if err != nil { return false } expected, err := base64.RawStdEncoding.DecodeString(parts[4]) if err != nil || uint32(len(expected)) != argon2KeyLen { return false } var m, t uint32 var p uint8 if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil { return false } actual := argon2.IDKey([]byte(password), salt, t, m, p, argon2KeyLen) return subtle.ConstantTimeCompare(actual, expected) == 1 } // Check 按 hash_version 路由做密码校验(登录主链路入口,ADR-016 惰性升级的前半段)。 // // 入参: // - ctx context.Context:请求上下文(预留:限流/审计钩子;当前未消费)。 // - password string:用户输入的明文密码。 // - storedHash string:t_user.password_hash 列当前值。 // - salt string:t_user.salt 列当前值(argon2 版本下应为 NULL/空——盐已内嵌编码串)。 // - version string:t_user.hash_version 列当前值(HashVersion* 常量之一)。 // // 出参: // - Result:Match/NeedsUpgrade 见 Result 结构注释。 // - error:仅以下场景非 nil: // - version 不在已知集合 → 包装 ErrUnknownVersion(拒绝登录 + 告警,非密码错误); // - storedHash 为空 → 该账号无凭证,需运维重建(同样走告警分支)。 // // 路由逻辑(避免双算法误判,ADR-016 核心): // 1. md5_salt:md5(password + salt) hex 小写与 storedHash 比较(教务旧算法); // 2. sha_salt:sha1(password + salt) hex 小写与 storedHash 比较(教学旧算法, // 迁移前须抽样双跑确认教学确为 sha1 而非 sha256); // 3. argon2:VerifyArgon2(storedHash, password)。 // // 命中旧算法时 NeedsUpgrade=true,调用方负责后半段(写回 + 撤 token,见包注释"典型使用")。 // // 异常场景: // - 大小写差异:旧库 hex 可能存大写(历史脚本差异),比较前统一转小写—— // 该归一属于"数据保真"例外,单测须覆盖大写 storedHash 用例。 func Check(ctx context.Context, password, storedHash, salt, version string) (Result, error) { _ = ctx // 预留参数位 if storedHash == "" { return Result{}, fmt.Errorf("credential: 账号无密码哈希(version=%q),需运维重建凭证", version) } switch version { case HashVersionMD5Salt: return Result{Match: hexEqFold(legacyDigest(md5.New(), password+salt), storedHash), NeedsUpgrade: true}, nil case HashVersionShASalt: return Result{Match: hexEqFold(legacyDigest(sha1.New(), password+salt), storedHash), NeedsUpgrade: true}, nil case HashVersionArgon2: return Result{Match: VerifyArgon2(storedHash, password)}, nil default: return Result{}, fmt.Errorf("%w(实际=%q)", ErrUnknownVersion, version) } } // legacyDigest 计算旧算法摘要的 hex 小写串(md5/sha1 仅用于旧数据兼容比对, // 禁止用于任何新数据路径)。 func legacyDigest(h interface{ Write([]byte) (int, error); Sum([]byte) []byte }, s string) string { h.Write([]byte(s)) return hex.EncodeToString(h.Sum(nil)) } // hexEqFold 忽略大小写的 hex 串比较(先比长度;旧算法校验发生在"账号已存在"之后, // 登录链路统一文案已消除枚举风险,此处非常量时间可接受)。 func hexEqFold(a, b string) bool { if len(a) != len(b) { return false } for i := range a { ca, cb := a[i], b[i] if ca >= 'A' && ca <= 'F' { ca += 'a' - 'A' } if cb >= 'A' && cb <= 'F' { cb += 'a' - 'A' } if ca != cb { return false } } return true } // splitPHC 按 '$' 拆分 PHC 编码串(不校验各段格式,由调用方逐段解析)。 func splitPHC(s string) []string { var parts []string start := 0 for i := 0; i < len(s); i++ { if s[i] == '$' { parts = append(parts, s[start:i]) start = i + 1 } } return append(parts, s[start:]) }