Files

171 lines
7.0 KiB
Go
Raw Permalink Normal View History

// Controlled, explicitly approved migration of one legacy device. Never deletes history.
package main
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"regexp"
"strings"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/deviceactivation"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func secret(name string) (string, error) {
if path := os.Getenv(name + "_FILE"); path != "" {
b, err := os.ReadFile(path)
return strings.TrimSpace(string(b)), err
}
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return "", fmt.Errorf("%s is not configured", name)
}
return value, nil
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
public := flag.String("device", "", "exact existing public UUID")
binding := flag.String("expected-binding", "", "exact current binding UUID, or none")
mac := flag.String("factory-mac", "", "verified hardware MAC, uppercase 12 hex")
output := flag.String("output", "", "new private credential JSON file")
confirmed := flag.Bool("confirm-unbind-and-migrate", false, "requires explicit owner authorization")
dryRun := flag.Bool("dry-run", false, "validate the complete transaction, then roll it back without exporting keys")
flag.Parse()
if !*confirmed || uuid.Validate(*public) != nil || (*binding != "none" && uuid.Validate(*binding) != nil) || !regexp.MustCompile(`^[0-9A-F]{12}$`).MatchString(*mac) || *output == "" {
return errors.New("exact device, expected binding, factory MAC, private output and explicit confirmation are required")
}
if _, err := os.Stat(*output); !errors.Is(err, os.ErrNotExist) {
return errors.New("credential output already exists or is inaccessible; inspect previous attempt before retry")
}
dsn, err := secret("DATABASE_URL")
if err != nil {
return err
}
masterHex, err := secret("ONBOARDING_MASTER_KEY")
if err != nil {
return err
}
master, err := hex.DecodeString(masterHex)
if err != nil || len(master) != 32 {
return errors.New("invalid onboarding master key")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return err
}
defer pool.Close()
tx, err := pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var id, tenant, mode, lifecycle, status string
err = tx.QueryRow(ctx, `SELECT id::text,tenant_id::text,enrollment_mode,activation_state,status FROM device WHERE public_id=$1::uuid FOR UPDATE`, *public).Scan(&id, &tenant, &mode, &lifecycle, &status)
if err != nil {
return err
}
if mode != "legacy" || status != "registered" || (lifecycle != "ACTIVE" && lifecycle != "BOUND" && lifecycle != "CLAIMABLE") {
return errors.New("device is not an eligible legacy identity; no changes made")
}
var current string
var busy, hasKey bool
err = tx.QueryRow(ctx, `SELECT COALESCE((SELECT id::text FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL),'none'),EXISTS(SELECT 1 FROM conversation WHERE device_id=$1::uuid AND status NOT IN ('COMPLETED','FAILED','EXPIRED')),EXISTS(SELECT 1 FROM device_onboarding_key WHERE device_id=$1::uuid)`, id).Scan(&current, &busy, &hasKey)
if err != nil {
return err
}
if current != *binding || busy || hasKey {
return errors.New("binding changed, conversation still running, or onboarding key already exists; no changes made")
}
requestID := uuid.NewString()
if current != "none" {
if _, err = tx.Exec(ctx, `UPDATE device_binding SET unbound_at=now(),reason='用户明确授权:迁移蓝牙配网,保留历史记录' WHERE id=$1::uuid AND unbound_at IS NULL`, current); err != nil {
return err
}
}
if _, err = deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: id, TenantID: tenant, Target: deviceactivation.Claimable, Reason: "用户授权旧设备迁移蓝牙注册", Source: "ble-device-migrate", ActorType: "system", RequestID: requestID}); err != nil {
return err
}
for _, sql := range []string{
`UPDATE device_claim SET revoked_at=now() WHERE device_id=$1::uuid AND used_at IS NULL AND revoked_at IS NULL`,
`UPDATE hardware_agent_provider_credential SET status='SUSPENDED',updated_at=now() WHERE device_id=$1::uuid AND status='ACTIVE'`,
`UPDATE hardware_agent_device_binding SET status='DISABLED',updated_at=now() WHERE device_id=$1::uuid AND status='ACTIVE'`,
} {
if _, err = tx.Exec(ctx, sql, id); err != nil {
return err
}
}
factoryID := "KP01-" + *mac
sum := sha256.Sum256([]byte(factoryID))
_, err = tx.Exec(ctx, `UPDATE device SET enrollment_mode='physical_confirm',factory_device_id=$2,factory_mac=$3,model='ECHOEAR-S3',hardware_revision='V1.2',credential_ref=$4,serial_digest=$5,onboarding_released_at=now(),onboarding_boot_id=NULL,onboarding_revision=onboarding_revision+1,version=version+1,updated_at=now() WHERE id=$1::uuid`, id, factoryID, *mac, "onboarding://"+factoryID, hex.EncodeToString(sum[:]))
if err != nil {
return err
}
key := make([]byte, 32)
if _, err = rand.Read(key); err != nil {
return err
}
block, err := aes.NewCipher(master)
if err != nil {
return err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return err
}
nonce := make([]byte, aead.NonceSize())
if _, err = rand.Read(nonce); err != nil {
return err
}
encrypted := aead.Seal(nonce, nonce, key, []byte("ONBOARDING-KEY1\n"+id+"\n1"))
_, err = tx.Exec(ctx, `INSERT INTO device_onboarding_key(device_id,tenant_id,key_version,ciphertext,status,activated_at) VALUES($1::uuid,$2::uuid,1,$3,'ACTIVE',now())`, id, tenant, encrypted)
if err != nil {
return err
}
if err = audit.NewRepository().Record(ctx, tx, audit.Event{TenantID: &tenant, ActorType: "system", Action: "device.ble.migrate", ResourceType: "device", ResourceID: public, RequestID: requestID, Result: "success", Detail: map[string]any{"factory_device_id": factoryID, "previous_binding_id": current, "user_approved_unbind": true, "history_preserved": true}}); err != nil {
return err
}
if *dryRun {
if err = tx.Rollback(ctx); err != nil {
return err
}
return json.NewEncoder(os.Stdout).Encode(map[string]any{"validated": true, "rolled_back": true, "device_id": factoryID})
}
// Persist private recovery material before committing the identity mutation.
f, err := os.OpenFile(*output, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err != nil {
return err
}
err = json.NewEncoder(f).Encode(map[string]any{"device_id": factoryID, "platform_device_id": *public, "model": "ECHOEAR-S3", "key_version": 1, "device_key_hex": hex.EncodeToString(key), "request_id": requestID})
if err == nil {
err = f.Sync()
}
closeErr := f.Close()
if err != nil {
return err
}
if closeErr != nil {
return closeErr
}
if err = tx.Commit(ctx); err != nil {
return fmt.Errorf("commit uncertain; retain private output and inspect device state: %w", err)
}
return json.NewEncoder(os.Stdout).Encode(map[string]any{"migrated": true, "device_id": factoryID, "platform_device_id": *public, "history_preserved": true, "request_id": requestID})
}