Files
story/internal/onboarding/auth.go
T

185 lines
7.1 KiB
Go
Raw Normal View History

package onboarding
import (
"bytes"
"context"
"crypto/hmac"
"encoding/json"
"errors"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"data-terminal-cloud/internal/deviceauth"
"github.com/jackc/pgx/v5"
)
var factoryPattern = regexp.MustCompile(`^KP01-[0-9A-F]{12}$`)
type deviceContextKey struct{}
func DeviceFromContext(ctx context.Context) (Device, bool) {
d, ok := ctx.Value(deviceContextKey{}).(Device)
return d, ok
}
func (s *Service) authenticate(r *http.Request) (Device, []byte, error) {
id := r.Header.Get("X-Device-ID")
nonce := r.Header.Get("X-Nonce")
requestID := r.Header.Get("X-Request-ID")
version, ve := strconv.ParseInt(r.Header.Get("X-Key-Version"), 10, 64)
timestamp, te := strconv.ParseInt(r.Header.Get("X-Timestamp-Ms"), 10, 64)
if strconv.FormatInt(version, 10) != r.Header.Get("X-Key-Version") || strconv.FormatInt(timestamp, 10) != r.Header.Get("X-Timestamp-Ms") {
return Device{}, nil, errAuth
}
signature := strings.TrimPrefix(r.Header.Get("Authorization"), "Device-HMAC-SHA256 ")
if !factoryPattern.MatchString(id) || ve != nil || te != nil || version < 1 || !validB64(nonce, 16) || !uuidV4(requestID) || !validDigest(signature) || signature == r.Header.Get("Authorization") || r.URL.RawQuery != "" || strings.ContainsAny(r.URL.EscapedPath(), "\r\n") {
return Device{}, nil, errAuth
}
raw, err := io.ReadAll(io.LimitReader(r.Body, maxBody+1))
if err != nil || len(raw) > maxBody {
return Device{}, nil, fail(413, "REQUEST_TOO_LARGE", "请求正文过长")
}
r.Body = io.NopCloser(bytes.NewReader(raw))
d, err := s.findFactoryDevice(r.Context(), id, version)
if err != nil {
return Device{}, nil, err
}
expected := RequestMAC(d.Key, r.Method, r.URL.EscapedPath(), id, version, timestamp, nonce, requestID, raw)
if !hmac.Equal([]byte(expected), []byte(signature)) || time.UnixMilli(timestamp).Before(s.now().Add(-120*time.Second)) || time.UnixMilli(timestamp).After(s.now().Add(120*time.Second)) {
return Device{}, nil, errAuth
}
fresh, err := s.redis.SetNX(r.Context(), "onboarding:nonce:"+id+":"+strconv.FormatInt(version, 10)+":"+nonce, "1", 5*time.Minute).Result()
if err != nil {
return Device{}, nil, fail(503, "AUTHENTICATION_UNAVAILABLE", "认证服务暂不可用")
}
if !fresh {
return Device{}, nil, errAuth
}
if !deviceAllowed(d) {
return Device{}, nil, errAuth
}
return d, raw, nil
}
func (s *Service) findFactoryDevice(ctx context.Context, factoryID string, version int64) (Device, error) {
var id string
err := s.pool.QueryRow(ctx, `SELECT id::text FROM device WHERE factory_device_id=$1 AND enrollment_mode='physical_confirm'`, factoryID).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return Device{}, errAuth
}
if err != nil {
return Device{}, err
}
d, err := readDevice(ctx, s.pool, id, false)
if err != nil {
return d, err
}
var ciphertext []byte
err = s.pool.QueryRow(ctx, `SELECT ciphertext FROM device_onboarding_key WHERE device_id=$1::uuid AND key_version=$2 AND status='ACTIVE'`, id, version).Scan(&ciphertext)
if errors.Is(err, pgx.ErrNoRows) {
return d, errAuth
}
if err != nil {
return d, err
}
d.Key, err = s.crypt.open(ciphertext, keyAAD(id, version))
if err != nil || len(d.Key) != 32 {
return d, errAuth
}
d.KeyVersion = version
return d, nil
}
// Verify adapts explicitly enrolled devices for the existing voice interfaces.
// A failed signature is never retried with the other signing algorithm.
func (s *Service) Verify(r *http.Request) (deviceauth.Identity, error) {
id := r.Header.Get("X-Device-ID")
if factoryPattern.MatchString(id) {
if !s.secure(r) {
return deviceauth.Identity{}, deviceauth.ErrCredentialInvalid
}
d, raw, err := s.authenticate(r)
if err != nil {
return deviceauth.Identity{}, deviceauth.ErrCredentialInvalid
}
if len(raw) > 0 {
var body map[string]any
if err = StrictJSON(raw, &body); err != nil {
return deviceauth.Identity{}, deviceauth.ErrCredentialInvalid
}
}
test, err := s.runtimeAdmission(r, d, raw)
if err != nil {
return deviceauth.Identity{}, err
}
return deviceauth.Identity{ID: d.ID, PublicID: d.PublicID, TenantID: d.TenantID, Status: d.Status, ActivationState: d.Lifecycle, VoiceRuntime: "volc_hardware_agent", CredentialRef: "onboarding://" + d.FactoryID, EnrollmentMode: d.Mode, BindingGeneration: d.Generation, ActivationTest: test}, nil
}
if s.options.LegacyVerifier == nil {
return deviceauth.Identity{}, deviceauth.ErrCredentialInvalid
}
legacy, err := s.options.LegacyVerifier.Verify(r)
if err != nil {
return legacy, err
}
var mode string
if err = s.pool.QueryRow(r.Context(), `SELECT enrollment_mode FROM device WHERE id=$1::uuid`, legacy.ID).Scan(&mode); err != nil {
return legacy, err
}
if mode != "legacy" {
return deviceauth.Identity{}, deviceauth.ErrCredentialInvalid
}
return legacy, nil
}
type TimeChallengeInput struct {
DeviceID string `json:"device_id"`
KeyVersion int64 `json:"key_version"`
BootID string `json:"boot_id"`
ClientNonce string `json:"client_nonce"`
}
func (s *Service) TimeChallenge(ctx context.Context, input TimeChallengeInput) (map[string]any, error) {
if !factoryPattern.MatchString(input.DeviceID) || input.KeyVersion < 1 || !uuidV4(input.BootID) || !validB64(input.ClientNonce, 16) {
return nil, errAuth
}
d, err := s.findFactoryDevice(ctx, input.DeviceID, input.KeyVersion)
if err != nil {
return nil, err
}
if !deviceAllowed(d) {
return nil, errAuth
}
if err := s.limit(ctx, "clock:"+d.ID, 30, time.Minute); err != nil {
return nil, err
}
var challenge string
var timestamp int64
var expires time.Time
err = s.pool.QueryRow(ctx, `INSERT INTO device_time_challenge(device_id,key_version,boot_id,client_nonce,server_time_ms,expires_at) VALUES($1::uuid,$2,$3::uuid,$4,$5,$6) ON CONFLICT(device_id,key_version,boot_id,client_nonce) DO UPDATE SET client_nonce=EXCLUDED.client_nonce RETURNING id::text,server_time_ms,expires_at`, d.ID, d.KeyVersion, input.BootID, input.ClientNonce, s.now().UnixMilli(), s.now().Add(120*time.Second)).Scan(&challenge, &timestamp, &expires)
if err != nil {
return nil, err
}
if !expires.After(s.now()) {
return nil, fail(410, "CHALLENGE_EXPIRED", "请使用新的校时随机数")
}
raw, _ := json.Marshal(map[string]any{"device_id": d.FactoryID, "boot_id": input.BootID, "client_nonce": input.ClientNonce, "challenge_id": challenge, "server_time_ms": timestamp, "expires_at_ms": expires.UnixMilli()})
encoded := encode64(raw)
return map[string]any{"payload_b64": encoded, "mac_hex": domainMAC(d.Key, "TIME1\n"+encoded)}, nil
}
func (s *Service) limit(ctx context.Context, key string, max int64, window time.Duration) error {
// Atomic expiry: a process failure between INCR and EXPIRE must not leave
// a permanently exhausted rate-limit key.
n, err := s.redis.Eval(ctx, `local n=redis.call('INCR',KEYS[1]); if n==1 then redis.call('PEXPIRE',KEYS[1],ARGV[1]); end; return n`, []string{"onboarding:limit:" + key}, window.Milliseconds()).Int64()
if err != nil {
return fail(503, "RATE_LIMIT_UNAVAILABLE", "访问控制暂不可用")
}
if n > max {
return &Error{Status: 429, Code: "RATE_LIMITED", Message: "请求过于频繁,请稍后重试", Recovery: "RETRY"}
}
return nil
}