Files
story/internal/onboarding/inventory.go
T

300 lines
13 KiB
Go
Raw Normal View History

package onboarding
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"regexp"
"strings"
"time"
"data-terminal-cloud/internal/deviceactivation"
"data-terminal-cloud/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
func encode64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
type operator struct{ ID, PublicID, TenantID, Role string }
func operatorAccess(ctx context.Context, q queryer, principal identity.Principal, tenantPublicID string, write bool) (operator, error) {
if !uuidValid(tenantPublicID) {
return operator{}, errAccess
}
var a operator
err := q.QueryRow(ctx, `SELECT ua.id::text,ua.public_id::text,t.id::text,ua.platform_role FROM user_account ua JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE t.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND ua.status='active' AND ua.account_kind='operator'`, tenantPublicID, principal.Issuer, principal.Subject).Scan(&a.ID, &a.PublicID, &a.TenantID, &a.Role)
if errors.Is(err, pgx.ErrNoRows) {
return a, errAccess
}
if err != nil {
return a, err
}
if write && a.Role != "ops_admin" && a.Role != "ops_operator" {
return a, errAccess
}
return a, nil
}
type InventoryInput struct {
FactoryMAC string `json:"factory_mac"`
Model string `json:"hardware_model"`
HardwareRevision string `json:"hardware_revision"`
BatchNo string `json:"batch_no"`
}
func (s *Service) CreateInventory(ctx context.Context, principal identity.Principal, tenant, requestID, idempotencyKey string, raw []byte, input InventoryInput) (map[string]any, error) {
if len(idempotencyKey) < 16 || len(idempotencyKey) > 160 || !regexp.MustCompile(`^[0-9A-F]{12}$`).MatchString(input.FactoryMAC) || len(input.Model) < 1 || len(input.Model) > 32 || len(input.HardwareRevision) < 1 || len(input.HardwareRevision) > 16 || len(input.BatchNo) < 1 || len(input.BatchNo) > 120 {
return nil, fail(400, "VALIDATION_FAILED", "生产登记字段不正确")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
a, err := operatorAccess(ctx, tx, principal, tenant, true)
if err != nil {
return nil, err
}
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "inventory-request:"+a.TenantID+":"+a.ID+":"+idempotencyKey); err != nil {
return nil, err
}
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "inventory:"+input.FactoryMAC); err != nil {
return nil, err
}
var jobID, deviceID, storedDigest string
err = tx.QueryRow(ctx, `SELECT id::text,device_id::text,request_digest FROM device_enrollment_job WHERE tenant_id=$1::uuid AND actor_id=$2::uuid AND request_key=$3`, a.TenantID, a.ID, idempotencyKey).Scan(&jobID, &deviceID, &storedDigest)
if err == nil {
if storedDigest != digest(raw) {
return nil, fail(409, "REQUEST_BODY_CONFLICT", "生产请求号已用于其他内容")
}
d, e := readDevice(ctx, tx, deviceID, false)
if e != nil {
return nil, e
}
return inventoryResult(d, jobID), tx.Commit(ctx)
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
var existingTenant, model, revision string
err = tx.QueryRow(ctx, `SELECT id::text,tenant_id::text,model,COALESCE(hardware_revision,'') FROM device WHERE factory_mac=$1 FOR UPDATE`, input.FactoryMAC).Scan(&deviceID, &existingTenant, &model, &revision)
var d Device
if err == nil {
if existingTenant != a.TenantID || model != input.Model || revision != input.HardwareRevision {
return nil, fail(409, "INVENTORY_CONFLICT", "设备已有不同的生产或租户记录")
}
d, err = readDevice(ctx, tx, deviceID, false)
if err != nil {
return nil, err
}
// A repair reuses the latest credential job. It never generates a key.
err = tx.QueryRow(ctx, `SELECT id::text FROM device_enrollment_job WHERE device_id=$1::uuid ORDER BY created_at DESC,id DESC LIMIT 1`, deviceID).Scan(&jobID)
if err != nil {
return nil, err
}
return inventoryResult(d, jobID), tx.Commit(ctx)
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
d = Device{ID: uuid.NewString(), PublicID: uuid.NewString(), TenantID: a.TenantID, FactoryID: "KP01-" + input.FactoryMAC, Model: input.Model, HardwareRevision: input.HardwareRevision, Mode: "physical_confirm"}
_, err = tx.Exec(ctx, `INSERT INTO device(id,public_id,tenant_id,serial_digest,model,hardware_revision,credential_ref,activation_state,activation_reason,enrollment_mode,factory_device_id,factory_mac) VALUES($1::uuid,$2::uuid,$3::uuid,$4,$5,$6,$7,'FACTORY','controlled-production','physical_confirm',$8,$9)`, d.ID, d.PublicID, d.TenantID, digest([]byte(d.FactoryID)), d.Model, d.HardwareRevision, "onboarding://"+d.FactoryID, d.FactoryID, input.FactoryMAC)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `INSERT INTO device_twin(device_id,tenant_id,desired_config) VALUES($1::uuid,$2::uuid,'{"voice_runtime":"volc_hardware_agent"}'::jsonb)`, d.ID, d.TenantID)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `INSERT INTO device_onboarding_key(device_id,tenant_id,key_version,ciphertext,status) VALUES($1::uuid,$2::uuid,1,$3,'PENDING')`, d.ID, d.TenantID, s.crypt.seal(randomBytes(32), keyAAD(d.ID, 1)))
if err != nil {
return nil, err
}
err = tx.QueryRow(ctx, `INSERT INTO device_enrollment_job(device_id,tenant_id,actor_id,request_key,request_digest,batch_no) VALUES($1::uuid,$2::uuid,$3::uuid,$4,$5,$6) RETURNING id::text`, d.ID, d.TenantID, a.ID, idempotencyKey, digest(raw), input.BatchNo).Scan(&jobID)
if err != nil {
return nil, err
}
if err = s.auditEvent(ctx, tx, d, "user", a.PublicID, "device.inventory.create", requestID, map[string]any{"factory_device_id": d.FactoryID, "enrollment_job_id": jobID}); err != nil {
return nil, err
}
return inventoryResult(d, jobID), tx.Commit(ctx)
}
func inventoryResult(d Device, job string) map[string]any {
return map[string]any{"device_id": d.FactoryID, "platform_device_id": d.PublicID, "enrollment_job_id": job, "contract_version": ContractVersion, "released": d.Released}
}
type DeliveryInput struct {
JobID string `json:"enrollment_job_id"`
ReceiverKey string `json:"receiver_public_key_pem"`
}
func (s *Service) DeliverCredential(ctx context.Context, p identity.Principal, tenant, publicID, requestID string, input DeliveryInput) (map[string]any, error) {
if !uuidValid(publicID) || !uuidV4(input.JobID) {
return nil, fail(400, "VALIDATION_FAILED", "交付任务编号不正确")
}
block, _ := pem.Decode([]byte(input.ReceiverKey))
if block == nil {
return nil, fail(400, "INVALID_RECEIVER_KEY", "需要RSA公钥")
}
parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fail(400, "INVALID_RECEIVER_KEY", "需要PKIX格式的RSA公钥")
}
key, ok := parsed.(*rsa.PublicKey)
if !ok || key.N.BitLen() < 2048 || key.N.BitLen() > 4096 || key.E != 65537 {
return nil, fail(400, "INVALID_RECEIVER_KEY", "需要2048至4096位RSA公钥")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
a, err := operatorAccess(ctx, tx, p, tenant, true)
if err != nil {
return nil, err
}
var id string
err = tx.QueryRow(ctx, `SELECT id::text FROM device WHERE public_id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, publicID, a.TenantID).Scan(&id)
if err != nil {
return nil, errAccess
}
d, err := readDevice(ctx, tx, id, false)
if err != nil {
return nil, err
}
var version int64
var storedKey, status string
var delivered []byte
var expiry *time.Time
err = tx.QueryRow(ctx, `SELECT key_version,COALESCE(receiver_key,''),delivery_ciphertext,delivery_expires_at,status FROM device_enrollment_job WHERE id=$1::uuid AND device_id=$2::uuid AND tenant_id=$3::uuid FOR UPDATE`, input.JobID, id, a.TenantID).Scan(&version, &storedKey, &delivered, &expiry, &status)
if err != nil {
return nil, errAccess
}
if storedKey != "" && storedKey != input.ReceiverKey {
return nil, fail(409, "RECEIVER_CONFLICT", "同一交付任务不能更换接收方")
}
if status == "RELEASED" || status == "CANCELLED" {
return nil, fail(409, "DELIVERY_CLOSED", "该交付任务已经关闭")
}
if len(delivered) > 0 && expiry != nil && expiry.After(s.now()) {
var result map[string]any
if err = json.Unmarshal(delivered, &result); err != nil {
return nil, err
}
return result, tx.Commit(ctx)
}
var encrypted []byte
err = tx.QueryRow(ctx, `SELECT ciphertext FROM device_onboarding_key WHERE device_id=$1::uuid AND key_version=$2 AND status='PENDING'`, id, version).Scan(&encrypted)
if err != nil {
return nil, err
}
secret, err := s.crypt.open(encrypted, keyAAD(id, version))
if err != nil {
return nil, err
}
// Envelope encryption supports a full manufacturing payload independently
// of the RSA OAEP plaintext size. The receiver unwraps a random AES key.
envelopeKey := randomBytes(32)
ec, _ := newCryptor(fmtHex(envelopeKey))
until := s.now().Add(15 * time.Minute)
plain, _ := json.Marshal(map[string]any{"device_id": d.FactoryID, "platform_device_id": d.PublicID, "device_key_b64": encode64(secret), "key_version": version, "contract_version": ContractVersion, "expires_at_ms": until.UnixMilli()})
wrapped, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, key, envelopeKey, []byte(input.JobID))
if err != nil {
return nil, err
}
result := map[string]any{"algorithm": "RSA-OAEP-SHA256+A256GCM", "enrollment_job_id": input.JobID, "wrapped_key_b64": encode64(wrapped), "ciphertext_b64": encode64(ec.seal(plain, input.JobID)), "expires_at_ms": until.UnixMilli()}
saved, _ := json.Marshal(result)
_, err = tx.Exec(ctx, `UPDATE device_enrollment_job SET receiver_key=$2,delivery_ciphertext=$3,delivery_expires_at=$4,status='DELIVERED' WHERE id=$1::uuid`, input.JobID, input.ReceiverKey, saved, until)
if err != nil {
return nil, err
}
if err = s.auditEvent(ctx, tx, d, "user", a.PublicID, "device.credential.deliver", requestID, map[string]any{"job_id": input.JobID, "key_version": version}); err != nil {
return nil, err
}
return result, tx.Commit(ctx)
}
func fmtHex(b []byte) string {
const alphabet = "0123456789abcdef"
out := make([]byte, len(b)*2)
for i, v := range b {
out[i*2] = alphabet[v>>4]
out[i*2+1] = alphabet[v&15]
}
return string(out)
}
type ReleaseInput struct {
JobID string `json:"enrollment_job_id"`
Evidence string `json:"evidence_ref"`
ExpectedVersion int64 `json:"expected_key_version"`
}
func (s *Service) ReleaseInventory(ctx context.Context, p identity.Principal, tenant, publicID, requestID string, in ReleaseInput) (map[string]any, error) {
if !uuidValid(publicID) || !uuidV4(in.JobID) || len(strings.TrimSpace(in.Evidence)) < 3 || len(in.Evidence) > 500 || in.ExpectedVersion < 1 {
return nil, fail(400, "VALIDATION_FAILED", "需要烧录校验记录和凭据版本")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
a, err := operatorAccess(ctx, tx, p, tenant, true)
if err != nil {
return nil, err
}
var id string
err = tx.QueryRow(ctx, `SELECT id::text FROM device WHERE public_id=$1::uuid AND tenant_id=$2::uuid AND enrollment_mode='physical_confirm' AND status='registered' AND activation_state NOT IN ('LOST','REVOKED') FOR UPDATE`, publicID, a.TenantID).Scan(&id)
if err != nil {
return nil, errAccess
}
d, err := readDevice(ctx, tx, id, false)
if err != nil {
return nil, err
}
var status string
var version int64
err = tx.QueryRow(ctx, `SELECT status,key_version FROM device_enrollment_job WHERE id=$1::uuid AND device_id=$2::uuid FOR UPDATE`, in.JobID, id).Scan(&status, &version)
if err != nil {
return nil, errAccess
}
if version != in.ExpectedVersion || (status != "DELIVERED" && status != "RELEASED") {
return nil, fail(409, "ENROLLMENT_NOT_DELIVERED", "请先完成对应版本凭据交付")
}
if status == "RELEASED" {
return inventoryResult(d, in.JobID), tx.Commit(ctx)
}
_, err = tx.Exec(ctx, `UPDATE device_onboarding_key SET status='REVOKED' WHERE device_id=$1::uuid AND status='ACTIVE'`, id)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `UPDATE device_onboarding_key SET status='ACTIVE',activated_at=now() WHERE device_id=$1::uuid AND key_version=$2 AND status='PENDING'`, id, version)
if err != nil {
return nil, err
}
if d.Lifecycle == "FACTORY" {
if err = requireLifecycle(ctx, tx, d, deviceactivation.Claimable, requestID); err != nil {
return nil, err
}
}
_, err = tx.Exec(ctx, `UPDATE device SET onboarding_released_at=COALESCE(onboarding_released_at,now()),onboarding_revision=onboarding_revision+1,version=version+1,updated_at=now() WHERE id=$1::uuid`, id)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `UPDATE device_enrollment_job SET status='RELEASED',evidence_ref=$2,delivery_ciphertext=NULL WHERE id=$1::uuid`, in.JobID, in.Evidence)
if err != nil {
return nil, err
}
if err = s.auditEvent(ctx, tx, d, "user", a.PublicID, "device.inventory.release", requestID, map[string]any{"job_id": in.JobID, "key_version": version, "evidence_ref": in.Evidence}); err != nil {
return nil, err
}
d.Released = true
return inventoryResult(d, in.JobID), tx.Commit(ctx)
}