Files
ai_site/platform/internal/yuhticket/ticket.go
whm e571e98387 feat: Z34b restore-by-host + harden web nginx DNS race
Add HMAC restore-by-host for phone-less rebind; resolve gateway at request time and recreate web after stack up.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 00:16:08 +08:00

209 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package yuhticket
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const Prefix = "YHT1"
// Claims 宇恒签发的短时凭票(仅用于智建 sync 绑定/免登录换票)。
type Claims struct {
Iss string `json:"iss"`
Aud string `json:"aud"`
Phone string `json:"phone"`
HostKey string `json:"host_key"`
Name string `json:"name,omitempty"`
LocalDatabaseID string `json:"local_database_id,omitempty"`
Exp int64 `json:"exp"`
JTI string `json:"jti"`
Scope string `json:"scope,omitempty"` // sync_bind | sync_restore
YuhengUserID string `json:"yuheng_user_id,omitempty"`
}
type VerifyOpts struct {
Secret string
Issuer string // default yuheng
Audience string // default aijianzhan
Now time.Time
MaxSkew time.Duration // clock skew; default 30s
// AllowEmptyPhoneZ34b restore-by-hostphone 可空(仍须 host_key+jti
AllowEmptyPhone bool
// AllowedScopes空则仅允许 sync_bind含空 scoperestore 可传 sync_bind+sync_restore。
AllowedScopes []string
}
// Sign 供联调/测试;生产由宇恒侧用同一 Secret 签发。
func Sign(secret string, c Claims) (string, error) {
secret = strings.TrimSpace(secret)
if secret == "" {
return "", fmt.Errorf("ticket secret empty")
}
if c.Iss == "" {
c.Iss = "yuheng"
}
if c.Aud == "" {
c.Aud = "aijianzhan"
}
if c.Scope == "" {
c.Scope = "sync_bind"
}
// sync_restore允许无 phone换机仅凭宇恒账号 ID其余 scope 仍须 phone。
needPhone := c.Scope != "sync_restore"
if c.JTI == "" || c.HostKey == "" || c.Exp == 0 || (needPhone && c.Phone == "") {
return "", fmt.Errorf("jti/host_key/exp required; phone required unless scope=sync_restore")
}
raw, err := json.Marshal(c)
if err != nil {
return "", err
}
payload := base64.RawURLEncoding.EncodeToString(raw)
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(Prefix + "." + payload))
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return Prefix + "." + payload + "." + sig, nil
}
func Verify(ticket string, opts VerifyOpts) (*Claims, error) {
ticket = strings.TrimSpace(ticket)
secret := strings.TrimSpace(opts.Secret)
if secret == "" {
return nil, fmt.Errorf("宇恒凭票未配置Agent.YuhengTicket.Secret")
}
parts := strings.Split(ticket, ".")
if len(parts) != 3 || parts[0] != Prefix {
return nil, fmt.Errorf("invalid ticket format")
}
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(parts[0] + "." + parts[1]))
want := mac.Sum(nil)
got, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil || !hmac.Equal(want, got) {
return nil, fmt.Errorf("ticket signature invalid")
}
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("ticket payload invalid")
}
var c Claims
if err := json.Unmarshal(raw, &c); err != nil {
return nil, fmt.Errorf("ticket claims invalid")
}
iss := strings.TrimSpace(opts.Issuer)
if iss == "" {
iss = "yuheng"
}
aud := strings.TrimSpace(opts.Audience)
if aud == "" {
aud = "aijianzhan"
}
if c.Iss != iss {
return nil, fmt.Errorf("ticket issuer not allowed")
}
if c.Aud != aud {
return nil, fmt.Errorf("ticket audience mismatch")
}
scope := strings.TrimSpace(c.Scope)
allowed := opts.AllowedScopes
if len(allowed) == 0 {
allowed = []string{"", "sync_bind"}
}
scopeOK := false
for _, a := range allowed {
if scope == strings.TrimSpace(a) {
scopeOK = true
break
}
}
if !scopeOK {
return nil, fmt.Errorf("ticket scope not allowed")
}
now := opts.Now
if now.IsZero() {
now = time.Now().UTC()
}
skew := opts.MaxSkew
if skew <= 0 {
skew = 30 * time.Second
}
exp := time.Unix(c.Exp, 0).UTC()
if now.After(exp.Add(skew)) {
return nil, fmt.Errorf("ticket expired")
}
// 拒绝过远未来的 exp防永久票
if exp.After(now.Add(10 * time.Minute)) {
return nil, fmt.Errorf("ticket exp too far")
}
if strings.TrimSpace(c.HostKey) == "" || strings.TrimSpace(c.JTI) == "" {
return nil, fmt.Errorf("ticket missing host_key/jti")
}
if strings.TrimSpace(c.Phone) == "" && !opts.AllowEmptyPhone && scope != "sync_restore" {
return nil, fmt.Errorf("ticket missing phone/host_key/jti")
}
return &c, nil
}
// JTIStore 防重放(短 TTL
type JTIStore struct {
mu sync.Mutex
path string
seen map[string]int64 // jti -> exp unix
}
func NewJTIStore(dir string) (*JTIStore, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
s := &JTIStore{path: filepath.Join(dir, "yuheng_ticket_jti.json"), seen: map[string]int64{}}
_ = s.load()
return s, nil
}
func (s *JTIStore) load() error {
b, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
return json.Unmarshal(b, &s.seen)
}
func (s *JTIStore) save() error {
b, err := json.Marshal(s.seen)
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// Consume 若 jti 已用则报错;否则记入至 exp。
func (s *JTIStore) Consume(jti string, expUnix int64) error {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now().Unix()
for k, exp := range s.seen {
if exp < now {
delete(s.seen, k)
}
}
if _, ok := s.seen[jti]; ok {
return fmt.Errorf("ticket already used")
}
s.seen[jti] = expUnix
return s.save()
}