171 lines
7.0 KiB
Go
171 lines
7.0 KiB
Go
// 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})
|
||
|
|
}
|