// Package auth:密钥哈希方案修正说明与统一入口(Issue/Check 一致性)。 // // 存储方案(本文件为权威实现): // // hash 列 = SHA-256("lookup:" + globalSalt + ":" + plaintext)(确定性、可索引、常数时间比对) // salt 列 = per-key 随机盐 hex(审计与将来迁移用,不参与校验) // // 全局盐必须来自 ${BROWSER_SERVER_KEYS_SEED:?required}(config.MustEnv,缺省 panic)。 package auth import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "fmt" "strings" "time" "onesvm.com/onesvm/browser-server/internal/config" "onesvm.com/onesvm/browser-server/internal/store" ) // KeyPrefix 明文 key 前缀(mcp-usage §1.2)。 const KeyPrefix = "bs_" // Verifier key 签发/校验器。 type Verifier struct { db *store.DB salt string } // NewVerifier 构造校验器。seedSalt 为空即 panic(fail-closed,禁默认值兜底)。 func NewVerifier(db *store.DB, seedSalt string) *Verifier { if seedSalt == "" { panic("auth: 密钥盐为空(BROWSER_SERVER_KEYS_SEED 未注入,fail-closed)") } return &Verifier{db: db, salt: seedSalt} } // Salt 暴露盐值(CLI/管理面签发与 gateway 校验同源)。 func (v *Verifier) Salt() string { return v.salt } // Error 认证/配额错误(映射 contract 错误码字面量,避免循环依赖)。 type Error struct { Code string // unauthorized | quota | rate_limited Message string } // Error 实现 error 接口。 func (e *Error) Error() string { return e.Code + ": " + e.Message } // Issue 签发新 key:明文仅本次回传。scopes 缺省 search,read。 func (v *Verifier) Issue(consumerID int64, name string, scopes []string, rpm, daily, monthly, sessions int, expiresAt *time.Time) (plaintext string, keyID int64, err error) { if len(scopes) == 0 { scopes = []string{"search", "read"} } raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", 0, fmt.Errorf("auth: 随机数生成失败: %w", err) } plaintext = KeyPrefix + base64.RawURLEncoding.EncodeToString(raw) perKeySalt := make([]byte, 16) if _, err := rand.Read(perKeySalt); err != nil { return "", 0, fmt.Errorf("auth: 盐生成失败: %w", err) } k := &store.ApiKey{ ConsumerID: consumerID, Prefix: plaintext[:10], Name: name, Hash: LookupHash(v.salt, plaintext), // 校验同源 Salt: hex.EncodeToString(perKeySalt), Scopes: scopes, RPM: rpm, DailyQuota: daily, MonthlyQuota: monthly, ConcurrentSessions: sessions, Status: "enabled", ExpiresAt: expiresAt, } keyID, err = v.db.CreateKey(k) if err != nil { return "", 0, fmt.Errorf("auth: Issue 落库: %w", err) } return plaintext, keyID, nil } // LookupHash 查询索引哈希:SHA-256("lookup:" + globalSalt + ":" + plaintext)。 // 单向:库泄露不泄露明文;比对用 hmac.Equal 常数时间。 func LookupHash(globalSalt, plaintext string) string { h := sha256.New() h.Write([]byte("lookup:" + globalSalt + ":")) h.Write([]byte(plaintext)) return hex.EncodeToString(h.Sum(nil)) } // Check 校验明文 key:粗检 → 查库 → hmac.Equal 常数时间比对 → 过期/吊销判定。 func (v *Verifier) Check(plaintext string) (*store.ApiKey, *Error) { plaintext = strings.TrimSpace(plaintext) if !strings.HasPrefix(plaintext, KeyPrefix) || len(plaintext) < len(KeyPrefix)+8 { return nil, &Error{Code: "unauthorized", Message: "key 缺失或格式非法"} } lookup := LookupHash(v.salt, plaintext) k, err := v.db.KeyByHash(lookup) if err != nil { // 假比对抹平时序差(查无此 key 与比对失败不可区分)。 hmac.Equal([]byte(lookup), []byte("0000000000000000000000000000000000000000000000000000000000000000")) return nil, &Error{Code: "unauthorized", Message: "key 无效"} } if !hmac.Equal([]byte(lookup), []byte(k.Hash)) { return nil, &Error{Code: "unauthorized", Message: "key 无效"} } now := config.Now() if k.Status == "disabled" || k.Status == "revoked" { return nil, &Error{Code: "unauthorized", Message: "key 已吊销或禁用"} } if k.ExpiresAt != nil && now.After(*k.ExpiresAt) { return nil, &Error{Code: "unauthorized", Message: "key 已过期"} } return k, nil } // HasScope 判断 key 是否含某 scope。 func HasScope(k *store.ApiKey, scope string) bool { for _, s := range k.Scopes { if strings.TrimSpace(s) == scope { return true } } return false }