单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。 Co-authored-by: Cursor <cursoragent@cursor.com>
133 lines
4.5 KiB
Go
133 lines
4.5 KiB
Go
// 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
|
||
}
|