// 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(¤t, &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}) }