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, ×tamp, &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 }