This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user