@@ -0,0 +1,166 @@
|
||||
package hardwareprovision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultDynamicRegisterURL = "https://iot-cn-shanghai.iot.volces.com/2021-12-14/DynamicRegister?Action=DynamicRegister&Version=2021-12-14"
|
||||
|
||||
type RegisterInput struct {
|
||||
InstanceID string
|
||||
ProductKey string
|
||||
DeviceName string
|
||||
}
|
||||
|
||||
type RegisterResult struct {
|
||||
DeviceSecret string
|
||||
RTCAppID string
|
||||
}
|
||||
|
||||
type Registrar interface {
|
||||
Register(context.Context, RegisterInput) (RegisterResult, error)
|
||||
}
|
||||
|
||||
type HTTPRegistrar struct {
|
||||
endpoint string
|
||||
productSecret string
|
||||
client *http.Client
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewHTTPRegistrar(endpoint, productSecret string, timeout time.Duration) (*HTTPRegistrar, error) {
|
||||
endpoint = strings.TrimSpace(endpoint)
|
||||
if endpoint == "" {
|
||||
endpoint = defaultDynamicRegisterURL
|
||||
}
|
||||
if !strings.HasPrefix(endpoint, "https://") && !strings.HasPrefix(endpoint, "http://127.0.0.1:") && !strings.HasPrefix(endpoint, "http://localhost:") {
|
||||
return nil, errors.New("Volcengine dynamic registration URL must use HTTPS")
|
||||
}
|
||||
if len([]byte(strings.TrimSpace(productSecret))) < 16 {
|
||||
return nil, errors.New("Volcengine hardware product secret must contain at least 16 bytes")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
return nil, errors.New("Volcengine hardware registration timeout must be positive")
|
||||
}
|
||||
return &HTTPRegistrar{
|
||||
endpoint: endpoint, productSecret: strings.TrimSpace(productSecret),
|
||||
client: &http.Client{Timeout: timeout}, now: time.Now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *HTTPRegistrar) Register(ctx context.Context, input RegisterInput) (RegisterResult, error) {
|
||||
randomNumber, err := rand.Int(rand.Reader, big.NewInt(2_147_483_646))
|
||||
if err != nil {
|
||||
return RegisterResult{}, fmt.Errorf("generate dynamic registration nonce: %w", err)
|
||||
}
|
||||
randomValue := randomNumber.Int64() + 1
|
||||
timestamp := r.now().UTC().UnixMilli()
|
||||
content := fmt.Sprintf("auth_type=1&device_name=%s&random_num=%d&product_key=%s×tamp=%d", input.DeviceName, randomValue, input.ProductKey, timestamp)
|
||||
mac := hmac.New(sha256.New, []byte(r.productSecret))
|
||||
_, _ = mac.Write([]byte(content))
|
||||
signature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"InstanceID": input.InstanceID, "product_key": input.ProductKey, "device_name": input.DeviceName,
|
||||
"random_num": randomValue, "timestamp": timestamp, "auth_type": 1, "signature": signature,
|
||||
})
|
||||
if err != nil {
|
||||
return RegisterResult{}, fmt.Errorf("encode dynamic registration request: %w", err)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, r.endpoint, strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return RegisterResult{}, fmt.Errorf("create dynamic registration request: %w", err)
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := r.client.Do(request)
|
||||
if err != nil {
|
||||
return RegisterResult{}, fmt.Errorf("call Volcengine dynamic registration: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(response.Body, 64<<10))
|
||||
if err != nil {
|
||||
return RegisterResult{}, fmt.Errorf("read Volcengine dynamic registration response: %w", err)
|
||||
}
|
||||
var payload struct {
|
||||
ResponseMetadata struct {
|
||||
Error struct {
|
||||
Code string `json:"Code"`
|
||||
CodeN any `json:"CodeN"`
|
||||
Message string `json:"Message"`
|
||||
} `json:"Error"`
|
||||
} `json:"ResponseMetadata"`
|
||||
Result struct {
|
||||
Payload string `json:"payload"`
|
||||
RTCAppID string `json:"RTCAppID"`
|
||||
} `json:"Result"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return RegisterResult{}, fmt.Errorf("decode Volcengine dynamic registration response: HTTP %d", response.StatusCode)
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 || payload.ResponseMetadata.Error.Code != "" || payload.ResponseMetadata.Error.CodeN != nil {
|
||||
code := payload.ResponseMetadata.Error.Code
|
||||
if code == "" {
|
||||
code = fmt.Sprintf("HTTP_%d", response.StatusCode)
|
||||
}
|
||||
message := strings.TrimSpace(payload.ResponseMetadata.Error.Message)
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(payload.Message)
|
||||
}
|
||||
if message == "" {
|
||||
message = "Volcengine dynamic registration failed"
|
||||
}
|
||||
return RegisterResult{}, fmt.Errorf("%s: %s", code, message)
|
||||
}
|
||||
secret, err := decryptRegisteredDeviceSecret(payload.Result.Payload, r.productSecret)
|
||||
if err != nil {
|
||||
return RegisterResult{}, err
|
||||
}
|
||||
return RegisterResult{DeviceSecret: secret, RTCAppID: strings.TrimSpace(payload.Result.RTCAppID)}, nil
|
||||
}
|
||||
|
||||
func decryptRegisteredDeviceSecret(encoded, productSecret string) (string, error) {
|
||||
keyMaterial := []byte(productSecret)
|
||||
if len(keyMaterial) < aes.BlockSize {
|
||||
return "", errors.New("product secret is too short to decrypt device credential")
|
||||
}
|
||||
key := keyMaterial[:aes.BlockSize]
|
||||
encrypted, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded))
|
||||
if err != nil || len(encrypted) == 0 || len(encrypted)%aes.BlockSize != 0 {
|
||||
return "", errors.New("Volcengine dynamic registration returned an invalid device credential")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create device credential decryptor: %w", err)
|
||||
}
|
||||
plain := make([]byte, len(encrypted))
|
||||
cipher.NewCBCDecrypter(block, key).CryptBlocks(plain, encrypted)
|
||||
padding := int(plain[len(plain)-1])
|
||||
if padding < 1 || padding > aes.BlockSize || padding > len(plain) {
|
||||
return "", errors.New("Volcengine device credential padding is invalid")
|
||||
}
|
||||
for _, value := range plain[len(plain)-padding:] {
|
||||
if int(value) != padding {
|
||||
return "", errors.New("Volcengine device credential padding is invalid")
|
||||
}
|
||||
}
|
||||
secret := strings.TrimSpace(string(plain[:len(plain)-padding]))
|
||||
if len(secret) < 16 || len(secret) > 4096 {
|
||||
return "", errors.New("Volcengine device credential length is invalid")
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package hardwareprovision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
)
|
||||
|
||||
func TestHTTPRegistrarSignsAndDecryptsDynamicRegistration(t *testing.T) {
|
||||
const productSecret = "1234567890abcdef-product-secret"
|
||||
const deviceSecret = "per-device-secret-with-at-least-32-characters"
|
||||
var received map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := json.NewDecoder(request.Body).Decode(&received); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
content := "auth_type=1&device_name=" + received["device_name"].(string) +
|
||||
"&random_num=" + strconv.FormatInt(int64(received["random_num"].(float64)), 10) +
|
||||
"&product_key=" + received["product_key"].(string) +
|
||||
"×tamp=" + strconv.FormatInt(int64(received["timestamp"].(float64)), 10)
|
||||
mac := hmac.New(sha256.New, []byte(productSecret))
|
||||
_, _ = mac.Write([]byte(content))
|
||||
if received["signature"] != base64.StdEncoding.EncodeToString(mac.Sum(nil)) {
|
||||
t.Fatal("dynamic registration signature did not match canonical fields")
|
||||
}
|
||||
if _, leaked := received["product_secret"]; leaked {
|
||||
t.Fatal("product secret must not be serialized into the provider request")
|
||||
}
|
||||
_ = json.NewEncoder(writer).Encode(map[string]any{"Result": map[string]any{
|
||||
"payload": encryptDeviceSecretForTest(t, deviceSecret, productSecret), "RTCAppID": "rtc-app",
|
||||
}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
registrar, err := NewHTTPRegistrar(server.URL, productSecret, time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("NewHTTPRegistrar() error = %v", err)
|
||||
}
|
||||
registrar.now = func() time.Time { return time.UnixMilli(1_700_000_000_000) }
|
||||
result, err := registrar.Register(context.Background(), RegisterInput{InstanceID: "instance-1", ProductKey: "product-1", DeviceName: "device-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("Register() error = %v", err)
|
||||
}
|
||||
if result.DeviceSecret != deviceSecret || result.RTCAppID != "rtc-app" {
|
||||
t.Fatalf("Register() result = %+v", result)
|
||||
}
|
||||
if received["InstanceID"] != "instance-1" || received["product_key"] != "product-1" || received["device_name"] != "device-1" {
|
||||
t.Fatalf("unexpected registration request: %+v", received)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledProvisioningDoesNotRequireProductSecret(t *testing.T) {
|
||||
service, err := NewService(nil, nil, Options{Enabled: false})
|
||||
if err != nil {
|
||||
t.Fatalf("NewService() error = %v", err)
|
||||
}
|
||||
_, err = service.Provision(context.Background(), deviceauth.Identity{}, "request", ProvisionInput{HardwareID: "device"})
|
||||
if err != ErrUnavailable {
|
||||
t.Fatalf("Provision() error = %v, want ErrUnavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func encryptDeviceSecretForTest(t *testing.T, secret, productSecret string) string {
|
||||
t.Helper()
|
||||
key := []byte(productSecret)[:aes.BlockSize]
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain := []byte(secret)
|
||||
padding := aes.BlockSize - len(plain)%aes.BlockSize
|
||||
for index := 0; index < padding; index++ {
|
||||
plain = append(plain, byte(padding))
|
||||
}
|
||||
encrypted := make([]byte, len(plain))
|
||||
cipher.NewCBCEncrypter(block, key).CryptBlocks(encrypted, plain)
|
||||
return base64.StdEncoding.EncodeToString(encrypted)
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package hardwareprovision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/deviceactivation"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnavailable = errors.New("hardware provider provisioning is unavailable")
|
||||
ErrInvalid = errors.New("invalid hardware provider provisioning input")
|
||||
ErrBindingMissing = errors.New("hardware agent binding is missing")
|
||||
ErrBindingMismatch = errors.New("hardware agent product binding does not match platform provisioning configuration")
|
||||
ErrHardwareMismatch = errors.New("hardware identity does not match the provisioned credential")
|
||||
ErrActivationState = errors.New("device activation state does not allow provider provisioning")
|
||||
)
|
||||
|
||||
var hardwareIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_\-.:@]{1,200}$`)
|
||||
|
||||
type Options struct {
|
||||
Enabled bool
|
||||
InstanceID string
|
||||
ProductKey string
|
||||
BotID string
|
||||
Transport string
|
||||
MinimumSDKVersion string
|
||||
ProductSecret string
|
||||
CredentialMasterKey string
|
||||
RegisterURL string
|
||||
RegisterTimeout time.Duration
|
||||
DeviceNamePrefix string
|
||||
Registrar Registrar
|
||||
}
|
||||
|
||||
type ProvisionInput struct {
|
||||
HardwareID string `json:"hardware_id"`
|
||||
}
|
||||
|
||||
type ProvisionResponse struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
ProductKey string `json:"product_key"`
|
||||
DeviceName string `json:"device_name"`
|
||||
HardwareID string `json:"hardware_id"`
|
||||
DeviceSecret string `json:"device_secret"`
|
||||
CredentialRef string `json:"credential_ref"`
|
||||
CredentialVersion int64 `json:"credential_version"`
|
||||
CredentialSource string `json:"credential_source"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
enabled bool
|
||||
pool *pgxpool.Pool
|
||||
audit *audit.Repository
|
||||
registrar Registrar
|
||||
aead cipher.AEAD
|
||||
instanceID string
|
||||
productKey string
|
||||
botID string
|
||||
transport string
|
||||
minimumSDK string
|
||||
deviceNamePrefix string
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, auditRepository *audit.Repository, options Options) (*Service, error) {
|
||||
service := &Service{enabled: options.Enabled}
|
||||
if !options.Enabled {
|
||||
return service, nil
|
||||
}
|
||||
if pool == nil || auditRepository == nil {
|
||||
return nil, errors.New("hardware provider provisioning requires database and audit repository")
|
||||
}
|
||||
service.instanceID = strings.TrimSpace(options.InstanceID)
|
||||
service.productKey = strings.TrimSpace(options.ProductKey)
|
||||
service.botID = strings.TrimSpace(options.BotID)
|
||||
service.transport = strings.TrimSpace(options.Transport)
|
||||
service.minimumSDK = strings.TrimSpace(options.MinimumSDKVersion)
|
||||
service.deviceNamePrefix = strings.TrimSpace(options.DeviceNamePrefix)
|
||||
if service.deviceNamePrefix == "" {
|
||||
service.deviceNamePrefix = "dt-"
|
||||
}
|
||||
if service.instanceID == "" || len(service.instanceID) > 200 || service.productKey == "" || len(service.productKey) > 200 ||
|
||||
service.botID == "" || len(service.botID) > 200 || (service.transport != "high_quality" && service.transport != "low_load") ||
|
||||
service.minimumSDK == "" || len(service.minimumSDK) > 80 ||
|
||||
!regexp.MustCompile(`^[a-zA-Z0-9_\-.:@]{1,32}$`).MatchString(service.deviceNamePrefix) {
|
||||
return nil, errors.New("hardware provider instance, product defaults and device-name prefix are invalid")
|
||||
}
|
||||
key, err := hex.DecodeString(strings.TrimSpace(options.CredentialMasterKey))
|
||||
if err != nil || len(key) != 32 {
|
||||
return nil, errors.New("VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY must be 32 bytes encoded as 64 hexadecimal characters")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create hardware provider credential cipher: %w", err)
|
||||
}
|
||||
service.aead, err = cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create hardware provider credential AEAD: %w", err)
|
||||
}
|
||||
service.registrar = options.Registrar
|
||||
if service.registrar == nil {
|
||||
service.registrar, err = NewHTTPRegistrar(options.RegisterURL, options.ProductSecret, options.RegisterTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
service.pool = pool
|
||||
service.audit = auditRepository
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (s *Service) Provision(ctx context.Context, identity deviceauth.Identity, requestID string, input ProvisionInput) (ProvisionResponse, error) {
|
||||
if !s.enabled {
|
||||
return ProvisionResponse{}, ErrUnavailable
|
||||
}
|
||||
hardwareID := strings.TrimSpace(input.HardwareID)
|
||||
if !hardwareIDPattern.MatchString(hardwareID) {
|
||||
return ProvisionResponse{}, ErrInvalid
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return ProvisionResponse{}, fmt.Errorf("begin hardware provider provisioning: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, identity.ID); err != nil {
|
||||
return ProvisionResponse{}, fmt.Errorf("lock hardware provider provisioning: %w", err)
|
||||
}
|
||||
activationState, err := s.prepareActivation(ctx, tx, identity, requestID)
|
||||
if err != nil {
|
||||
return ProvisionResponse{}, err
|
||||
}
|
||||
if err := s.ensureProductBinding(ctx, tx, identity); err != nil {
|
||||
return ProvisionResponse{}, err
|
||||
}
|
||||
existing, found, err := s.readExisting(ctx, tx, identity.ID)
|
||||
if err != nil {
|
||||
return ProvisionResponse{}, err
|
||||
}
|
||||
if found {
|
||||
if existing.HardwareID != hardwareID {
|
||||
return ProvisionResponse{}, ErrHardwareMismatch
|
||||
}
|
||||
if existing.InstanceID != s.instanceID || existing.ProductKey != s.productKey {
|
||||
return ProvisionResponse{}, ErrBindingMismatch
|
||||
}
|
||||
secret, err := s.aead.Open(nil, existing.Nonce, existing.EncryptedSecret, credentialAAD(existing.CredentialRef, existing.KeyVersion, existing.InstanceID, existing.ProductKey, existing.DeviceName))
|
||||
if err != nil {
|
||||
return ProvisionResponse{}, errors.New("decrypt hardware provider device credential")
|
||||
}
|
||||
response := ProvisionResponse{InstanceID: existing.InstanceID, ProductKey: existing.ProductKey, DeviceName: existing.DeviceName,
|
||||
HardwareID: existing.HardwareID, DeviceSecret: string(secret), CredentialRef: existing.CredentialRef,
|
||||
CredentialVersion: existing.KeyVersion, CredentialSource: "platform_stored", IssuedAt: existing.IssuedAt}
|
||||
if existing.Status == "SUSPENDED" {
|
||||
if _, err := tx.Exec(ctx, `UPDATE hardware_agent_provider_credential SET status='ACTIVE',revoked_at=NULL,updated_at=now() WHERE device_id=$1::uuid AND status='SUSPENDED'`, identity.ID); err != nil {
|
||||
return ProvisionResponse{}, fmt.Errorf("reactivate hardware provider credential: %w", err)
|
||||
}
|
||||
}
|
||||
if activationState == deviceactivation.Provisioning {
|
||||
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: identity.ID, TenantID: identity.TenantID,
|
||||
Target: deviceactivation.Active, Reason: "火山运行凭据已就绪", Source: "provider-provisioning", ActorType: "device",
|
||||
ActorPublicID: &identity.PublicID, RequestID: requestID}); err != nil {
|
||||
return ProvisionResponse{}, fmt.Errorf("complete provider activation: %w", err)
|
||||
}
|
||||
}
|
||||
if err := s.recordAudit(ctx, tx, identity, requestID, "hardware_agent.provider_credential.resolve", response, true); err != nil {
|
||||
return ProvisionResponse{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return ProvisionResponse{}, fmt.Errorf("commit hardware provider credential resolution: %w", err)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
var configuredProduct, configuredDeviceName string
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT COALESCE(product_key,''),COALESCE(provider_device_ref,'')
|
||||
FROM hardware_agent_device_binding
|
||||
WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND provider='volcengine' AND status='ACTIVE'
|
||||
FOR UPDATE`, identity.ID, identity.TenantID).Scan(&configuredProduct, &configuredDeviceName)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ProvisionResponse{}, ErrBindingMissing
|
||||
}
|
||||
if err != nil {
|
||||
return ProvisionResponse{}, fmt.Errorf("read hardware agent provider binding: %w", err)
|
||||
}
|
||||
if configuredProduct != "" && configuredProduct != s.productKey {
|
||||
return ProvisionResponse{}, ErrBindingMismatch
|
||||
}
|
||||
deviceName := strings.TrimSpace(configuredDeviceName)
|
||||
if deviceName == "" {
|
||||
deviceName = s.deviceNamePrefix + strings.ReplaceAll(identity.PublicID, "-", "")
|
||||
}
|
||||
if len(deviceName) > 128 || !regexp.MustCompile(`^[a-zA-Z0-9_\-.:@]{1,128}$`).MatchString(deviceName) {
|
||||
return ProvisionResponse{}, ErrInvalid
|
||||
}
|
||||
registered, err := s.registrar.Register(ctx, RegisterInput{InstanceID: s.instanceID, ProductKey: s.productKey, DeviceName: deviceName})
|
||||
if err != nil {
|
||||
return ProvisionResponse{}, fmt.Errorf("register hardware provider device: %w", err)
|
||||
}
|
||||
secret := []byte(strings.TrimSpace(registered.DeviceSecret))
|
||||
if len(secret) < 16 || len(secret) > 4096 {
|
||||
return ProvisionResponse{}, errors.New("hardware provider returned an invalid device credential")
|
||||
}
|
||||
credentialRef := "volc-device-secret://" + identity.PublicID
|
||||
const keyVersion int64 = 1
|
||||
nonce := make([]byte, s.aead.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return ProvisionResponse{}, fmt.Errorf("generate hardware provider credential nonce: %w", err)
|
||||
}
|
||||
encrypted := s.aead.Seal(nil, nonce, secret, credentialAAD(credentialRef, keyVersion, s.instanceID, s.productKey, deviceName))
|
||||
issuedAt := time.Now().UTC()
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO hardware_agent_provider_credential(
|
||||
tenant_id,device_id,credential_ref,instance_id,product_key,provider_device_name,hardware_id,
|
||||
key_version,encrypted_secret,nonce,status,issued_at
|
||||
) VALUES($1::uuid,$2::uuid,$3,$4,$5,$6,$7,$8,$9,$10,'ACTIVE',$11)`,
|
||||
identity.TenantID, identity.ID, credentialRef, s.instanceID, s.productKey, deviceName, hardwareID,
|
||||
keyVersion, encrypted, nonce, issuedAt)
|
||||
if err != nil {
|
||||
return ProvisionResponse{}, fmt.Errorf("store hardware provider device credential: %w", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE hardware_agent_device_binding
|
||||
SET product_key=$2,provider_device_ref=$3,credential_ref=$4,updated_at=now()
|
||||
WHERE device_id=$1::uuid`, identity.ID, s.productKey, deviceName, credentialRef)
|
||||
if err != nil {
|
||||
return ProvisionResponse{}, fmt.Errorf("update hardware agent provider binding: %w", err)
|
||||
}
|
||||
response := ProvisionResponse{InstanceID: s.instanceID, ProductKey: s.productKey, DeviceName: deviceName, HardwareID: hardwareID,
|
||||
DeviceSecret: string(secret), CredentialRef: credentialRef, CredentialVersion: keyVersion,
|
||||
CredentialSource: "platform_dynamic_registration", IssuedAt: issuedAt}
|
||||
if activationState == deviceactivation.Provisioning {
|
||||
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: identity.ID, TenantID: identity.TenantID,
|
||||
Target: deviceactivation.Active, Reason: "火山动态注册与运行凭据已就绪", Source: "provider-provisioning", ActorType: "device",
|
||||
ActorPublicID: &identity.PublicID, RequestID: requestID}); err != nil {
|
||||
return ProvisionResponse{}, fmt.Errorf("complete provider activation: %w", err)
|
||||
}
|
||||
}
|
||||
if err := s.recordAudit(ctx, tx, identity, requestID, "hardware_agent.provider_credential.provision", response, false); err != nil {
|
||||
return ProvisionResponse{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return ProvisionResponse{}, fmt.Errorf("commit hardware provider provisioning: %w", err)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ensureProductBinding resolves the device runtime from the platform-owned
|
||||
// product defaults. DynamicRegister only creates the provider device identity;
|
||||
// it does not return a Bot ID, so devices must never supply or persist that
|
||||
// routing choice themselves.
|
||||
func (s *Service) ensureProductBinding(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity) error {
|
||||
var runtime string
|
||||
if err := tx.QueryRow(ctx, `SELECT COALESCE(desired_config->>'voice_runtime','legacy_voicechat') FROM device_twin WHERE device_id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, identity.ID, identity.TenantID).Scan(&runtime); errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrBindingMissing
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("read hardware agent runtime: %w", err)
|
||||
}
|
||||
if runtime != "volc_hardware_agent" {
|
||||
return ErrBindingMissing
|
||||
}
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO hardware_agent_device_binding(
|
||||
tenant_id,device_id,provider,product_key,bot_id,transport,minimum_sdk_version,status
|
||||
) VALUES($1::uuid,$2::uuid,'volcengine',$3,$4,$5,$6,'ACTIVE')
|
||||
ON CONFLICT(device_id) DO UPDATE SET
|
||||
product_key=EXCLUDED.product_key,bot_id=EXCLUDED.bot_id,transport=EXCLUDED.transport,
|
||||
minimum_sdk_version=EXCLUDED.minimum_sdk_version,status='ACTIVE',updated_at=now()`,
|
||||
identity.TenantID, identity.ID, s.productKey, s.botID, s.transport, s.minimumSDK)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bind hardware agent product defaults: %w", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE device_twin SET desired_revision=desired_revision+1,
|
||||
desired_config=desired_config||jsonb_build_object(
|
||||
'voice_runtime','volc_hardware_agent','hardware_agent_transport',$2::text,'minimum_sdk_version',$3::text
|
||||
),updated_at=now()
|
||||
WHERE device_id=$1::uuid AND (
|
||||
COALESCE(desired_config->>'hardware_agent_transport','')<>$2 OR
|
||||
COALESCE(desired_config->>'minimum_sdk_version','')<>$3
|
||||
)`, identity.ID, s.transport, s.minimumSDK)
|
||||
if err != nil {
|
||||
return fmt.Errorf("publish hardware agent product defaults: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type storedCredential struct {
|
||||
Status string
|
||||
CredentialRef string
|
||||
InstanceID string
|
||||
ProductKey string
|
||||
DeviceName string
|
||||
HardwareID string
|
||||
KeyVersion int64
|
||||
EncryptedSecret []byte
|
||||
Nonce []byte
|
||||
IssuedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Service) readExisting(ctx context.Context, tx pgx.Tx, deviceID string) (storedCredential, bool, error) {
|
||||
var item storedCredential
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT status,credential_ref,instance_id,product_key,provider_device_name,hardware_id,key_version,encrypted_secret,nonce,issued_at
|
||||
FROM hardware_agent_provider_credential
|
||||
WHERE device_id=$1::uuid AND provider='volcengine' AND status IN ('ACTIVE','SUSPENDED')
|
||||
FOR UPDATE`, deviceID).Scan(&item.Status, &item.CredentialRef, &item.InstanceID, &item.ProductKey, &item.DeviceName, &item.HardwareID,
|
||||
&item.KeyVersion, &item.EncryptedSecret, &item.Nonce, &item.IssuedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return storedCredential{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return storedCredential{}, false, fmt.Errorf("read hardware provider device credential: %w", err)
|
||||
}
|
||||
return item, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) prepareActivation(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, requestID string) (deviceactivation.State, error) {
|
||||
var stateText string
|
||||
if err := tx.QueryRow(ctx, `SELECT activation_state FROM device WHERE id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, identity.ID, identity.TenantID).Scan(&stateText); err != nil {
|
||||
return "", fmt.Errorf("read device activation for provisioning: %w", err)
|
||||
}
|
||||
state, err := deviceactivation.Parse(stateText)
|
||||
if err != nil {
|
||||
return "", ErrActivationState
|
||||
}
|
||||
if state == deviceactivation.Bound {
|
||||
result, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: identity.ID, TenantID: identity.TenantID,
|
||||
Target: deviceactivation.Provisioning, Reason: "设备请求火山运行凭据", Source: "provider-provisioning", ActorType: "device",
|
||||
ActorPublicID: &identity.PublicID, RequestID: requestID})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("start provider activation: %w", err)
|
||||
}
|
||||
return result.To, nil
|
||||
}
|
||||
if state != deviceactivation.Provisioning && state != deviceactivation.Active {
|
||||
return "", ErrActivationState
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *Service) recordAudit(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, requestID, action string, response ProvisionResponse, reused bool) error {
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &identity.TenantID, ActorType: "device", ActorPublicID: &identity.PublicID,
|
||||
Action: action, ResourceType: "device", ResourceID: &identity.PublicID, RequestID: requestID, Result: "success",
|
||||
Detail: map[string]any{"provider": "volcengine", "product_key": response.ProductKey, "provider_device_name": response.DeviceName,
|
||||
"credential_version": response.CredentialVersion, "reused": reused}})
|
||||
}
|
||||
|
||||
func credentialAAD(reference string, version int64, instanceID, productKey, deviceName string) []byte {
|
||||
return []byte(fmt.Sprintf("hardware-provider-credential-v1\n%s\n%d\n%s\n%s\n%s", reference, version, instanceID, productKey, deviceName))
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package hardwareprovision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type countingRegistrar struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
}
|
||||
|
||||
func (r *countingRegistrar) Register(_ context.Context, _ RegisterInput) (RegisterResult, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.calls++
|
||||
return RegisterResult{DeviceSecret: "provider-device-secret-with-at-least-32-characters"}, nil
|
||||
}
|
||||
|
||||
func TestProvisionPersistsEncryptedCredentialAndReusesRegistration(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
|
||||
deviceID := uuid.NewString()
|
||||
devicePublicID := uuid.NewString()
|
||||
const tenantID = "10000000-0000-0000-0000-000000000001"
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO device(id,public_id,tenant_id,serial_digest,model,status,credential_ref,activation_state,activation_reason)
|
||||
VALUES($1::uuid,$2::uuid,$3::uuid,$4,'hardware-provision-test','registered',$5,'BOUND','integration-test')`,
|
||||
deviceID, devicePublicID, tenantID, strings.Repeat("9", 64), "test://"+devicePublicID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO device_twin(tenant_id,device_id,desired_revision,desired_config)
|
||||
VALUES($1::uuid,$2::uuid,1,'{"voice_runtime":"volc_hardware_agent"}'::jsonb)`, tenantID, deviceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE resource_id=$1::uuid`, devicePublicID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM hardware_agent_provider_credential WHERE device_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM hardware_agent_device_binding WHERE device_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_twin WHERE device_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_activation_transition WHERE device_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device WHERE id=$1::uuid`, deviceID)
|
||||
})
|
||||
|
||||
registrar := &countingRegistrar{}
|
||||
service, err := NewService(pool, audit.NewRepository(), Options{
|
||||
Enabled: true, InstanceID: "instance-1", ProductKey: "product-1", BotID: "bot-test", Transport: "low_load", MinimumSDKVersion: "1.1.0", ProductSecret: "unused-with-mock-registrar",
|
||||
CredentialMasterKey: strings.Repeat("ab", 32), RegisterTimeout: time.Second, DeviceNamePrefix: "dt-", Registrar: registrar,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity := deviceauth.Identity{ID: deviceID, PublicID: devicePublicID, TenantID: tenantID, Status: "registered"}
|
||||
first, err := service.Provision(ctx, identity, "request-1", ProvisionInput{HardwareID: "hardware-1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := service.Provision(ctx, identity, "request-2", ProvisionInput{HardwareID: "hardware-1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.DeviceSecret != second.DeviceSecret || first.CredentialSource != "platform_dynamic_registration" || second.CredentialSource != "platform_stored" {
|
||||
t.Fatalf("unexpected provisioning responses: first=%+v second=%+v", first, second)
|
||||
}
|
||||
registrar.mu.Lock()
|
||||
calls := registrar.calls
|
||||
registrar.mu.Unlock()
|
||||
if calls != 1 {
|
||||
t.Fatalf("registrar calls = %d, want 1", calls)
|
||||
}
|
||||
var activationState string
|
||||
if err := pool.QueryRow(ctx, `SELECT activation_state FROM device WHERE id=$1::uuid`, deviceID).Scan(&activationState); err != nil || activationState != "ACTIVE" {
|
||||
t.Fatalf("activation state=%s err=%v, want ACTIVE", activationState, err)
|
||||
}
|
||||
var encrypted []byte
|
||||
var productKey, deviceName, credentialRef, botID, transport, minimumSDK string
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT pc.encrypted_secret,hb.product_key,hb.provider_device_ref,hb.credential_ref,hb.bot_id,hb.transport,hb.minimum_sdk_version
|
||||
FROM hardware_agent_provider_credential pc JOIN hardware_agent_device_binding hb ON hb.device_id=pc.device_id
|
||||
WHERE pc.device_id=$1::uuid`, deviceID).Scan(&encrypted, &productKey, &deviceName, &credentialRef, &botID, &transport, &minimumSDK); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encrypted), first.DeviceSecret) || productKey != "product-1" || deviceName != first.DeviceName || credentialRef != first.CredentialRef || botID != "bot-test" || transport != "low_load" || minimumSDK != "1.1.0" {
|
||||
t.Fatalf("credential was not stored/bound safely")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user