371 lines
16 KiB
Go
371 lines
16 KiB
Go
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"
|
|
"data-terminal-cloud/internal/devicebinding"
|
|
|
|
"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)
|
|
}
|
|
if err := devicebinding.RequireLegacy(ctx, tx, identity.ID); err != nil {
|
|
return ProvisionResponse{}, 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))
|
|
}
|