diff --git a/admin/README.md b/admin/README.md index bbc8423..a14e5aa 100644 --- a/admin/README.md +++ b/admin/README.md @@ -13,6 +13,8 @@ | `CMBUYER_MAX_TASK_QUANTITY` | 每条任务允许的正整数数量上限。 | | `CMBUYER_MAX_TOTAL_PRICE` | 每条任务允许的规范正数总价上限,例如 `999.99`。 | | `CMBUYER_EVIDENCE_DIR` | 内部原始截图的绝对私有目录;不得指向仓库或公开静态目录。 | +| `CMBUYER_CLAIM_TOKEN_SECRET` | claim token 专用 32 字节密钥的 64 位小写十六进制;不得复用 session 或设备 token。 | +| `CMBUYER_CLAIM_LEASE_TTL` | 正 Go duration,且必须严格短于 `CMBUYER_AUTHORIZATION_TTL`。 | 示例仅展示变量名,不提供可运行凭据: @@ -26,6 +28,8 @@ $env:CMBUYER_AUTHORIZATION_TTL = '10m' $env:CMBUYER_MAX_TASK_QUANTITY = '99' $env:CMBUYER_MAX_TOTAL_PRICE = '999.99' $env:CMBUYER_EVIDENCE_DIR = '<内部截图绝对目录>' +$env:CMBUYER_CLAIM_TOKEN_SECRET = '<64 位小写十六进制随机值>' +$env:CMBUYER_CLAIM_LEASE_TTL = '1m' go run ./cmd/migrate -database $env:CMBUYER_DATABASE_SOURCE up go run ./cmd/server ``` diff --git a/admin/cmd/server/main.go b/admin/cmd/server/main.go index ba762b5..ce6415c 100644 --- a/admin/cmd/server/main.go +++ b/admin/cmd/server/main.go @@ -11,6 +11,7 @@ import ( "cmbuyer/admin/internal/server" evidencestorage "cmbuyer/admin/internal/storage/evidence" "cmbuyer/admin/internal/storage/sqlite" + "cmbuyer/admin/internal/taskclaim" "cmbuyer/admin/internal/taskdetail" "cmbuyer/admin/internal/tasks" ) @@ -52,6 +53,10 @@ func run() error { if err != nil { return err } + claimStore, err := taskclaim.NewStore(database, configuration.ClaimTokenSecret, configuration.ClaimLeaseTTL) + if err != nil { + return err + } router, err := server.NewRouter(server.Options{ AdminUsername: configuration.AdminUsername, @@ -61,6 +66,7 @@ func run() error { TaskDetails: detailStore, Evidence: evidenceStore, DeviceAuthenticator: deviceAuthenticator, + TaskClaims: claimStore, }) if err != nil { return err diff --git a/admin/internal/config/config.go b/admin/internal/config/config.go index a60cc84..6bb3e65 100644 --- a/admin/internal/config/config.go +++ b/admin/internal/config/config.go @@ -2,6 +2,8 @@ package config import ( + "bytes" + "encoding/hex" "errors" "fmt" "os" @@ -23,6 +25,8 @@ const ( maxTaskQuantityEnv = "CMBUYER_MAX_TASK_QUANTITY" maxTotalPriceEnv = "CMBUYER_MAX_TOTAL_PRICE" evidenceDirectoryEnv = "CMBUYER_EVIDENCE_DIR" + claimTokenSecretEnv = "CMBUYER_CLAIM_TOKEN_SECRET" + claimLeaseTTLEnv = "CMBUYER_CLAIM_LEASE_TTL" minimumSecretLength = 32 ) @@ -37,6 +41,8 @@ type Config struct { MaxTaskQuantity int MaxTotalPrice string EvidenceDirectory string + ClaimTokenSecret []byte + ClaimLeaseTTL time.Duration } // LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。 @@ -112,6 +118,27 @@ func Load(lookup func(string) (string, bool)) (Config, error) { if strings.TrimSpace(evidenceDirectory) != evidenceDirectory || !filepath.IsAbs(evidenceDirectory) { return Config{}, fmt.Errorf("%s must be an absolute path without surrounding whitespace", evidenceDirectoryEnv) } + claimSecretText, err := required(lookup, claimTokenSecretEnv) + if err != nil { + return Config{}, err + } + claimSecret, err := hex.DecodeString(claimSecretText) + if err != nil || len(claimSecret) != 32 || hex.EncodeToString(claimSecret) != claimSecretText { + return Config{}, fmt.Errorf("%s must be exactly 64 lowercase hexadecimal characters", claimTokenSecretEnv) + } + // Claim ownership, admin sessions and device authentication are separate security domains. + // Reject both identical configuration text and identical effective key bytes. + if claimSecretText == secret || bytes.Equal(claimSecret, []byte(secret)) { + return Config{}, fmt.Errorf("%s must be isolated from %s", claimTokenSecretEnv, sessionSecretEnv) + } + claimTTLText, err := required(lookup, claimLeaseTTLEnv) + if err != nil { + return Config{}, err + } + claimTTL, err := time.ParseDuration(claimTTLText) + if err != nil || claimTTL <= 0 || claimTTL >= ttl { + return Config{}, fmt.Errorf("%s must be positive and shorter than %s", claimLeaseTTLEnv, authorizationTTLEnv) + } return Config{ AdminUsername: username, @@ -121,6 +148,8 @@ func Load(lookup func(string) (string, bool)) (Config, error) { DatabaseSource: databaseSource, AuthorizationTTL: ttl, MaxTaskQuantity: maxQuantity, MaxTotalPrice: maxPrice, EvidenceDirectory: evidenceDirectory, + ClaimTokenSecret: claimSecret, + ClaimLeaseTTL: claimTTL, }, nil } diff --git a/admin/internal/config/config_test.go b/admin/internal/config/config_test.go index 1251b6d..928366c 100644 --- a/admin/internal/config/config_test.go +++ b/admin/internal/config/config_test.go @@ -3,6 +3,7 @@ package config_test import ( "strings" "testing" + "time" "cmbuyer/admin/internal/config" @@ -25,13 +26,15 @@ func TestLoad(t *testing.T) { "CMBUYER_MAX_TASK_QUANTITY": "99", "CMBUYER_MAX_TOTAL_PRICE": "999.99", "CMBUYER_EVIDENCE_DIR": t.TempDir(), + "CMBUYER_CLAIM_TOKEN_SECRET": strings.Repeat("ab", 32), + "CMBUYER_CLAIM_LEASE_TTL": "1m", } got, err := config.Load(lookup(values)) if err != nil { t.Fatalf("Load: %v", err) } - if got.AdminUsername != "admin" || !got.CookieSecure { + if got.AdminUsername != "admin" || !got.CookieSecure || len(got.ClaimTokenSecret) != 32 || got.ClaimLeaseTTL != time.Minute { t.Fatalf("Load returned unexpected public configuration: %#v", got) } } @@ -51,6 +54,8 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) { "CMBUYER_MAX_TASK_QUANTITY": "99", "CMBUYER_MAX_TOTAL_PRICE": "999.99", "CMBUYER_EVIDENCE_DIR": t.TempDir(), + "CMBUYER_CLAIM_TOKEN_SECRET": strings.Repeat("ab", 32), + "CMBUYER_CLAIM_LEASE_TTL": "1m", } tests := []struct { @@ -68,6 +73,12 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) { {"invalid maximum total price", func(values map[string]string) { values["CMBUYER_MAX_TOTAL_PRICE"] = "1" }, "CMBUYER_MAX_TOTAL_PRICE"}, {"missing evidence directory", func(values map[string]string) { delete(values, "CMBUYER_EVIDENCE_DIR") }, "CMBUYER_EVIDENCE_DIR"}, {"relative evidence directory", func(values map[string]string) { values["CMBUYER_EVIDENCE_DIR"] = "evidence" }, "CMBUYER_EVIDENCE_DIR"}, + {"invalid claim secret", func(values map[string]string) { values["CMBUYER_CLAIM_TOKEN_SECRET"] = strings.Repeat("A", 64) }, "CMBUYER_CLAIM_TOKEN_SECRET"}, + {"claim secret same raw session secret", func(values map[string]string) { + values["CMBUYER_SESSION_SECRET"] = values["CMBUYER_CLAIM_TOKEN_SECRET"] + }, "CMBUYER_CLAIM_TOKEN_SECRET"}, + {"claim secret same decoded session secret", func(values map[string]string) { values["CMBUYER_SESSION_SECRET"] = strings.Repeat("\xab", 32) }, "CMBUYER_CLAIM_TOKEN_SECRET"}, + {"invalid claim lease ttl", func(values map[string]string) { values["CMBUYER_CLAIM_LEASE_TTL"] = "10m" }, "CMBUYER_CLAIM_LEASE_TTL"}, } for _, test := range tests { diff --git a/admin/internal/migrations/migrations_test.go b/admin/internal/migrations/migrations_test.go index af8d302..35e82e8 100644 --- a/admin/internal/migrations/migrations_test.go +++ b/admin/internal/migrations/migrations_test.go @@ -26,7 +26,7 @@ func TestUpDownAndIdempotence(t *testing.T) { if err := migrations.Up(context, database, directory); err != nil { t.Fatalf("apply migrations: %v", err) } - assertVersion(t, database, 4) + assertVersion(t, database, 5) assertTableExists(t, database, "tasks", true) assertTableExists(t, database, "spec_trials", false) assertTableExists(t, database, "order_authorizations", true) @@ -34,12 +34,20 @@ func TestUpDownAndIdempotence(t *testing.T) { assertTableExists(t, database, "order_submissions", true) assertTableExists(t, database, "evidence_assets", true) assertTableExists(t, database, "device_credentials", true) + assertTableExists(t, database, "purchase_attempt_claims", true) assertTableExists(t, database, "single_pass_upgrade_guard", false) if err := migrations.Up(context, database, directory); err != nil { t.Fatalf("reapply migrations: %v", err) } + assertVersion(t, database, 5) + + if err := migrations.Down(context, database, directory); err != nil { + t.Fatalf("roll back task claim migration: %v", err) + } assertVersion(t, database, 4) + assertTableExists(t, database, "purchase_attempt_claims", false) + assertTableExists(t, database, "device_credentials", true) if err := migrations.Down(context, database, directory); err != nil { t.Fatalf("roll back device credential migration: %v", err) @@ -65,7 +73,7 @@ func TestUpDownAndIdempotence(t *testing.T) { if err := migrations.Up(context, database, directory); err != nil { t.Fatalf("reapply v2 after rollback: %v", err) } - assertVersion(t, database, 4) + assertVersion(t, database, 5) } func TestUpgradePreservesManualDraftLosslessly(t *testing.T) { @@ -84,7 +92,7 @@ func TestUpgradePreservesManualDraftLosslessly(t *testing.T) { if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil { t.Fatalf("upgrade v1 draft: %v", err) } - assertVersion(t, database, 4) + assertVersion(t, database, 5) var got struct { id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string quantity, version int @@ -235,9 +243,7 @@ func TestV2SchemaConstraintsAndRelationships(t *testing.T) { func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) { database := openTestDatabase(t) - if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil { - t.Fatalf("apply migrations: %v", err) - } + migrateToV3(t, database) insertV2Task(t, database, "task-one", "MANUAL", "DRAFT") insertV2Authorization(t, database, "auth-one", "task-one", 1, "start-one") insertV2Attempt(t, database, "attempt-one", "task-one", "auth-one", 1) @@ -271,9 +277,6 @@ func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) { }) } - if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err != nil { - t.Fatalf("roll back empty device credential migration: %v", err) - } if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil { t.Fatal("evidence-bearing schema downgraded successfully") } @@ -287,9 +290,7 @@ func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) { func TestDeviceCredentialSchemaConstraintsAndDowngradeGuard(t *testing.T) { database := openTestDatabase(t) - if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil { - t.Fatalf("apply migrations: %v", err) - } + migrateToV4(t, database) deviceID := "13c9f507-7473-4fa6-8d71-8786c34c6301" hash := make([]byte, 32) for index := range hash { @@ -354,6 +355,132 @@ func TestDeviceCredentialSchemaConstraintsAndDowngradeGuard(t *testing.T) { } } +func TestTaskClaimMigrationGuardsOwnershipConstraintsAndDowngradeFacts(t *testing.T) { + t.Run("upgrade rejects unmappable execution facts atomically", func(t *testing.T) { + database := openTestDatabase(t) + migrateToV4(t, database) + insertV2Task(t, database, "legacy-task", "MANUAL", "DRAFT") + insertV2Authorization(t, database, "legacy-auth", "legacy-task", 1, "legacy-start") + insertV2Attempt(t, database, "legacy-attempt", "legacy-task", "legacy-auth", 1) + if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err == nil { + t.Fatal("v5 upgrade accepted an attempt without device/session ownership") + } + assertVersion(t, database, 4) + assertTableExists(t, database, "purchase_attempt_claims", false) + var count int + if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempts").Scan(&count); err != nil || count != 1 { + t.Fatalf("legacy attempt after rejected upgrade = %d, err %v", count, err) + } + }) + + t.Run("schema binds authorization device session generation and token", func(t *testing.T) { + database := openTestDatabase(t) + if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil { + t.Fatalf("apply migrations: %v", err) + } + deviceA := "13c9f507-7473-4fa6-8d71-8786c34c6301" + deviceB := "23c9f507-7473-4fa6-8d71-8786c34c6301" + sessionA := "33c9f507-7473-4fa6-8d71-8786c34c6301" + sessionB := "43c9f507-7473-4fa6-8d71-8786c34c6301" + taskA := "53c9f507-7473-4fa6-8d71-8786c34c6301" + authA := "63c9f507-7473-4fa6-8d71-8786c34c6301" + attemptA := "73c9f507-7473-4fa6-8d71-8786c34c6301" + taskB := "83c9f507-7473-4fa6-8d71-8786c34c6301" + authB := "93c9f507-7473-4fa6-8d71-8786c34c6301" + attemptB := "a3c9f507-7473-4fa6-8d71-8786c34c6301" + tokenA := make([]byte, 32) + for index := range tokenA { + tokenA[index] = byte(index + 1) + } + for index, device := range []string{deviceA, deviceB} { + hash := make([]byte, 32) + hash[0] = byte(index + 100) + if _, err := database.Exec(`INSERT INTO device_credentials + (device_id,display_name,token_sha256,status,created_at,revoked_at) + VALUES (?, ?, ?, 'ACTIVE', ?, NULL)`, device, "device "+strconv.Itoa(index), hash, migrationTime); err != nil { + t.Fatalf("insert device: %v", err) + } + } + insertV2Task(t, database, taskA, "MANUAL", "DRAFT") + insertV2Authorization(t, database, authA, taskA, 1, "start-a") + insertV2Attempt(t, database, attemptA, taskA, authA, 1) + insertClaim := `INSERT INTO purchase_attempt_claims + (attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation, + task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity, + total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at) + VALUES (?, ?, ?, ?, ?, ?, 2, 'task', 1, 'goods', 'white', 'XL', 1, '1.00', + '2026-08-04T01:00:00Z', ?, ?, '2026-08-04T00:05:00Z', ?, NULL)` + if _, err := database.Exec(insertClaim, attemptA, taskA, authA, deviceA, sessionA, 1, make([]byte, 32), tokenA, migrationTime); err != nil { + t.Fatalf("insert valid claim: %v", err) + } + if _, err := database.Exec(`INSERT INTO purchase_attempts + (id,task_id,authorization_id,claim_generation,status,started_at) + VALUES ('b3c9f507-7473-4fa6-8d71-8786c34c6301', ?, ?, 2, 'CLAIMED', ?)`, taskA, authA, migrationTime); err == nil { + t.Fatal("second attempt for one authorization succeeded") + } + + insertV2Task(t, database, taskB, "MANUAL", "DRAFT") + insertV2Authorization(t, database, authB, taskB, 1, "start-b") + insertV2Attempt(t, database, attemptB, taskB, authB, 1) + if _, err := database.Exec(insertClaim, attemptB, taskB, authB, deviceB, sessionB, 2, make([]byte, 32), make([]byte, 32), migrationTime); err == nil { + t.Fatal("claim with generation different from its attempt succeeded") + } + if _, err := database.Exec(insertClaim, attemptB, taskB, authB, deviceA, sessionB, 1, make([]byte, 32), make([]byte, 32), migrationTime); err == nil { + t.Fatal("second open claim for one device succeeded") + } + + claimRequest := `INSERT INTO task_claim_requests + (claim_request_id,device_id,session_id,outcome,attempt_id,response_lease_expires_at,error_code,created_at) + VALUES (?, ?, ?, 'CLAIMED', ?, '2026-08-04T00:05:00Z', NULL, ?)` + if _, err := database.Exec(claimRequest, "c3c9f507-7473-4fa6-8d71-8786c34c6301", deviceA, sessionB, attemptA, migrationTime); err == nil { + t.Fatal("claim request with another session succeeded") + } + if _, err := database.Exec(claimRequest, "d3c9f507-7473-4fa6-8d71-8786c34c6301", deviceA, sessionA, attemptA, migrationTime); err != nil { + t.Fatalf("insert bound claim request: %v", err) + } + renewal := `INSERT INTO purchase_attempt_lease_renewals + (renew_request_id,task_id,attempt_id,device_id,session_id,claim_generation, + claim_token_sha256,expected_lease_expires_at,lease_expires_at,created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, '2026-08-04T00:05:00Z', '2026-08-04T00:06:00Z', ?)` + if _, err := database.Exec(renewal, "e3c9f507-7473-4fa6-8d71-8786c34c6301", taskA, attemptA, deviceA, sessionA, 2, tokenA, migrationTime); err == nil { + t.Fatal("renewal with another generation succeeded") + } + wrongHash := append([]byte(nil), tokenA...) + wrongHash[0] ^= 0xff + if _, err := database.Exec(renewal, "f3c9f507-7473-4fa6-8d71-8786c34c6301", taskA, attemptA, deviceA, sessionA, 1, wrongHash, migrationTime); err == nil { + t.Fatal("renewal with another token hash succeeded") + } + if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil { + t.Fatal("claim-bearing schema downgraded successfully") + } + assertVersion(t, database, 5) + assertTableExists(t, database, "purchase_attempt_claims", true) + }) + + t.Run("empty request alone blocks downgrade", func(t *testing.T) { + database := openTestDatabase(t) + if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil { + t.Fatal(err) + } + device := "13c9f507-7473-4fa6-8d71-8786c34c6301" + if _, err := database.Exec(`INSERT INTO device_credentials + (device_id,display_name,token_sha256,status,created_at,revoked_at) + VALUES (?, 'device', ?, 'ACTIVE', ?, NULL)`, device, make([]byte, 32), migrationTime); err != nil { + t.Fatal(err) + } + if _, err := database.Exec(`INSERT INTO task_claim_requests + (claim_request_id,device_id,session_id,outcome,attempt_id,response_lease_expires_at,error_code,created_at) + VALUES ('23c9f507-7473-4fa6-8d71-8786c34c6301', ?, + '33c9f507-7473-4fa6-8d71-8786c34c6301', 'EMPTY', NULL, NULL, NULL, ?)`, device, migrationTime); err != nil { + t.Fatal(err) + } + if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil { + t.Fatal("EMPTY request was silently dropped by downgrade") + } + assertVersion(t, database, 5) + }) +} + func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) { tests := []struct { name string @@ -416,6 +543,24 @@ func migrateToV2(t *testing.T, database *sql.DB) { assertVersion(t, database, 2) } +func migrateToV3(t *testing.T, database *sql.DB) { + t.Helper() + migrateToV2(t, database) + if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil { + t.Fatalf("apply v3: %v", err) + } + assertVersion(t, database, 3) +} + +func migrateToV4(t *testing.T, database *sql.DB) { + t.Helper() + migrateToV3(t, database) + if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil { + t.Fatalf("apply v4: %v", err) + } + assertVersion(t, database, 4) +} + func insertV1Task(t *testing.T, database *sql.DB, id, source, status, price string) { t.Helper() if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, ?, 'title', 'goods', 'white', 'XL', 1, ?, ?, ?, ?)`, id, source, price, status, migrationTime, migrationTime); err != nil { diff --git a/admin/internal/server/evidence_test.go b/admin/internal/server/evidence_test.go index 87532c7..4a81701 100644 --- a/admin/internal/server/evidence_test.go +++ b/admin/internal/server/evidence_test.go @@ -134,6 +134,7 @@ func TestRealDeviceCredentialIdentityIsolationAndMixedCredentials(t *testing.T) if err != nil { t.Fatalf("issue credential: %v", err) } + insertEvidenceClaim(t, database, issued.DeviceID) authenticator, err := deviceauth.NewSQLiteAuthenticator(database) if err != nil { t.Fatalf("new authenticator: %v", err) @@ -353,6 +354,8 @@ func newEvidenceRouter(t *testing.T, authenticator deviceauth.Authenticator) (ht t.Fatalf("migrate database: %v", err) } insertEvidenceAttempt(t, database) + insertEvidenceClaimDevice(t, database, evidenceDeviceID) + insertEvidenceClaim(t, database, evidenceDeviceID) store, err := evidencestorage.NewStore(database, filepath.Join(t.TempDir(), "assets")) if err != nil { t.Fatalf("new evidence store: %v", err) @@ -384,6 +387,30 @@ func insertEvidenceAttempt(t *testing.T, database *sql.DB) { } } +func insertEvidenceClaimDevice(t *testing.T, database *sql.DB, deviceID string) { + t.Helper() + digest := sha256.Sum256([]byte("fake evidence device")) + if _, err := database.Exec(`INSERT INTO device_credentials + (device_id,display_name,token_sha256,status,created_at,revoked_at) + VALUES (?, 'fake evidence device', ?, 'ACTIVE', '2026-08-04T00:00:00Z', NULL)`, deviceID, digest[:]); err != nil { + t.Fatalf("insert evidence device: %v", err) + } +} + +func insertEvidenceClaim(t *testing.T, database *sql.DB, deviceID string) { + t.Helper() + if _, err := database.Exec(`INSERT INTO purchase_attempt_claims + (attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation, + task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity, + total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at) + VALUES (?, ?, ?, ?, '23c9f507-7473-4fa6-8d71-8786c34c6301', 1, 1, 'task', + 1, '123', 'black', 'M', 1, '1.00', '2026-08-04T00:00:00Z', ?, ?, + '2026-08-04T02:00:00Z', '2026-08-04T00:00:00Z', NULL)`, evidenceAttemptID, + evidenceTaskID, evidenceAuthID, deviceID, bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32)); err != nil { + t.Fatalf("insert evidence claim: %v", err) + } +} + func serveEvidenceUpload(t *testing.T, router http.Handler, taskID string, fields map[string]string, file []byte, fileContentType, filename string, extra func(*multipart.Writer) error) *httptest.ResponseRecorder { t.Helper() request := newEvidenceUploadRequest(t, taskID, fields, file, fileContentType, filename, extra) diff --git a/admin/internal/server/router.go b/admin/internal/server/router.go index 7007624..1f8fc86 100644 --- a/admin/internal/server/router.go +++ b/admin/internal/server/router.go @@ -16,6 +16,7 @@ import ( "cmbuyer/admin/internal/auth" "cmbuyer/admin/internal/deviceauth" "cmbuyer/admin/internal/evidence" + "cmbuyer/admin/internal/taskclaim" "cmbuyer/admin/internal/taskdetail" "cmbuyer/admin/internal/tasks" "cmbuyer/admin/internal/transport/webui" @@ -36,11 +37,12 @@ type Options struct { TaskDetails taskdetail.Store Evidence evidence.Store DeviceAuthenticator deviceauth.Authenticator + TaskClaims taskclaim.Service } // NewRouter 返回当前服务范围内的完整 HTTP 路由。 func NewRouter(options Options) (*gin.Engine, error) { - if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil || options.TaskDetails == nil || options.Evidence == nil || options.DeviceAuthenticator == nil { + if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil || options.TaskDetails == nil || options.Evidence == nil || options.DeviceAuthenticator == nil || options.TaskClaims == nil { return nil, errors.New("server authentication options are incomplete") } @@ -57,6 +59,8 @@ func NewRouter(options Options) (*gin.Engine, error) { router.POST("/tasks", createTask(options)) router.POST("/tasks/start-purchases", startPurchases(options)) router.POST("/api/v1/tasks/:id/evidence", uploadEvidence(options)) + router.POST("/api/v1/tasks/claim-next", claimNext(options)) + router.POST("/api/v1/tasks/:id/lease/renew", renewLease(options)) router.GET("/evidence/:asset_id", readEvidence(options)) router.GET("/static/tasks.js", func(context *gin.Context) { context.Data(http.StatusOK, "application/javascript; charset=utf-8", webui.TasksScript()) diff --git a/admin/internal/server/router_test.go b/admin/internal/server/router_test.go index b0ba53c..85c9138 100644 --- a/admin/internal/server/router_test.go +++ b/admin/internal/server/router_test.go @@ -15,6 +15,7 @@ import ( "cmbuyer/admin/internal/deviceauth" "cmbuyer/admin/internal/evidence" "cmbuyer/admin/internal/server" + "cmbuyer/admin/internal/taskclaim" "cmbuyer/admin/internal/taskdetail" "cmbuyer/admin/internal/tasks" @@ -493,6 +494,10 @@ func newRouterWithStore(t *testing.T, store tasks.Store) (*gin.Engine, *auth.Man } func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator deviceauth.Authenticator) (*gin.Engine, *auth.Manager) { + return newRouterWithClaimService(t, store, details, evidenceStore, deviceAuthenticator, emptyTaskClaimService{}) +} + +func newRouterWithClaimService(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator deviceauth.Authenticator, claims taskclaim.Service) (*gin.Engine, *auth.Manager) { t.Helper() gin.SetMode(gin.TestMode) hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost) @@ -508,6 +513,7 @@ func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdeta TaskDetails: details, Evidence: evidenceStore, DeviceAuthenticator: deviceAuthenticator, + TaskClaims: claims, }) if err != nil { t.Fatalf("NewRouter: %v", err) @@ -517,6 +523,16 @@ func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdeta type emptyDetailStore struct{} +type emptyTaskClaimService struct{} + +func (emptyTaskClaimService) ClaimNext(context.Context, string, taskclaim.ClaimCommand) (taskclaim.ClaimResponse, bool, error) { + return taskclaim.ClaimResponse{}, false, nil +} + +func (emptyTaskClaimService) Renew(context.Context, string, taskclaim.RenewCommand) (taskclaim.RenewResponse, error) { + return taskclaim.RenewResponse{}, taskclaim.ErrNotCurrent +} + func (emptyDetailStore) Get(context.Context, string) (taskdetail.Detail, error) { return taskdetail.Detail{}, taskdetail.ErrNotFound } diff --git a/admin/internal/server/task_claims.go b/admin/internal/server/task_claims.go new file mode 100644 index 0000000..cf60341 --- /dev/null +++ b/admin/internal/server/task_claims.go @@ -0,0 +1,164 @@ +package server + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "unicode/utf8" + + "cmbuyer/admin/internal/deviceauth" + "cmbuyer/admin/internal/taskclaim" + + "github.com/gin-gonic/gin" +) + +const maxClaimJSONBytes = 4096 + +func claimNext(options Options) gin.HandlerFunc { + return func(context *gin.Context) { + principal, ok := authenticateDevice(context, options) + if !ok { + return + } + var command taskclaim.ClaimCommand + if !decodeClaimJSON(context, &command) { + return + } + response, found, err := options.TaskClaims.ClaimNext(context.Request.Context(), principal.ID, command) + if err != nil { + writeTaskClaimError(context, err) + return + } + if !found { + context.Status(http.StatusNoContent) + return + } + context.JSON(http.StatusOK, response) + } +} + +func renewLease(options Options) gin.HandlerFunc { + return func(context *gin.Context) { + principal, ok := authenticateDevice(context, options) + if !ok { + return + } + var command taskclaim.RenewCommand + if !decodeClaimJSON(context, &command) { + return + } + command.TaskID = context.Param("id") + response, err := options.TaskClaims.Renew(context.Request.Context(), principal.ID, command) + if err != nil { + writeTaskClaimError(context, err) + return + } + context.JSON(http.StatusOK, response) + } +} + +// Authentication precedes path interpretation, Content-Type parsing and every body read. This +// keeps rejected devices from using parsing differences as an oracle or making the server buffer data. +func authenticateDevice(context *gin.Context, options Options) (deviceauth.Principal, bool) { + principal, err := options.DeviceAuthenticator.Authenticate(context.Request) + if errors.Is(err, deviceauth.ErrUnauthenticated) { + context.Header("WWW-Authenticate", "Bearer") + context.Status(http.StatusUnauthorized) + return deviceauth.Principal{}, false + } + if err != nil || !deviceauth.ValidDeviceID(principal.ID) { + context.Status(http.StatusServiceUnavailable) + return deviceauth.Principal{}, false + } + return principal, true +} + +func decodeClaimJSON(context *gin.Context, target any) bool { + if !isJSONContentType(context.GetHeader("Content-Type")) { + writeFixedError(context, http.StatusUnsupportedMediaType, "unsupported_media_type") + return false + } + context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxClaimJSONBytes) + raw, err := io.ReadAll(context.Request.Body) + if err != nil { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + writeFixedError(context, http.StatusRequestEntityTooLarge, "request_too_large") + } else { + writeFixedError(context, http.StatusBadRequest, "invalid_request") + } + return false + } + if len(raw) == 0 || !utf8.Valid(raw) { + writeFixedError(context, http.StatusBadRequest, "invalid_request") + return false + } + if !hasUniqueTopLevelJSONFields(raw) { + writeFixedError(context, http.StatusBadRequest, "invalid_request") + return false + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + writeFixedError(context, http.StatusBadRequest, "invalid_request") + return false + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + writeFixedError(context, http.StatusBadRequest, "invalid_request") + return false + } + return true +} + +func hasUniqueTopLevelJSONFields(raw []byte) bool { + decoder := json.NewDecoder(bytes.NewReader(raw)) + first, err := decoder.Token() + if err != nil || first != json.Delim('{') { + return false + } + seen := make(map[string]struct{}) + for decoder.More() { + key, err := decoder.Token() + name, ok := key.(string) + if err != nil || !ok { + return false + } + if _, duplicate := seen[name]; duplicate { + return false + } + seen[name] = struct{}{} + var value json.RawMessage + if err := decoder.Decode(&value); err != nil { + return false + } + } + last, err := decoder.Token() + return err == nil && last == json.Delim('}') +} + +func writeTaskClaimError(context *gin.Context, err error) { + switch { + case errors.Is(err, taskclaim.ErrInvalid): + writeFixedError(context, http.StatusBadRequest, "invalid_request") + case errors.Is(err, taskclaim.ErrIdempotencyConflict): + writeFixedError(context, http.StatusConflict, "idempotency_conflict") + case errors.Is(err, taskclaim.ErrRequiresManual): + writeFixedError(context, http.StatusConflict, "claim_requires_manual") + case errors.Is(err, taskclaim.ErrNotCurrent): + writeFixedError(context, http.StatusConflict, "claim_not_current") + case errors.Is(err, taskclaim.ErrDeviceInactive): + context.Header("WWW-Authenticate", "Bearer") + context.Status(http.StatusUnauthorized) + default: + // Storage and transaction failures are intentionally bodyless: SQL, paths and candidate + // details are server-only and must not become a device-facing diagnostic oracle. + context.Status(http.StatusServiceUnavailable) + } +} + +func writeFixedError(context *gin.Context, status int, code string) { + context.JSON(status, gin.H{"error": code}) +} diff --git a/admin/internal/server/task_claims_test.go b/admin/internal/server/task_claims_test.go new file mode 100644 index 0000000..0e1897c --- /dev/null +++ b/admin/internal/server/task_claims_test.go @@ -0,0 +1,166 @@ +package server_test + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "cmbuyer/admin/internal/deviceauth" + "cmbuyer/admin/internal/taskclaim" +) + +const ( + claimDeviceID = "10000000-0000-4000-8000-000000000001" + claimSessionID = "20000000-0000-4000-8000-000000000001" + claimRequestID = "30000000-0000-4000-8000-000000000001" + claimTaskID = "40000000-0000-4000-8000-000000000001" + claimAttemptID = "50000000-0000-4000-8000-000000000001" + claimRenewID = "60000000-0000-4000-8000-000000000001" +) + +func TestTaskClaimEndpointsAuthenticateBeforeBody(t *testing.T) { + for _, authentication := range []struct { + name string + err error + status int + }{ + {"unauthenticated", deviceauth.ErrUnauthenticated, http.StatusUnauthorized}, + {"authentication storage unavailable", deviceauth.ErrUnavailable, http.StatusServiceUnavailable}, + } { + t.Run(authentication.name, func(t *testing.T) { + authenticator := &fakeDeviceAuthenticator{err: authentication.err} + service := &fakeTaskClaimService{} + router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service) + for _, path := range []string{"/api/v1/tasks/claim-next", "/api/v1/tasks/" + claimTaskID + "/lease/renew"} { + body := &poisonBody{} + request := httptest.NewRequest(http.MethodPost, path, nil) + request.Body = body + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != authentication.status || response.Body.Len() != 0 || body.reads != 0 || service.calls != 0 { + t.Fatalf("%s = status %d, body %q, reads %d, calls %d", path, response.Code, response.Body.String(), body.reads, service.calls) + } + } + }) + } +} + +func TestClaimNextStrictJSONSuccessEmptyAndErrors(t *testing.T) { + authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}} + service := &fakeTaskClaimService{claimResponse: taskclaim.ClaimResponse{ + Task: taskclaim.ClaimedTask{ID: claimTaskID, Version: 3, Title: "测试", ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=1", GoodsID: "1", SKUColor: "黑色", SKUSize: "M", Quantity: 1, MaxTotalPrice: "1.00"}, + Authorization: taskclaim.ClaimedAuthorization{ID: "70000000-0000-4000-8000-000000000001", TaskVersion: 2, ExpiresAt: "2026-08-04T01:10:00Z"}, + Attempt: taskclaim.ClaimedAttempt{ID: claimAttemptID, ClaimToken: strings.Repeat("a", 64), ClaimGeneration: 1, LeaseExpiresAt: "2026-08-04T01:03:00Z"}, + }, claimFound: true} + router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service) + valid := `{"session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `"}` + + response := serveClaimJSON(router, "/api/v1/tasks/claim-next", valid, "application/json; charset=utf-8") + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), strings.Repeat("a", 64)) || service.claimCommand.ClaimRequestID != claimRequestID { + t.Fatalf("claim success = %d %q command %#v", response.Code, response.Body.String(), service.claimCommand) + } + service.claimFound = false + response = serveClaimJSON(router, "/api/v1/tasks/claim-next", valid, "application/json") + if response.Code != http.StatusNoContent || response.Body.Len() != 0 { + t.Fatalf("claim empty = %d %q", response.Code, response.Body.String()) + } + + tests := []struct { + name, body, contentType, code string + status int + }{ + {"unsupported type", valid, "text/plain", "unsupported_media_type", http.StatusUnsupportedMediaType}, + {"unknown field", strings.TrimSuffix(valid, "}") + `,"device_id":"` + claimDeviceID + `"}`, "application/json", "invalid_request", http.StatusBadRequest}, + {"duplicate session", `{"session_id":"` + claimSessionID + `","session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `"}`, "application/json", "invalid_request", http.StatusBadRequest}, + {"duplicate request", `{"session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `","claim_request_id":"` + claimRequestID + `"}`, "application/json", "invalid_request", http.StatusBadRequest}, + {"extra json", valid + `{}`, "application/json", "invalid_request", http.StatusBadRequest}, + {"too large", strings.Repeat(" ", 4097), "application/json", "request_too_large", http.StatusRequestEntityTooLarge}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response := serveClaimJSON(router, "/api/v1/tasks/claim-next", test.body, test.contentType) + if response.Code != test.status || response.Body.String() != `{"error":"`+test.code+`"}` { + t.Fatalf("response = %d %q", response.Code, response.Body.String()) + } + }) + } + invalidUTF8 := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/claim-next", bytes.NewReader([]byte{'{', 0xff, '}'})) + invalidUTF8.Header.Set("Content-Type", "application/json") + invalidResponse := httptest.NewRecorder() + router.ServeHTTP(invalidResponse, invalidUTF8) + if invalidResponse.Code != http.StatusBadRequest || invalidResponse.Body.String() != `{"error":"invalid_request"}` { + t.Fatalf("invalid UTF-8 = %d %q", invalidResponse.Code, invalidResponse.Body.String()) + } +} + +func TestRenewStrictBindingResponseAndFixedErrors(t *testing.T) { + authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}} + service := &fakeTaskClaimService{renewResponse: taskclaim.RenewResponse{TaskID: claimTaskID, AttemptID: claimAttemptID, ClaimGeneration: 1, LeaseExpiresAt: "2026-08-04T01:04:00Z"}} + router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service) + body := `{"renew_request_id":"` + claimRenewID + `","session_id":"` + claimSessionID + `","attempt_id":"` + claimAttemptID + `","claim_generation":1,"claim_token":"` + strings.Repeat("a", 64) + `","expected_lease_expires_at":"2026-08-04T01:03:00Z"}` + response := serveClaimJSON(router, "/api/v1/tasks/"+claimTaskID+"/lease/renew", body, "application/json") + if response.Code != http.StatusOK || strings.Contains(response.Body.String(), "claim_token") || service.renewCommand.TaskID != claimTaskID { + t.Fatalf("renew response = %d %q command %#v", response.Code, response.Body.String(), service.renewCommand) + } + + duplicateToken := strings.Replace(body, `"expected_lease_expires_at"`, `"claim_token":"`+strings.Repeat("a", 64)+`","expected_lease_expires_at"`, 1) + response = serveClaimJSON(router, "/api/v1/tasks/"+claimTaskID+"/lease/renew", duplicateToken, "application/json") + if response.Code != http.StatusBadRequest { + t.Fatalf("duplicate token status = %d", response.Code) + } + + errorsToCodes := []struct { + err error + status int + body string + }{ + {taskclaim.ErrIdempotencyConflict, http.StatusConflict, `{"error":"idempotency_conflict"}`}, + {taskclaim.ErrRequiresManual, http.StatusConflict, `{"error":"claim_requires_manual"}`}, + {taskclaim.ErrNotCurrent, http.StatusConflict, `{"error":"claim_not_current"}`}, + {taskclaim.ErrDeviceInactive, http.StatusUnauthorized, ""}, + {errors.New("database path and SQL must stay private"), http.StatusServiceUnavailable, ""}, + } + for _, test := range errorsToCodes { + service.renewErr = test.err + response = serveClaimJSON(router, "/api/v1/tasks/"+claimTaskID+"/lease/renew", body, "application/json") + if response.Code != test.status || response.Body.String() != test.body { + t.Fatalf("error %v = %d %q", test.err, response.Code, response.Body.String()) + } + } +} + +type fakeTaskClaimService struct { + claimResponse taskclaim.ClaimResponse + claimFound bool + claimErr error + renewResponse taskclaim.RenewResponse + renewErr error + claimCommand taskclaim.ClaimCommand + renewCommand taskclaim.RenewCommand + calls int +} + +func (service *fakeTaskClaimService) ClaimNext(_ context.Context, _ string, command taskclaim.ClaimCommand) (taskclaim.ClaimResponse, bool, error) { + service.calls++ + service.claimCommand = command + return service.claimResponse, service.claimFound, service.claimErr +} + +func (service *fakeTaskClaimService) Renew(_ context.Context, _ string, command taskclaim.RenewCommand) (taskclaim.RenewResponse, error) { + service.calls++ + service.renewCommand = command + return service.renewResponse, service.renewErr +} + +func serveClaimJSON(router http.Handler, path, body, contentType string) *httptest.ResponseRecorder { + request := httptest.NewRequest(http.MethodPost, path, io.NopCloser(strings.NewReader(body))) + request.Header.Set("Content-Type", contentType) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + return response +} diff --git a/admin/internal/storage/evidence/store.go b/admin/internal/storage/evidence/store.go index 37077dd..0700756 100644 --- a/admin/internal/storage/evidence/store.go +++ b/admin/internal/storage/evidence/store.go @@ -185,11 +185,16 @@ func (store *Store) Commit(ctx context.Context, principal deviceauth.Principal, return existing, true, nil } - var attemptCount int - if err := transaction.QueryRowContext(ctx, "SELECT COUNT(*) FROM purchase_attempts WHERE task_id = ? AND id = ?", metadata.TaskID, metadata.AttemptID).Scan(&attemptCount); err != nil { + var ownedClaimCount int + if err := transaction.QueryRowContext(ctx, `SELECT COUNT(*) FROM purchase_attempt_claims + WHERE task_id = ? AND attempt_id = ? AND claimed_by_device_id = ? AND closed_at IS NULL`, + metadata.TaskID, metadata.AttemptID, principal.ID).Scan(&ownedClaimCount); err != nil { return core.Asset{}, false, err } - if attemptCount != 1 { + // Evidence is auditable only when the authenticated device owns the current attempt. The + // idempotent asset lookup above deliberately remains first so closing a claim later cannot + // destroy stable replay of an already committed screenshot. + if ownedClaimCount != 1 { return core.Asset{}, false, core.ErrInvalid } diff --git a/admin/internal/storage/evidence/store_test.go b/admin/internal/storage/evidence/store_test.go index 1d25fcc..e8994e5 100644 --- a/admin/internal/storage/evidence/store_test.go +++ b/admin/internal/storage/evidence/store_test.go @@ -82,6 +82,41 @@ func TestStageCommitReplayAndOpen(t *testing.T) { } } +func TestCommitRequiresCurrentClaimOwnerButClosedClaimKeepsHistoricalReplay(t *testing.T) { + database, store := newTestStore(t) + insertAttemptFixture(t, database) + pngBytes := makePNG(t, 4, 3) + metadata := testMetadata(sha256Hex(pngBytes)) + stage := func() core.StagedFile { + staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType) + if err != nil { + t.Fatalf("Stage: %v", err) + } + return staged + } + otherDevice := deviceauth.Principal{ID: "73c9f507-7473-4fa6-8d71-8786c34c6301"} + if _, _, err := store.Commit(context.Background(), otherDevice, metadata, stage()); !errors.Is(err, core.ErrInvalid) { + t.Fatalf("device B upload to device A attempt error = %v", err) + } + principal := deviceauth.Principal{ID: testDeviceID} + asset, replayed, err := store.Commit(context.Background(), principal, metadata, stage()) + if err != nil || replayed { + t.Fatalf("owner first Commit = replayed %v, err %v", replayed, err) + } + if _, err := database.Exec("UPDATE purchase_attempt_claims SET closed_at='2026-08-04T03:00:00Z' WHERE attempt_id=?", testAttemptID); err != nil { + t.Fatalf("close claim: %v", err) + } + replayedAsset, replayed, err := store.Commit(context.Background(), principal, metadata, stage()) + if err != nil || !replayed || replayedAsset.ID != asset.ID { + t.Fatalf("closed claim historical replay = %#v replayed %v err %v", replayedAsset, replayed, err) + } + newMetadata := metadata + newMetadata.UploadKey = "83c9f507-7473-4fa6-8d71-8786c34c6301" + if _, _, err := store.Commit(context.Background(), principal, newMetadata, stage()); !errors.Is(err, core.ErrInvalid) { + t.Fatalf("closed claim new upload error = %v", err) + } +} + func TestConcurrentReplayCreatesOneAsset(t *testing.T) { database, store := newTestStore(t) insertAttemptFixture(t, database) @@ -494,6 +529,12 @@ func newTestStore(t *testing.T) (*sql.DB, *Store) { func insertAttemptFixture(t *testing.T, database *sql.DB) { t.Helper() timestamp := "2026-08-04T00:00:00Z" + tokenHash := sha256.Sum256([]byte("evidence-device-token")) + if _, err := database.Exec(`INSERT INTO device_credentials + (device_id,display_name,token_sha256,status,created_at,revoked_at) + VALUES (?, 'evidence device', ?, 'ACTIVE', ?, NULL)`, testDeviceID, tokenHash[:], timestamp); err != nil { + t.Fatalf("insert device: %v", err) + } if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', 'task', '123', 'black', 'M', 1, '1.00', 'DRAFT', 1, ?, ?)`, testTaskID, timestamp, timestamp); err != nil { t.Fatalf("insert task: %v", err) } @@ -503,6 +544,15 @@ func insertAttemptFixture(t *testing.T, database *sql.DB) { if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, testAttemptID, testTaskID, testAuthID, timestamp); err != nil { t.Fatalf("insert attempt: %v", err) } + if _, err := database.Exec(`INSERT INTO purchase_attempt_claims + (attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation, + task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity, + total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at) + VALUES (?, ?, ?, ?, '63c9f507-7473-4fa6-8d71-8786c34c6301', 1, 1, 'task', + 1, '123', 'black', 'M', 1, '1.00', ?, ?, ?, '2026-08-04T02:00:00Z', ?, NULL)`, + testAttemptID, testTaskID, testAuthID, testDeviceID, timestamp, bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32), timestamp); err != nil { + t.Fatalf("insert claim: %v", err) + } } func testMetadata(hash string) core.UploadMetadata { diff --git a/admin/internal/taskclaim/store.go b/admin/internal/taskclaim/store.go new file mode 100644 index 0000000..8aefe2f --- /dev/null +++ b/admin/internal/taskclaim/store.go @@ -0,0 +1,830 @@ +package taskclaim + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "io" + "math" + "math/big" + "strings" + "sync" + "time" + + "cmbuyer/admin/internal/deviceauth" +) + +const writeTimeout = 2 * time.Second + +type Store struct { + database *sql.DB + secret []byte + leaseTTL time.Duration + now func() time.Time + random io.Reader + randomMu sync.Mutex + writeGate chan struct{} + // The unexported linearization hooks let package tests coordinate real SQLite + // transactions at the first write. Production construction always leaves them nil. + beforeLinearization func() + afterLinearization func() +} + +func NewStore(database *sql.DB, secret []byte, leaseTTL time.Duration) (*Store, error) { + if database == nil { + return nil, errors.New("task claim database is required") + } + if len(secret) != sha256.Size { + return nil, errors.New("task claim secret must be 32 bytes") + } + if leaseTTL <= 0 { + return nil, errors.New("task claim lease TTL must be positive") + } + if _, err := database.Exec("SELECT attempt_id, claim_nonce, claim_token_sha256 FROM purchase_attempt_claims LIMIT 1"); err != nil { + return nil, errors.New("task claim migration is not available") + } + store := &Store{ + database: database, secret: append([]byte(nil), secret...), leaseTTL: leaseTTL, + now: time.Now, random: rand.Reader, writeGate: make(chan struct{}, 1), + } + if err := store.validateSecretIsolation(); err != nil { + return nil, err + } + if err := store.validateStoredClaims(context.Background()); err != nil { + return nil, err + } + return store, nil +} + +// validateSecretIsolation ensures the HMAC key cannot also authenticate a device. The session +// secret comparison is performed while parsing configuration, before either secret is discarded. +func (store *Store) validateSecretIsolation() error { + digest := sha256.Sum256(store.secret) + var count int + if err := store.database.QueryRow(`SELECT COUNT(*) FROM device_credentials WHERE token_sha256 = ?`, digest[:]).Scan(&count); err != nil { + return errors.New("validate task claim secret isolation") + } + if count != 0 { + return errors.New("task claim secret must be isolated from device credentials") + } + return nil +} + +// validateStoredClaims covers open and closed claims. Replacing the secret must fail startup; +// silently signing a new token would destroy idempotent recovery and the ownership audit chain. +func (store *Store) validateStoredClaims(ctx context.Context) error { + rows, err := store.database.QueryContext(ctx, `SELECT claims.claimed_by_device_id, claims.task_id, claims.authorization_id, + claims.attempt_id, claims.claim_generation, claims.claim_nonce, typeof(claims.claim_nonce), length(claims.claim_nonce), + claims.claim_token_sha256, typeof(claims.claim_token_sha256), length(claims.claim_token_sha256), + claims.authorization_task_version, claims.goods_id, claims.sku_color, claims.sku_size, + claims.quantity, claims.total_price_cap, claims.authorization_expires_at, claims.closed_at, + attempts.claim_generation, attempts.status, authorizations.status, tasks.status + FROM purchase_attempt_claims AS claims + LEFT JOIN purchase_attempts AS attempts ON attempts.id = claims.attempt_id + LEFT JOIN order_authorizations AS authorizations ON authorizations.id = claims.authorization_id + LEFT JOIN tasks ON tasks.id = claims.task_id + ORDER BY claims.attempt_id`) + if err != nil { + return errors.New("validate stored task claims") + } + defer rows.Close() + for rows.Next() { + var deviceID, taskID, authorizationID, attemptID string + var generation, authorizationTaskVersion, quantity int + var nonce, storedHash []byte + var nonceType, hashType, goodsID, color, size, price, expires string + var nonceLength, hashLength int + var closed, attemptStatus, authorizationStatus, taskStatus sql.NullString + var attemptGeneration sql.NullInt64 + if err := rows.Scan(&deviceID, &taskID, &authorizationID, &attemptID, &generation, + &nonce, &nonceType, &nonceLength, &storedHash, &hashType, &hashLength, + &authorizationTaskVersion, &goodsID, &color, &size, &quantity, &price, &expires, &closed, + &attemptGeneration, &attemptStatus, &authorizationStatus, &taskStatus); err != nil { + return errors.New("validate stored task claims") + } + if !deviceauth.ValidDeviceID(deviceID) || !validUUID(taskID) || !validUUID(authorizationID) || !validUUID(attemptID) || + generation <= 0 || nonceType != "blob" || nonceLength != sha256.Size || len(nonce) != sha256.Size || + hashType != "blob" || hashLength != sha256.Size || len(storedHash) != sha256.Size || + authorizationTaskVersion <= 0 || !digitsOnly(goodsID) || color == "" || size == "" || quantity <= 0 || + !canonicalMoney(price) || !validCanonicalTime(expires) || (closed.Valid && !validCanonicalTime(closed.String)) || + !attemptGeneration.Valid || attemptGeneration.Int64 != int64(generation) || + !validAttemptStatus(attemptStatus) || !validAuthorizationStatus(authorizationStatus) || !validTaskStatus(taskStatus) { + return errors.New("stored task claim metadata is invalid") + } + token := deriveToken(store.secret, deviceID, taskID, authorizationID, attemptID, generation, nonce) + if !matchingHash(tokenHash(token), storedHash) { + return errors.New("task claim secret does not match stored claims") + } + } + if err := rows.Err(); err != nil { + return errors.New("validate stored task claims") + } + return nil +} + +func (store *Store) ClaimNext(ctx context.Context, deviceID string, command ClaimCommand) (ClaimResponse, bool, error) { + if !deviceauth.ValidDeviceID(deviceID) || !validUUID(command.SessionID) || !validUUID(command.ClaimRequestID) { + return ClaimResponse{}, false, ErrInvalid + } + writeCtx, cancel := context.WithTimeout(ctx, writeTimeout) + defer cancel() + select { + case store.writeGate <- struct{}{}: + defer func() { <-store.writeGate }() + case <-writeCtx.Done(): + return ClaimResponse{}, false, writeCtx.Err() + } + + transaction, err := store.database.BeginTx(writeCtx, nil) + if err != nil { + return ClaimResponse{}, false, err + } + defer transaction.Rollback() + + // This must be the transaction's first database statement. The no-op conditional UPDATE takes + // SQLite's write position and linearizes a concurrent credential revocation before any replay, + // EMPTY response, conflict response, candidate read, or other business write is possible. + if store.beforeLinearization != nil { + store.beforeLinearization() + } + active, err := transaction.ExecContext(writeCtx, `UPDATE device_credentials SET status = status + WHERE device_id = ? AND status = 'ACTIVE' AND revoked_at IS NULL`, deviceID) + if err != nil { + return ClaimResponse{}, false, err + } + if ok, err := exactlyOne(active); err != nil { + return ClaimResponse{}, false, err + } else if !ok { + return ClaimResponse{}, false, ErrDeviceInactive + } + if store.afterLinearization != nil { + store.afterLinearization() + } + + now, err := store.serverNow() + if err != nil { + return ClaimResponse{}, false, err + } + request, found, err := findClaimRequest(writeCtx, transaction, command.ClaimRequestID) + if err != nil { + return ClaimResponse{}, false, err + } + if found { + if request.DeviceID != deviceID || request.SessionID != command.SessionID { + return ClaimResponse{}, false, ErrIdempotencyConflict + } + switch request.Outcome { + case "EMPTY": + if err := transaction.Commit(); err != nil { + return ClaimResponse{}, false, err + } + return ClaimResponse{}, false, nil + case "BLOCKED": + if err := transaction.Commit(); err != nil { + return ClaimResponse{}, false, err + } + return ClaimResponse{}, false, ErrRequiresManual + case "CLAIMED": + record, found, err := store.loadClaimByAttempt(writeCtx, transaction, request.AttemptID) + if err != nil || !found { + if err == nil { + err = errors.New("stored claim request has no claim") + } + return ClaimResponse{}, false, err + } + response, err := store.responseFor(record, request.ResponseLeaseExpiresAt) + if err != nil { + return ClaimResponse{}, false, err + } + if err := transaction.Commit(); err != nil { + return ClaimResponse{}, false, err + } + return response, true, nil + default: + return ClaimResponse{}, false, errors.New("stored claim request outcome is invalid") + } + } + + existing, found, err := store.loadOpenClaimByDevice(writeCtx, transaction, deviceID) + if err != nil { + return ClaimResponse{}, false, err + } + if found { + current := existing.SessionID == command.SessionID && existing.ClosedAt == "" && + existing.LeaseExpiresAt.After(now) && existing.AuthorizationExpiresAt.After(now) && + existing.CurrentAuthorizationExpiresAt.After(now) && existing.AuthorizationStatus == "CLAIMED" && + existing.authorizationConsistent() && existing.recoverableBusinessState() + if !current { + if err := insertClaimRequest(writeCtx, transaction, command.ClaimRequestID, deviceID, command.SessionID, "BLOCKED", "", "", "manual_recovery_required", now); err != nil { + return ClaimResponse{}, false, err + } + if err := transaction.Commit(); err != nil { + return ClaimResponse{}, false, err + } + return ClaimResponse{}, false, ErrRequiresManual + } + response, err := store.responseFor(existing, existing.LeaseExpiresText) + if err != nil { + return ClaimResponse{}, false, err + } + if err := insertClaimRequest(writeCtx, transaction, command.ClaimRequestID, deviceID, command.SessionID, "CLAIMED", existing.AttemptID, existing.LeaseExpiresText, "", now); err != nil { + return ClaimResponse{}, false, err + } + if err := transaction.Commit(); err != nil { + return ClaimResponse{}, false, err + } + return response, true, nil + } + + candidate, found, err := findCandidate(writeCtx, transaction, now) + if err != nil { + return ClaimResponse{}, false, err + } + if !found { + if err := insertClaimRequest(writeCtx, transaction, command.ClaimRequestID, deviceID, command.SessionID, "EMPTY", "", "", "", now); err != nil { + return ClaimResponse{}, false, err + } + if err := transaction.Commit(); err != nil { + return ClaimResponse{}, false, err + } + return ClaimResponse{}, false, nil + } + + generation, err := nextGeneration(writeCtx, transaction, candidate.TaskID) + if err != nil { + return ClaimResponse{}, false, err + } + attemptID, err := store.newUUID() + if err != nil { + return ClaimResponse{}, false, err + } + nonce, err := store.randomBytes(sha256.Size) + if err != nil { + return ClaimResponse{}, false, err + } + token := deriveToken(store.secret, deviceID, candidate.TaskID, candidate.AuthorizationID, attemptID, generation, nonce) + storedTokenHash := tokenHash(token) + leaseExpires := now.Add(store.leaseTTL) + if candidate.AuthorizationExpiresAt.Before(leaseExpires) { + leaseExpires = candidate.AuthorizationExpiresAt + } + leaseText := formatTime(leaseExpires) + nowText := formatTime(now) + + authorizationUpdate, err := transaction.ExecContext(writeCtx, `UPDATE order_authorizations SET status = 'CLAIMED' + WHERE id = ? AND task_id = ? AND status = 'ACTIVE' AND task_version = ? + AND goods_id = ? AND sku_color = ? AND sku_size = ? AND quantity = ? + AND total_price_cap = ? AND expires_at = ?`, + candidate.AuthorizationID, candidate.TaskID, candidate.TaskVersion, candidate.GoodsID, + candidate.SKUColor, candidate.SKUSize, candidate.Quantity, candidate.TotalPriceCap, + candidate.AuthorizationExpiresText) + if err != nil { + return ClaimResponse{}, false, err + } + if ok, err := exactlyOne(authorizationUpdate); err != nil || !ok { + if err == nil { + err = errors.New("authorization changed during claim") + } + return ClaimResponse{}, false, err + } + taskUpdate, err := transaction.ExecContext(writeCtx, `UPDATE tasks SET status = 'CLAIMED', version = version + 1, updated_at = ? + WHERE id = ? AND status = 'PENDING' AND version = ? AND title = ? AND goods_id = ? + AND sku_color = ? AND sku_size = ? AND quantity = ? AND max_total_price = ?`, + nowText, candidate.TaskID, candidate.TaskVersion, candidate.Title, candidate.GoodsID, + candidate.SKUColor, candidate.SKUSize, candidate.Quantity, candidate.TotalPriceCap) + if err != nil { + return ClaimResponse{}, false, err + } + if ok, err := exactlyOne(taskUpdate); err != nil || !ok { + if err == nil { + err = errors.New("task changed during claim") + } + return ClaimResponse{}, false, err + } + if _, err := transaction.ExecContext(writeCtx, `INSERT INTO purchase_attempts + (id, task_id, authorization_id, claim_generation, status, started_at) + VALUES (?, ?, ?, ?, 'CLAIMED', ?)`, attemptID, candidate.TaskID, candidate.AuthorizationID, generation, nowText); err != nil { + return ClaimResponse{}, false, err + } + if _, err := transaction.ExecContext(writeCtx, `INSERT INTO purchase_attempt_claims + (attempt_id, task_id, authorization_id, claimed_by_device_id, session_id, claim_generation, + task_version, task_title, authorization_task_version, goods_id, sku_color, sku_size, quantity, + total_price_cap, authorization_expires_at, claim_nonce, claim_token_sha256, + lease_expires_at, claimed_at, closed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`, + attemptID, candidate.TaskID, candidate.AuthorizationID, deviceID, command.SessionID, generation, + candidate.TaskVersion+1, candidate.Title, candidate.TaskVersion, candidate.GoodsID, + candidate.SKUColor, candidate.SKUSize, candidate.Quantity, candidate.TotalPriceCap, + candidate.AuthorizationExpiresText, nonce, storedTokenHash, leaseText, nowText); err != nil { + return ClaimResponse{}, false, err + } + if err := insertClaimRequest(writeCtx, transaction, command.ClaimRequestID, deviceID, command.SessionID, "CLAIMED", attemptID, leaseText, "", now); err != nil { + return ClaimResponse{}, false, err + } + response := ClaimResponse{ + Task: ClaimedTask{ID: candidate.TaskID, Version: candidate.TaskVersion + 1, Title: candidate.Title, + ProductURL: productURL(candidate.GoodsID), GoodsID: candidate.GoodsID, SKUColor: candidate.SKUColor, + SKUSize: candidate.SKUSize, Quantity: candidate.Quantity, MaxTotalPrice: candidate.TotalPriceCap}, + Authorization: ClaimedAuthorization{ID: candidate.AuthorizationID, TaskVersion: candidate.TaskVersion, ExpiresAt: candidate.AuthorizationExpiresText}, + Attempt: ClaimedAttempt{ID: attemptID, ClaimToken: hex.EncodeToString(token), ClaimGeneration: generation, LeaseExpiresAt: leaseText}, + } + if err := transaction.Commit(); err != nil { + return ClaimResponse{}, false, err + } + return response, true, nil +} + +func (store *Store) Renew(ctx context.Context, deviceID string, command RenewCommand) (RenewResponse, error) { + providedToken, tokenOK := decodeToken(command.ClaimToken) + if !deviceauth.ValidDeviceID(deviceID) || !validUUID(command.TaskID) || !validUUID(command.RenewRequestID) || + !validUUID(command.SessionID) || !validUUID(command.AttemptID) || command.ClaimGeneration <= 0 || + !tokenOK || !validCanonicalTime(command.ExpectedLeaseExpiresAt) { + return RenewResponse{}, ErrInvalid + } + providedHash := tokenHash(providedToken) + writeCtx, cancel := context.WithTimeout(ctx, writeTimeout) + defer cancel() + select { + case store.writeGate <- struct{}{}: + defer func() { <-store.writeGate }() + case <-writeCtx.Done(): + return RenewResponse{}, writeCtx.Err() + } + transaction, err := store.database.BeginTx(writeCtx, nil) + if err != nil { + return RenewResponse{}, err + } + defer transaction.Rollback() + + // As in ClaimNext, this is deliberately the first database statement in the transaction. + if store.beforeLinearization != nil { + store.beforeLinearization() + } + active, err := transaction.ExecContext(writeCtx, `UPDATE device_credentials SET status = status + WHERE device_id = ? AND status = 'ACTIVE' AND revoked_at IS NULL`, deviceID) + if err != nil { + return RenewResponse{}, err + } + if ok, err := exactlyOne(active); err != nil { + return RenewResponse{}, err + } else if !ok { + return RenewResponse{}, ErrDeviceInactive + } + if store.afterLinearization != nil { + store.afterLinearization() + } + + renewal, found, err := findRenewal(writeCtx, transaction, command.RenewRequestID) + if err != nil { + return RenewResponse{}, err + } + if found { + if renewal.TaskID != command.TaskID || renewal.AttemptID != command.AttemptID || renewal.DeviceID != deviceID || + renewal.SessionID != command.SessionID || renewal.Generation != command.ClaimGeneration || + renewal.ExpectedLeaseExpiresAt != command.ExpectedLeaseExpiresAt || !matchingHash(renewal.TokenHash, providedHash) { + return RenewResponse{}, ErrIdempotencyConflict + } + response := RenewResponse{TaskID: renewal.TaskID, AttemptID: renewal.AttemptID, ClaimGeneration: renewal.Generation, LeaseExpiresAt: renewal.LeaseExpiresAt} + if err := transaction.Commit(); err != nil { + return RenewResponse{}, err + } + return response, nil + } + + now, err := store.serverNow() + if err != nil { + return RenewResponse{}, err + } + record, found, err := store.loadClaimByAttempt(writeCtx, transaction, command.AttemptID) + if err != nil { + return RenewResponse{}, err + } + if !found || record.TaskID != command.TaskID || record.DeviceID != deviceID || record.SessionID != command.SessionID || + record.Generation != command.ClaimGeneration || !matchingHash(record.TokenHash, providedHash) { + return RenewResponse{}, ErrNotCurrent + } + stateCurrent := record.ClosedAt == "" && record.LeaseExpiresAt.After(now) && record.AuthorizationExpiresAt.After(now) && + record.CurrentAuthorizationExpiresAt.After(now) && record.AuthorizationStatus == "CLAIMED" && + record.authorizationConsistent() && record.recoverableBusinessState() + if !stateCurrent || record.LeaseExpiresText != command.ExpectedLeaseExpiresAt { + return RenewResponse{}, ErrNotCurrent + } + leaseExpires := now.Add(store.leaseTTL) + if record.AuthorizationExpiresAt.Before(leaseExpires) { + leaseExpires = record.AuthorizationExpiresAt + } + leaseText := formatTime(leaseExpires) + updated, err := transaction.ExecContext(writeCtx, `UPDATE purchase_attempt_claims SET lease_expires_at = ? + WHERE attempt_id = ? AND lease_expires_at = ? AND closed_at IS NULL`, leaseText, command.AttemptID, command.ExpectedLeaseExpiresAt) + if err != nil { + return RenewResponse{}, err + } + if ok, err := exactlyOne(updated); err != nil || !ok { + if err == nil { + err = ErrNotCurrent + } + return RenewResponse{}, err + } + if _, err := transaction.ExecContext(writeCtx, `INSERT INTO purchase_attempt_lease_renewals + (renew_request_id, task_id, attempt_id, device_id, session_id, claim_generation, + claim_token_sha256, expected_lease_expires_at, lease_expires_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + command.RenewRequestID, command.TaskID, command.AttemptID, deviceID, command.SessionID, + command.ClaimGeneration, record.TokenHash, command.ExpectedLeaseExpiresAt, leaseText, formatTime(now)); err != nil { + return RenewResponse{}, err + } + response := RenewResponse{TaskID: command.TaskID, AttemptID: command.AttemptID, ClaimGeneration: command.ClaimGeneration, LeaseExpiresAt: leaseText} + if err := transaction.Commit(); err != nil { + return RenewResponse{}, err + } + return response, nil +} + +type claimRequestRecord struct { + DeviceID, SessionID, Outcome, AttemptID, ResponseLeaseExpiresAt string +} + +func findClaimRequest(ctx context.Context, transaction *sql.Tx, requestID string) (claimRequestRecord, bool, error) { + var record claimRequestRecord + var attemptID, responseLease sql.NullString + err := transaction.QueryRowContext(ctx, `SELECT device_id, session_id, outcome, attempt_id, response_lease_expires_at + FROM task_claim_requests WHERE claim_request_id = ?`, requestID). + Scan(&record.DeviceID, &record.SessionID, &record.Outcome, &attemptID, &responseLease) + if errors.Is(err, sql.ErrNoRows) { + return claimRequestRecord{}, false, nil + } + if err != nil { + return claimRequestRecord{}, false, err + } + record.AttemptID, record.ResponseLeaseExpiresAt = attemptID.String, responseLease.String + return record, true, nil +} + +func insertClaimRequest(ctx context.Context, transaction *sql.Tx, requestID, deviceID, sessionID, outcome, attemptID, responseLease, errorCode string, now time.Time) error { + var attempt, lease, code any + if attemptID != "" { + attempt = attemptID + } + if responseLease != "" { + lease = responseLease + } + if errorCode != "" { + code = errorCode + } + _, err := transaction.ExecContext(ctx, `INSERT INTO task_claim_requests + (claim_request_id, device_id, session_id, outcome, attempt_id, response_lease_expires_at, error_code, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, requestID, deviceID, sessionID, outcome, attempt, lease, code, formatTime(now)) + return err +} + +type renewalRecord struct { + TaskID, AttemptID, DeviceID, SessionID string + Generation int + TokenHash []byte + ExpectedLeaseExpiresAt, LeaseExpiresAt string +} + +func findRenewal(ctx context.Context, transaction *sql.Tx, requestID string) (renewalRecord, bool, error) { + var record renewalRecord + err := transaction.QueryRowContext(ctx, `SELECT task_id, attempt_id, device_id, session_id, + claim_generation, claim_token_sha256, expected_lease_expires_at, lease_expires_at + FROM purchase_attempt_lease_renewals WHERE renew_request_id = ?`, requestID). + Scan(&record.TaskID, &record.AttemptID, &record.DeviceID, &record.SessionID, &record.Generation, + &record.TokenHash, &record.ExpectedLeaseExpiresAt, &record.LeaseExpiresAt) + if errors.Is(err, sql.ErrNoRows) { + return renewalRecord{}, false, nil + } + return record, err == nil, err +} + +type claimRecord struct { + AttemptID, TaskID, AuthorizationID, DeviceID, SessionID string + Generation, TaskVersion, CurrentTaskVersion int + TaskTitle string + Nonce, TokenHash []byte + LeaseExpiresText, ClaimedAt, ClosedAt string + LeaseExpiresAt time.Time + AuthorizationTaskVersion int + GoodsID, SKUColor, SKUSize, TotalPriceCap string + Quantity int + AuthorizationExpiresText, AuthorizationStatus string + AuthorizationExpiresAt time.Time + CurrentAuthorizationTaskVersion int + CurrentGoodsID, CurrentSKUColor, CurrentSKUSize string + CurrentQuantity int + CurrentTotalPriceCap, CurrentAuthorizationExpiresText string + CurrentAuthorizationExpiresAt time.Time + AttemptStatus, TaskStatus string + CurrentTaskTitle, CurrentTaskGoodsID string + CurrentTaskSKUColor, CurrentTaskSKUSize string + CurrentTaskQuantity int + CurrentTaskMaxTotalPrice string + CurrentAttemptGeneration int +} + +const claimSelect = `SELECT claims.attempt_id, claims.task_id, claims.authorization_id, + claims.claimed_by_device_id, claims.session_id, claims.claim_generation, claims.task_version, + claims.task_title, claims.authorization_task_version, claims.goods_id, claims.sku_color, + claims.sku_size, claims.quantity, claims.total_price_cap, claims.authorization_expires_at, + claims.claim_nonce, claims.claim_token_sha256, claims.lease_expires_at, + claims.claimed_at, claims.closed_at, authorizations.task_version, authorizations.goods_id, + authorizations.sku_color, authorizations.sku_size, authorizations.quantity, + authorizations.total_price_cap, authorizations.expires_at, authorizations.status, + attempts.claim_generation, attempts.status, tasks.status, tasks.version, tasks.title, tasks.goods_id, + tasks.sku_color, tasks.sku_size, tasks.quantity, tasks.max_total_price + FROM purchase_attempt_claims AS claims + JOIN order_authorizations AS authorizations + ON authorizations.task_id = claims.task_id AND authorizations.id = claims.authorization_id + JOIN purchase_attempts AS attempts ON attempts.id = claims.attempt_id + JOIN tasks ON tasks.id = claims.task_id ` + +func (store *Store) loadOpenClaimByDevice(ctx context.Context, transaction *sql.Tx, deviceID string) (claimRecord, bool, error) { + return store.scanClaim(transaction.QueryRowContext(ctx, claimSelect+`WHERE claims.claimed_by_device_id = ? AND claims.closed_at IS NULL`, deviceID)) +} + +func (store *Store) loadClaimByAttempt(ctx context.Context, transaction *sql.Tx, attemptID string) (claimRecord, bool, error) { + return store.scanClaim(transaction.QueryRowContext(ctx, claimSelect+`WHERE claims.attempt_id = ?`, attemptID)) +} + +type rowScanner interface{ Scan(...any) error } + +func (store *Store) scanClaim(row rowScanner) (claimRecord, bool, error) { + var record claimRecord + var closed sql.NullString + err := row.Scan(&record.AttemptID, &record.TaskID, &record.AuthorizationID, &record.DeviceID, + &record.SessionID, &record.Generation, &record.TaskVersion, &record.TaskTitle, + &record.AuthorizationTaskVersion, &record.GoodsID, &record.SKUColor, &record.SKUSize, + &record.Quantity, &record.TotalPriceCap, &record.AuthorizationExpiresText, + &record.Nonce, &record.TokenHash, &record.LeaseExpiresText, &record.ClaimedAt, &closed, + &record.CurrentAuthorizationTaskVersion, &record.CurrentGoodsID, &record.CurrentSKUColor, + &record.CurrentSKUSize, &record.CurrentQuantity, &record.CurrentTotalPriceCap, + &record.CurrentAuthorizationExpiresText, + &record.AuthorizationStatus, &record.CurrentAttemptGeneration, &record.AttemptStatus, + &record.TaskStatus, &record.CurrentTaskVersion, + &record.CurrentTaskTitle, &record.CurrentTaskGoodsID, &record.CurrentTaskSKUColor, + &record.CurrentTaskSKUSize, &record.CurrentTaskQuantity, &record.CurrentTaskMaxTotalPrice) + if errors.Is(err, sql.ErrNoRows) { + return claimRecord{}, false, nil + } + if err != nil { + return claimRecord{}, false, err + } + record.ClosedAt = closed.String + if !validUUID(record.AttemptID) || !validUUID(record.TaskID) || !validUUID(record.AuthorizationID) || + !deviceauth.ValidDeviceID(record.DeviceID) || !validUUID(record.SessionID) || record.Generation <= 0 || + record.CurrentAttemptGeneration != record.Generation || + record.TaskVersion <= 0 || record.AuthorizationTaskVersion <= 0 || strings.TrimSpace(record.TaskTitle) == "" || + !digitsOnly(record.GoodsID) || record.SKUColor == "" || record.SKUSize == "" || record.Quantity <= 0 || + !canonicalMoney(record.TotalPriceCap) || len(record.Nonce) != sha256.Size || len(record.TokenHash) != sha256.Size { + return claimRecord{}, false, errors.New("stored task claim metadata is invalid") + } + record.LeaseExpiresAt, err = parseCanonicalTime(record.LeaseExpiresText) + if err != nil { + return claimRecord{}, false, errors.New("stored task claim lease is invalid") + } + record.AuthorizationExpiresAt, err = parseCanonicalTime(record.AuthorizationExpiresText) + if err != nil { + return claimRecord{}, false, errors.New("stored authorization expiry is invalid") + } + record.CurrentAuthorizationExpiresAt, err = parseCanonicalTime(record.CurrentAuthorizationExpiresText) + if err != nil { + return claimRecord{}, false, errors.New("current authorization expiry is invalid") + } + derived := deriveToken(store.secret, record.DeviceID, record.TaskID, record.AuthorizationID, record.AttemptID, record.Generation, record.Nonce) + if !matchingHash(tokenHash(derived), record.TokenHash) { + return claimRecord{}, false, errors.New("task claim secret does not match stored claim") + } + return record, true, nil +} + +func (record claimRecord) authorizationConsistent() bool { + return record.AuthorizationTaskVersion == record.CurrentAuthorizationTaskVersion && + record.GoodsID == record.CurrentGoodsID && record.SKUColor == record.CurrentSKUColor && + record.SKUSize == record.CurrentSKUSize && record.Quantity == record.CurrentQuantity && + record.TotalPriceCap == record.CurrentTotalPriceCap && + record.AuthorizationExpiresText == record.CurrentAuthorizationExpiresText && + record.TaskTitle == record.CurrentTaskTitle && record.GoodsID == record.CurrentTaskGoodsID && + record.SKUColor == record.CurrentTaskSKUColor && record.SKUSize == record.CurrentTaskSKUSize && + record.Quantity == record.CurrentTaskQuantity && record.TotalPriceCap == record.CurrentTaskMaxTotalPrice +} + +func (record claimRecord) recoverableBusinessState() bool { + if record.TaskStatus == "CLAIMED" && record.AttemptStatus == "CLAIMED" { + return record.CurrentTaskVersion == record.TaskVersion + } + // A later server task may advance this same attempt to ORDERING. A valid lease and identical + // ownership recover that attempt; claim-next still cannot select another task. + return record.TaskStatus == "ORDERING" && record.AttemptStatus == "ORDERING" && + record.TaskVersion < math.MaxInt && record.CurrentTaskVersion == record.TaskVersion+1 +} + +func (store *Store) responseFor(record claimRecord, responseLease string) (ClaimResponse, error) { + if !validCanonicalTime(responseLease) { + return ClaimResponse{}, errors.New("stored claim response lease is invalid") + } + token := deriveToken(store.secret, record.DeviceID, record.TaskID, record.AuthorizationID, record.AttemptID, record.Generation, record.Nonce) + return ClaimResponse{ + Task: ClaimedTask{ID: record.TaskID, Version: record.TaskVersion, Title: record.TaskTitle, + ProductURL: productURL(record.GoodsID), GoodsID: record.GoodsID, SKUColor: record.SKUColor, + SKUSize: record.SKUSize, Quantity: record.Quantity, MaxTotalPrice: record.TotalPriceCap}, + Authorization: ClaimedAuthorization{ID: record.AuthorizationID, TaskVersion: record.AuthorizationTaskVersion, ExpiresAt: record.AuthorizationExpiresText}, + Attempt: ClaimedAttempt{ID: record.AttemptID, ClaimToken: hex.EncodeToString(token), ClaimGeneration: record.Generation, LeaseExpiresAt: responseLease}, + }, nil +} + +type candidate struct { + AuthorizationID, TaskID, Title, GoodsID, SKUColor, SKUSize, TotalPriceCap string + TaskVersion, Quantity int + AuthorizationExpiresText string + AuthorizationExpiresAt time.Time +} + +func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (candidate, bool, error) { + rows, err := transaction.QueryContext(ctx, `SELECT authorizations.id, tasks.id, tasks.version, + tasks.title, tasks.goods_id, tasks.sku_color, tasks.sku_size, tasks.quantity, + tasks.max_total_price, authorizations.expires_at + FROM order_authorizations AS authorizations + JOIN tasks ON tasks.id = authorizations.task_id + WHERE authorizations.status = 'ACTIVE' AND tasks.status = 'PENDING' + AND authorizations.task_version = tasks.version + AND authorizations.goods_id = tasks.goods_id + AND authorizations.sku_color = tasks.sku_color + AND authorizations.sku_size = tasks.sku_size + AND authorizations.quantity = tasks.quantity + AND authorizations.total_price_cap = tasks.max_total_price + ORDER BY authorizations.created_at, authorizations.rowid, authorizations.id`) + if err != nil { + return candidate{}, false, err + } + defer rows.Close() + for rows.Next() { + var item candidate + if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &item.Title, + &item.GoodsID, &item.SKUColor, &item.SKUSize, &item.Quantity, &item.TotalPriceCap, + &item.AuthorizationExpiresText); err != nil { + return candidate{}, false, err + } + item.AuthorizationExpiresAt, err = parseCanonicalTime(item.AuthorizationExpiresText) + if err != nil { + return candidate{}, false, errors.New("stored authorization expiry is invalid") + } + if !validCandidate(item) { + return candidate{}, false, errors.New("stored claim candidate is invalid") + } + if item.AuthorizationExpiresAt.After(now) { + if err := rows.Close(); err != nil { + return candidate{}, false, err + } + return item, true, nil + } + } + if err := rows.Err(); err != nil { + return candidate{}, false, err + } + return candidate{}, false, nil +} + +func validCandidate(item candidate) bool { + return validUUID(item.AuthorizationID) && validUUID(item.TaskID) && item.TaskVersion > 0 && item.TaskVersion < math.MaxInt && + strings.TrimSpace(item.Title) != "" && digitsOnly(item.GoodsID) && item.SKUColor != "" && item.SKUSize != "" && + item.Quantity > 0 && canonicalMoney(item.TotalPriceCap) +} + +func validAttemptStatus(value sql.NullString) bool { + return value.Valid && oneOf(value.String, "CLAIMED", "ORDERING", "FAILED", "FENCED", "ABANDONED") +} + +func validAuthorizationStatus(value sql.NullString) bool { + return value.Valid && oneOf(value.String, "ACTIVE", "CLAIMED", "FENCED", "CONSUMED", "EXPIRED", "ABANDONED") +} + +func validTaskStatus(value sql.NullString) bool { + return value.Valid && oneOf(value.String, "DRAFT", "PENDING", "CLAIMED", "ORDERING", "NEEDS_MANUAL", + "WAITING_PAYMENT", "RECONCILIATION_REQUIRED", "SUCCEEDED", "FAILED", "CANCELED") +} + +func oneOf(value string, allowed ...string) bool { + for _, item := range allowed { + if value == item { + return true + } + } + return false +} + +func nextGeneration(ctx context.Context, transaction *sql.Tx, taskID string) (int, error) { + var maximum int64 + if err := transaction.QueryRowContext(ctx, `SELECT COALESCE(MAX(claim_generation), 0) FROM purchase_attempts WHERE task_id = ?`, taskID).Scan(&maximum); err != nil { + return 0, err + } + if maximum < 0 || maximum >= int64(math.MaxInt) { + return 0, errors.New("task claim generation is exhausted") + } + return int(maximum) + 1, nil +} + +func (store *Store) serverNow() (time.Time, error) { + now := store.now().UTC() + if now.IsZero() { + return time.Time{}, errors.New("task claim clock is invalid") + } + return now, nil +} + +func (store *Store) randomBytes(size int) ([]byte, error) { + value := make([]byte, size) + store.randomMu.Lock() + _, err := io.ReadFull(store.random, value) + store.randomMu.Unlock() + if err != nil { + return nil, fmt.Errorf("generate task claim randomness: %w", err) + } + return value, nil +} + +func (store *Store) newUUID() (string, error) { + value, err := store.randomBytes(16) + if err != nil { + return "", err + } + value[6] = (value[6] & 0x0f) | 0x40 + value[8] = (value[8] & 0x3f) | 0x80 + encoded := hex.EncodeToString(value) + return encoded[:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:], nil +} + +func exactlyOne(result sql.Result) (bool, error) { + rows, err := result.RowsAffected() + return rows == 1, err +} + +func formatTime(value time.Time) string { return value.UTC().Format(time.RFC3339Nano) } + +func parseCanonicalTime(value string) (time.Time, error) { + if !strings.HasSuffix(value, "Z") || strings.TrimSpace(value) != value { + return time.Time{}, ErrInvalid + } + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil || parsed.Location() != time.UTC || formatTime(parsed) != value { + return time.Time{}, ErrInvalid + } + return parsed, nil +} + +func validCanonicalTime(value string) bool { + _, err := parseCanonicalTime(value) + return err == nil +} + +func validUUID(value string) bool { + if len(value) != 36 { + return false + } + for index, character := range value { + if index == 8 || index == 13 || index == 18 || index == 23 { + if character != '-' { + return false + } + continue + } + if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') { + return false + } + } + return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b') +} + +func digitsOnly(value string) bool { + if value == "" { + return false + } + for _, character := range value { + if character < '0' || character > '9' { + return false + } + } + return true +} + +func canonicalMoney(value string) bool { + parts := strings.Split(value, ".") + if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) != 2 || (len(parts[0]) > 1 && parts[0][0] == '0') { + return false + } + for _, part := range parts { + if !digitsOnly(part) { + return false + } + } + cents := new(big.Int) + _, ok := cents.SetString(parts[0]+parts[1], 10) + return ok && cents.Sign() > 0 +} + +func productURL(goodsID string) string { + return "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID +} diff --git a/admin/internal/taskclaim/store_test.go b/admin/internal/taskclaim/store_test.go new file mode 100644 index 0000000..fba5aad --- /dev/null +++ b/admin/internal/taskclaim/store_test.go @@ -0,0 +1,895 @@ +package taskclaim + +import ( + "bytes" + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "path/filepath" + "reflect" + "runtime" + "strings" + "sync" + "testing" + "time" + + "cmbuyer/admin/internal/migrations" + "cmbuyer/admin/internal/storage/sqlite" +) + +const ( + testDeviceA = "10000000-0000-4000-8000-000000000001" + testDeviceB = "10000000-0000-4000-8000-000000000002" + testSessionA = "20000000-0000-4000-8000-000000000001" + testSessionB = "20000000-0000-4000-8000-000000000002" + testTaskA = "30000000-0000-4000-8000-000000000001" + testTaskB = "30000000-0000-4000-8000-000000000002" + testAuthA = "40000000-0000-4000-8000-000000000001" + testAuthB = "40000000-0000-4000-8000-000000000002" + testClaimRequestA = "50000000-0000-4000-8000-000000000001" + testClaimRequestB = "50000000-0000-4000-8000-000000000002" + testClaimRequestC = "50000000-0000-4000-8000-000000000003" + testRenewRequestA = "60000000-0000-4000-8000-000000000001" + testRenewRequestB = "60000000-0000-4000-8000-000000000002" +) + +var testNow = time.Date(2026, 8, 4, 1, 2, 3, 123000000, time.UTC) + +func TestClaimReplayEmptyManualAndSecretRecovery(t *testing.T) { + database := openClaimTestDatabase(t) + insertDevice(t, database, testDeviceA, []byte("device-a")) + insertDevice(t, database, testDeviceB, []byte("device-b")) + insertCandidate(t, database, testTaskA, testAuthA, testNow.Add(-time.Minute), testNow.Add(10*time.Minute), true) + secret := bytes.Repeat([]byte{0x11}, 32) + store := mustStore(t, database, secret, 30*time.Second) + store.now = func() time.Time { return testNow } + + command := ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA} + claimed, found, err := store.ClaimNext(context.Background(), testDeviceA, command) + if err != nil || !found { + t.Fatalf("ClaimNext = found %v, err %v", found, err) + } + if claimed.Task.ID != testTaskA || claimed.Task.Version != 3 || claimed.Authorization.ID != testAuthA || + claimed.Authorization.TaskVersion != 2 || claimed.Attempt.ClaimGeneration != 1 || + len(claimed.Attempt.ClaimToken) != 64 || strings.ToLower(claimed.Attempt.ClaimToken) != claimed.Attempt.ClaimToken { + t.Fatalf("unexpected claim response: %#v", claimed) + } + assertClaimState(t, database, 1, "CLAIMED", "CLAIMED") + assertNoPlaintextTokenColumnOrValue(t, database, claimed.Attempt.ClaimToken) + + replayed, found, err := store.ClaimNext(context.Background(), testDeviceA, command) + if err != nil || !found || !reflect.DeepEqual(replayed, claimed) { + t.Fatalf("same request replay = %#v, found %v, err %v", replayed, found, err) + } + restarted := mustStore(t, database, secret, 30*time.Second) + restarted.now = func() time.Time { return testNow.Add(5 * time.Second) } + replayed, found, err = restarted.ClaimNext(context.Background(), testDeviceA, command) + if err != nil || !found || !reflect.DeepEqual(replayed, claimed) { + t.Fatalf("restart replay = %#v, found %v, err %v", replayed, found, err) + } + if _, err := NewStore(database, bytes.Repeat([]byte{0x22}, 32), 30*time.Second); err == nil { + t.Fatal("NewStore accepted a secret that cannot rebuild existing claims") + } + sameSession, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{ + SessionID: testSessionA, ClaimRequestID: "50000000-0000-4000-8000-000000000005", + }) + if err != nil || !found || sameSession.Attempt.ID != claimed.Attempt.ID || sameSession.Attempt.ClaimToken != claimed.Attempt.ClaimToken { + t.Fatalf("same-session recovery = %#v, found %v, err %v", sameSession, found, err) + } + if _, err := database.Exec(`UPDATE order_authorizations SET goods_id='937122477376', sku_color='白色', + sku_size='L', quantity=3, total_price_cap='40.00', expires_at=? WHERE id=?`, + formatTime(testNow.Add(20*time.Minute)), testAuthA); err != nil { + t.Fatalf("mutate authorization source: %v", err) + } + if _, err := database.Exec(`UPDATE tasks SET title='漂移标题', goods_id='937122477376', sku_color='白色', + sku_size='L', quantity=3, max_total_price='40.00' WHERE id=?`, testTaskA); err != nil { + t.Fatalf("mutate task source: %v", err) + } + afterDrift := mustStore(t, database, secret, 30*time.Second) + afterDrift.now = func() time.Time { return testNow.Add(6 * time.Second) } + stable, found, err := afterDrift.ClaimNext(context.Background(), testDeviceA, command) + if err != nil || !found || !reflect.DeepEqual(stable, claimed) { + t.Fatalf("source-drift replay = %#v, found %v, err %v; want original %#v", stable, found, err, claimed) + } + if _, _, err := afterDrift.ClaimNext(context.Background(), testDeviceA, ClaimCommand{ + SessionID: testSessionA, ClaimRequestID: "50000000-0000-4000-8000-000000000006", + }); !errors.Is(err, ErrRequiresManual) { + t.Fatalf("new recovery after source drift error = %v", err) + } + + manualCommand := ClaimCommand{SessionID: testSessionB, ClaimRequestID: testClaimRequestB} + if _, _, err := store.ClaimNext(context.Background(), testDeviceA, manualCommand); !errors.Is(err, ErrRequiresManual) { + t.Fatalf("different session error = %v, want ErrRequiresManual", err) + } + if _, _, err := store.ClaimNext(context.Background(), testDeviceA, manualCommand); !errors.Is(err, ErrRequiresManual) { + t.Fatalf("manual replay error = %v, want ErrRequiresManual", err) + } + assertClaimState(t, database, 1, "CLAIMED", "CLAIMED") + + emptyCommand := ClaimCommand{SessionID: testSessionB, ClaimRequestID: testClaimRequestC} + if _, found, err := store.ClaimNext(context.Background(), testDeviceB, emptyCommand); err != nil || found { + t.Fatalf("empty claim = found %v, err %v", found, err) + } + insertCandidate(t, database, testTaskB, testAuthB, testNow, testNow.Add(10*time.Minute), true) + if _, found, err := store.ClaimNext(context.Background(), testDeviceB, emptyCommand); err != nil || found { + t.Fatalf("persisted EMPTY replay = found %v, err %v", found, err) + } + claimedB, found, err := store.ClaimNext(context.Background(), testDeviceB, ClaimCommand{ + SessionID: testSessionB, ClaimRequestID: "50000000-0000-4000-8000-000000000004", + }) + if err != nil || !found || claimedB.Task.ID != testTaskB { + t.Fatalf("new request after EMPTY = %#v, found %v, err %v", claimedB, found, err) + } + var distinctNonces int + if err := database.QueryRow("SELECT COUNT(DISTINCT claim_nonce) FROM purchase_attempt_claims").Scan(&distinctNonces); err != nil || distinctNonces != 2 { + t.Fatalf("distinct claim nonces = %d, err %v", distinctNonces, err) + } +} + +func TestSameSessionOrderingRecoveryNeverClaimsAnotherTask(t *testing.T) { + database := openClaimTestDatabase(t) + insertDevice(t, database, testDeviceA, []byte("device-a")) + insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(10*time.Minute), true) + insertCandidate(t, database, testTaskB, testAuthB, testNow.Add(time.Second), testNow.Add(10*time.Minute), true) + store := mustStore(t, database, bytes.Repeat([]byte{0x21}, 32), time.Minute) + store.now = func() time.Time { return testNow } + claimed, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}) + if err != nil || !found || claimed.Task.ID != testTaskA { + t.Fatalf("initial claim = %#v, found %v, err %v", claimed, found, err) + } + if _, err := database.Exec("UPDATE tasks SET status='ORDERING', version=version+1 WHERE id=?", testTaskA); err != nil { + t.Fatal(err) + } + if _, err := database.Exec("UPDATE purchase_attempts SET status='ORDERING' WHERE id=?", claimed.Attempt.ID); err != nil { + t.Fatal(err) + } + recovered, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestB}) + if err != nil || !found || recovered.Attempt.ID != claimed.Attempt.ID || recovered.Task.ID != testTaskA { + t.Fatalf("ORDERING recovery = %#v, found %v, err %v", recovered, found, err) + } + var attempts int + var taskBStatus string + if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempts").Scan(&attempts); err != nil { + t.Fatal(err) + } + if err := database.QueryRow("SELECT status FROM tasks WHERE id=?", testTaskB).Scan(&taskBStatus); err != nil { + t.Fatal(err) + } + if attempts != 1 || taskBStatus != "PENDING" { + t.Fatalf("ORDERING recovery attempts/taskB = %d/%s", attempts, taskBStatus) + } + if _, err := database.Exec("UPDATE tasks SET version=version+1 WHERE id=?", testTaskA); err != nil { + t.Fatal(err) + } + if _, _, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestC}); !errors.Is(err, ErrRequiresManual) { + t.Fatalf("ORDERING recovery with drifted version error = %v", err) + } + if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempts").Scan(&attempts); err != nil || attempts != 1 { + t.Fatalf("attempts after drifted ORDERING recovery = %d, err %v", attempts, err) + } +} + +func TestClaimRollsBackEveryBusinessMutationOnLateFailure(t *testing.T) { + database := openClaimTestDatabase(t) + insertDevice(t, database, testDeviceA, []byte("device-a")) + insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(time.Minute), true) + if _, err := database.Exec(`CREATE TRIGGER fail_claim_insert BEFORE INSERT ON purchase_attempt_claims + BEGIN SELECT RAISE(ABORT, 'injected claim failure'); END`); err != nil { + t.Fatal(err) + } + store := mustStore(t, database, bytes.Repeat([]byte{0x31}, 32), 30*time.Second) + store.now = func() time.Time { return testNow } + if _, _, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}); err == nil { + t.Fatal("ClaimNext succeeded despite injected late failure") + } + assertClaimState(t, database, 0, "PENDING", "ACTIVE") + for _, table := range []string{"purchase_attempts", "task_claim_requests"} { + var count int + if err := database.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count); err != nil || count != 0 { + t.Fatalf("%s rows after rollback = %d, err %v", table, count, err) + } + } +} + +func TestClaimEligibilityStableOrderAndConcurrentUniqueness(t *testing.T) { + database := openClaimTestDatabase(t) + insertDevice(t, database, testDeviceA, []byte("device-a")) + insertDevice(t, database, testDeviceB, []byte("device-b")) + // The oldest row has a mismatched snapshot and is ineligible; the next oldest valid row wins. + insertCandidate(t, database, testTaskA, testAuthA, testNow.Add(-2*time.Minute), testNow.Add(10*time.Minute), false) + insertCandidate(t, database, testTaskB, testAuthB, testNow.Add(-time.Minute), testNow.Add(10*time.Minute), true) + store := mustStore(t, database, bytes.Repeat([]byte{0x33}, 32), time.Minute) + store.now = func() time.Time { return testNow } + + type result struct { + response ClaimResponse + found bool + err error + } + commands := []struct{ device, session, request string }{ + {testDeviceA, testSessionA, testClaimRequestA}, + {testDeviceB, testSessionB, testClaimRequestB}, + } + results := make(chan result, 2) + var wait sync.WaitGroup + for _, command := range commands { + command := command + wait.Add(1) + go func() { + defer wait.Done() + response, found, err := store.ClaimNext(context.Background(), command.device, ClaimCommand{SessionID: command.session, ClaimRequestID: command.request}) + results <- result{response, found, err} + }() + } + wait.Wait() + close(results) + foundCount := 0 + for result := range results { + if result.err != nil { + t.Fatalf("concurrent ClaimNext error: %v", result.err) + } + if result.found { + foundCount++ + if result.response.Task.ID != testTaskB { + t.Fatalf("claimed task = %s, want stable eligible task B", result.response.Task.ID) + } + } + } + if foundCount != 1 { + t.Fatalf("successful claims = %d, want 1", foundCount) + } + var claimCount int + if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempt_claims").Scan(&claimCount); err != nil || claimCount != 1 { + t.Fatalf("claim count = %d, err %v", claimCount, err) + } + var taskStatus, authorizationStatus string + if err := database.QueryRow(`SELECT tasks.status, order_authorizations.status FROM tasks + JOIN order_authorizations ON order_authorizations.task_id=tasks.id WHERE tasks.id=?`, testTaskB). + Scan(&taskStatus, &authorizationStatus); err != nil || taskStatus != "CLAIMED" || authorizationStatus != "CLAIMED" { + t.Fatalf("claimed B states = %s/%s, err %v", taskStatus, authorizationStatus, err) + } +} + +func TestClaimConcurrencyAcrossDistinctDatabasesAndStores(t *testing.T) { + path := filepath.ToSlash(filepath.Join(t.TempDir(), "shared-claim.db")) + source := "file:" + path + "?_busy_timeout=5000&_journal_mode=WAL" + databaseA, err := sqlite.Open(source) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = databaseA.Close() }) + if err := migrations.Up(context.Background(), databaseA, claimMigrationDirectory(t)); err != nil { + t.Fatal(err) + } + databaseB, err := sqlite.Open(source) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = databaseB.Close() }) + databaseA.SetMaxOpenConns(1) + databaseB.SetMaxOpenConns(1) + insertDevice(t, databaseA, testDeviceA, []byte("device-a")) + insertDevice(t, databaseA, testDeviceB, []byte("device-b")) + insertCandidate(t, databaseA, testTaskA, testAuthA, testNow, testNow.Add(10*time.Minute), true) + secret := bytes.Repeat([]byte{0x39}, 32) + storeA := mustStore(t, databaseA, secret, time.Minute) + storeB := mustStore(t, databaseB, secret, time.Minute) + storeA.now = func() time.Time { return testNow } + storeB.now = func() time.Time { return testNow } + firstLinearized := make(chan struct{}) + releaseFirst := make(chan struct{}) + secondAtFirstWrite := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseFirst) }) } + t.Cleanup(release) + storeA.afterLinearization = func() { + close(firstLinearized) + <-releaseFirst + } + storeB.beforeLinearization = func() { + // Reaching this hook means B has begun its own transaction and its very next + // database operation is the first-write UPDATE currently held by A. + close(secondAtFirstWrite) + } + + type result struct { + found bool + err error + } + firstResult := make(chan result, 1) + secondResult := make(chan result, 1) + go func() { + _, found, err := storeA.ClaimNext(context.Background(), testDeviceA, ClaimCommand{ + SessionID: testSessionA, ClaimRequestID: testClaimRequestA, + }) + firstResult <- result{found: found, err: err} + }() + select { + case <-firstLinearized: + case result := <-firstResult: + t.Fatalf("first ClaimNext returned before holding SQLite write position: found %v, err %v", result.found, result.err) + case <-time.After(time.Second): + t.Fatal("first ClaimNext did not reach SQLite write position") + } + + go func() { + _, found, err := storeB.ClaimNext(context.Background(), testDeviceB, ClaimCommand{ + SessionID: testSessionB, ClaimRequestID: testClaimRequestB, + }) + secondResult <- result{found: found, err: err} + }() + select { + case <-secondAtFirstWrite: + // A still owns the SQLite write position here. B cannot have observed or + // changed claim state, so releasing A below creates deterministic contention. + case result := <-secondResult: + release() + <-firstResult + t.Fatalf("second ClaimNext returned before reaching the contended first write: found %v, err %v", result.found, result.err) + case <-time.After(time.Second): + release() + <-firstResult + t.Fatal("second ClaimNext did not reach the contended SQLite first write") + } + select { + case result := <-secondResult: + release() + <-firstResult + t.Fatalf("second ClaimNext completed while first transaction held SQLite write position: found %v, err %v", result.found, result.err) + default: + } + release() + first := <-firstResult + second := <-secondResult + if first.err != nil || !first.found { + t.Fatalf("first cross-database ClaimNext = found %v, err %v", first.found, first.err) + } + if second.err != nil || second.found { + t.Fatalf("second cross-database ClaimNext = found %v, err %v", second.found, second.err) + } + var attempts, claims, requestsCount, claimedRequests, emptyRequests int + queries := []struct { + query string + value *int + }{ + {"SELECT COUNT(*) FROM purchase_attempts", &attempts}, + {"SELECT COUNT(*) FROM purchase_attempt_claims", &claims}, + {"SELECT COUNT(*) FROM task_claim_requests", &requestsCount}, + {"SELECT COUNT(*) FROM task_claim_requests WHERE outcome='CLAIMED'", &claimedRequests}, + {"SELECT COUNT(*) FROM task_claim_requests WHERE outcome='EMPTY'", &emptyRequests}, + } + for _, query := range queries { + if err := databaseA.QueryRow(query.query).Scan(query.value); err != nil { + t.Fatal(err) + } + } + if attempts != 1 || claims != 1 || requestsCount != 2 || claimedRequests != 1 || emptyRequests != 1 { + t.Fatalf("cross-database attempts/claims/requests/claimed/empty = %d/%d/%d/%d/%d", + attempts, claims, requestsCount, claimedRequests, emptyRequests) + } +} + +func TestRenewCASReplayCapAndNoResurrection(t *testing.T) { + database := openClaimTestDatabase(t) + insertDevice(t, database, testDeviceA, []byte("device-a")) + insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(40*time.Second), true) + store := mustStore(t, database, bytes.Repeat([]byte{0x44}, 32), 30*time.Second) + current := testNow + store.now = func() time.Time { return current } + claim, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}) + if err != nil || !found { + t.Fatalf("ClaimNext = found %v, err %v", found, err) + } + + current = testNow.Add(20 * time.Second) + command := RenewCommand{TaskID: testTaskA, RenewRequestID: testRenewRequestA, SessionID: testSessionA, + AttemptID: claim.Attempt.ID, ClaimGeneration: claim.Attempt.ClaimGeneration, + ClaimToken: claim.Attempt.ClaimToken, ExpectedLeaseExpiresAt: claim.Attempt.LeaseExpiresAt} + renewed, err := store.Renew(context.Background(), testDeviceA, command) + if err != nil { + t.Fatalf("Renew: %v", err) + } + wantCap := formatTime(testNow.Add(40 * time.Second)) + if renewed.LeaseExpiresAt != wantCap { + t.Fatalf("renewed lease = %s, want authorization cap %s", renewed.LeaseExpiresAt, wantCap) + } + current = testNow.Add(25 * time.Second) + replay, err := store.Renew(context.Background(), testDeviceA, command) + if err != nil || !reflect.DeepEqual(replay, renewed) { + t.Fatalf("renew replay = %#v, err %v", replay, err) + } + changed := command + changed.ExpectedLeaseExpiresAt = renewed.LeaseExpiresAt + if _, err := store.Renew(context.Background(), testDeviceA, changed); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatalf("same key different payload error = %v", err) + } + stale := command + stale.RenewRequestID = "60000000-0000-4000-8000-000000000004" + if _, err := store.Renew(context.Background(), testDeviceA, stale); !errors.Is(err, ErrNotCurrent) { + t.Fatalf("out-of-order expected lease error = %v", err) + } + wrongToken := command + wrongToken.RenewRequestID = testRenewRequestB + wrongToken.ClaimToken = strings.Repeat("0", 64) + if _, err := store.Renew(context.Background(), testDeviceA, wrongToken); !errors.Is(err, ErrNotCurrent) { + t.Fatalf("wrong token error = %v", err) + } + + current = testNow.Add(40 * time.Second) // equality is expired; no grace and no resurrection. + expired := command + expired.RenewRequestID = "60000000-0000-4000-8000-000000000003" + expired.ExpectedLeaseExpiresAt = renewed.LeaseExpiresAt + if _, err := store.Renew(context.Background(), testDeviceA, expired); !errors.Is(err, ErrNotCurrent) { + t.Fatalf("expired renewal error = %v", err) + } + var lease, taskStatus, attemptStatus, authorizationStatus string + if err := database.QueryRow(`SELECT claims.lease_expires_at, tasks.status, attempts.status, authorizations.status + FROM purchase_attempt_claims claims JOIN tasks ON tasks.id=claims.task_id + JOIN purchase_attempts attempts ON attempts.id=claims.attempt_id + JOIN order_authorizations authorizations ON authorizations.id=claims.authorization_id`). + Scan(&lease, &taskStatus, &attemptStatus, &authorizationStatus); err != nil { + t.Fatal(err) + } + if lease != wantCap || taskStatus != "CLAIMED" || attemptStatus != "CLAIMED" || authorizationStatus != "CLAIMED" { + t.Fatalf("renew changed business state: lease=%s task=%s attempt=%s auth=%s", lease, taskStatus, attemptStatus, authorizationStatus) + } + if _, err := database.Exec(`UPDATE device_credentials SET status='REVOKED', revoked_at=? WHERE device_id=?`, formatTime(current), testDeviceA); err != nil { + t.Fatal(err) + } + revoked := expired + revoked.RenewRequestID = "60000000-0000-4000-8000-000000000005" + if _, err := store.Renew(context.Background(), testDeviceA, revoked); !errors.Is(err, ErrDeviceInactive) { + t.Fatalf("renew after revocation error = %v", err) + } +} + +func TestRenewRequiresPairedBusinessStateAndExactTaskVersion(t *testing.T) { + tests := []struct { + name string + mutate func(*testing.T, *sql.DB, ClaimResponse) + wantError bool + }{ + {"claimed exact version", func(*testing.T, *sql.DB, ClaimResponse) {}, false}, + {"ordering exact next version", func(t *testing.T, database *sql.DB, claim ClaimResponse) { + if _, err := database.Exec("UPDATE tasks SET status='ORDERING',version=version+1 WHERE id=?", testTaskA); err != nil { + t.Fatal(err) + } + if _, err := database.Exec("UPDATE purchase_attempts SET status='ORDERING' WHERE id=?", claim.Attempt.ID); err != nil { + t.Fatal(err) + } + }, false}, + {"claimed version drift", func(t *testing.T, database *sql.DB, _ ClaimResponse) { + if _, err := database.Exec("UPDATE tasks SET version=version+1 WHERE id=?", testTaskA); err != nil { + t.Fatal(err) + } + }, true}, + {"ordering version drift", func(t *testing.T, database *sql.DB, claim ClaimResponse) { + if _, err := database.Exec("UPDATE tasks SET status='ORDERING',version=version+2 WHERE id=?", testTaskA); err != nil { + t.Fatal(err) + } + if _, err := database.Exec("UPDATE purchase_attempts SET status='ORDERING' WHERE id=?", claim.Attempt.ID); err != nil { + t.Fatal(err) + } + }, true}, + {"task ordering attempt claimed", func(t *testing.T, database *sql.DB, _ ClaimResponse) { + if _, err := database.Exec("UPDATE tasks SET status='ORDERING',version=version+1 WHERE id=?", testTaskA); err != nil { + t.Fatal(err) + } + }, true}, + {"task claimed attempt ordering", func(t *testing.T, database *sql.DB, claim ClaimResponse) { + if _, err := database.Exec("UPDATE purchase_attempts SET status='ORDERING' WHERE id=?", claim.Attempt.ID); err != nil { + t.Fatal(err) + } + }, true}, + } + for index, test := range tests { + t.Run(test.name, func(t *testing.T) { + database, store, claim := claimedRenewFixture(t, byte(0x50+index)) + test.mutate(t, database, claim) + _, err := store.Renew(context.Background(), testDeviceA, renewCommandFor(claim, testRenewRequestA)) + if test.wantError { + if !errors.Is(err, ErrNotCurrent) { + t.Fatalf("Renew error = %v, want ErrNotCurrent", err) + } + var lease string + var renewals int + if scanErr := database.QueryRow("SELECT lease_expires_at FROM purchase_attempt_claims WHERE attempt_id=?", claim.Attempt.ID).Scan(&lease); scanErr != nil { + t.Fatal(scanErr) + } + if scanErr := database.QueryRow("SELECT COUNT(*) FROM purchase_attempt_lease_renewals").Scan(&renewals); scanErr != nil { + t.Fatal(scanErr) + } + if lease != claim.Attempt.LeaseExpiresAt || renewals != 0 { + t.Fatalf("rejected renew changed lease/rows = %s/%d", lease, renewals) + } + return + } + if err != nil { + t.Fatalf("Renew valid state: %v", err) + } + }) + } +} + +func TestConcurrentRenewCASUsesSQLiteNotOneStoreGate(t *testing.T) { + database := openClaimTestDatabase(t) + insertDevice(t, database, testDeviceA, []byte("device-a")) + insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(10*time.Minute), true) + secret := bytes.Repeat([]byte{0x48}, 32) + storeA := mustStore(t, database, secret, time.Minute) + storeA.now = func() time.Time { return testNow } + claim, found, err := storeA.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}) + if err != nil || !found { + t.Fatalf("ClaimNext = found %v, err %v", found, err) + } + storeB := mustStore(t, database, secret, time.Minute) + renewNow := testNow.Add(10 * time.Second) + storeA.now = func() time.Time { return renewNow } + storeB.now = func() time.Time { return renewNow } + base := RenewCommand{TaskID: testTaskA, SessionID: testSessionA, AttemptID: claim.Attempt.ID, + ClaimGeneration: claim.Attempt.ClaimGeneration, ClaimToken: claim.Attempt.ClaimToken, + ExpectedLeaseExpiresAt: claim.Attempt.LeaseExpiresAt} + commands := []RenewCommand{base, base} + commands[0].RenewRequestID = testRenewRequestA + commands[1].RenewRequestID = testRenewRequestB + type result struct{ err error } + results := make(chan result, 2) + var wait sync.WaitGroup + for index, claimStore := range []*Store{storeA, storeB} { + index, claimStore := index, claimStore + wait.Add(1) + go func() { + defer wait.Done() + _, err := claimStore.Renew(context.Background(), testDeviceA, commands[index]) + results <- result{err: err} + }() + } + wait.Wait() + close(results) + successes, stale := 0, 0 + for result := range results { + switch { + case result.err == nil: + successes++ + case errors.Is(result.err, ErrNotCurrent): + stale++ + default: + t.Fatalf("concurrent Renew error = %v", result.err) + } + } + var renewalCount int + if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempt_lease_renewals").Scan(&renewalCount); err != nil { + t.Fatal(err) + } + if successes != 1 || stale != 1 || renewalCount != 1 { + t.Fatalf("concurrent renew success/stale/rows = %d/%d/%d", successes, stale, renewalCount) + } +} + +func TestRenewRevocationLinearizationBothOrders(t *testing.T) { + t.Run("revocation first", func(t *testing.T) { + database, store, claim := claimedRenewFixture(t, 0x49) + if _, err := database.Exec(`UPDATE device_credentials SET status='REVOKED', revoked_at=? WHERE device_id=?`, formatTime(testNow.Add(time.Second)), testDeviceA); err != nil { + t.Fatal(err) + } + command := renewCommandFor(claim, testRenewRequestA) + if _, err := store.Renew(context.Background(), testDeviceA, command); !errors.Is(err, ErrDeviceInactive) { + t.Fatalf("Renew after revocation error = %v", err) + } + }) + + t.Run("renew write position first", func(t *testing.T) { + database, store, claim := claimedRenewFixture(t, 0x4a) + linearized := make(chan struct{}) + release := make(chan struct{}) + store.afterLinearization = func() { close(linearized); <-release } + renewResult := make(chan error, 1) + go func() { + _, err := store.Renew(context.Background(), testDeviceA, renewCommandFor(claim, testRenewRequestA)) + renewResult <- err + }() + <-linearized + revocationStarted := make(chan struct{}) + revocationResult := make(chan error, 1) + go func() { + close(revocationStarted) + _, err := database.Exec(`UPDATE device_credentials SET status='REVOKED', revoked_at=? + WHERE device_id=? AND status='ACTIVE'`, formatTime(testNow.Add(2*time.Second)), testDeviceA) + revocationResult <- err + }() + <-revocationStarted + close(release) + if err := <-renewResult; err != nil { + t.Fatalf("renew holding first write position: %v", err) + } + if err := <-revocationResult; err != nil { + t.Fatalf("revocation after renew: %v", err) + } + var renewals int + if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempt_lease_renewals").Scan(&renewals); err != nil || renewals != 1 { + t.Fatalf("renewal rows = %d, err %v", renewals, err) + } + }) +} + +func TestStartupValidatesClosedClaimStorageTypesAndStatuses(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*testing.T, *sql.DB) + }{ + {"text nonce", func(t *testing.T, database *sql.DB) { + if _, err := database.Exec(`UPDATE purchase_attempt_claims + SET claim_nonce=CAST('12345678901234567890123456789012' AS TEXT)`); err != nil { + t.Fatal(err) + } + }}, + {"invalid attempt status", func(t *testing.T, database *sql.DB) { + if _, err := database.Exec("UPDATE purchase_attempts SET status='CORRUPT'"); err != nil { + t.Fatal(err) + } + }}, + } { + t.Run(test.name, func(t *testing.T) { + database := openClaimTestDatabase(t) + database.SetMaxOpenConns(1) + insertDevice(t, database, testDeviceA, []byte("device-a")) + insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(10*time.Minute), true) + secret := bytes.Repeat([]byte{0x4b}, 32) + store := mustStore(t, database, secret, time.Minute) + store.now = func() time.Time { return testNow } + claim, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}) + if err != nil || !found { + t.Fatalf("ClaimNext = found %v, err %v", found, err) + } + if _, err := database.Exec("UPDATE purchase_attempt_claims SET closed_at=? WHERE attempt_id=?", formatTime(testNow.Add(2*time.Minute)), claim.Attempt.ID); err != nil { + t.Fatal(err) + } + if _, err := NewStore(database, secret, time.Minute); err != nil { + t.Fatalf("valid closed claim rejected: %v", err) + } + if _, err := database.Exec("PRAGMA ignore_check_constraints=ON"); err != nil { + t.Fatal(err) + } + test.mutate(t, database) + if _, err := NewStore(database, secret, time.Minute); err == nil { + t.Fatal("NewStore accepted corrupted closed claim storage") + } + }) + } +} + +func TestStartupRejectsClaimAttemptGenerationCorruption(t *testing.T) { + database := openClaimTestDatabase(t) + database.SetMaxOpenConns(1) + insertDevice(t, database, testDeviceA, []byte("device-a")) + insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(10*time.Minute), true) + secret := bytes.Repeat([]byte{0x4c}, 32) + store := mustStore(t, database, secret, time.Minute) + store.now = func() time.Time { return testNow } + claim, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}) + if err != nil || !found { + t.Fatalf("ClaimNext = found %v, err %v", found, err) + } + var nonce []byte + if err := database.QueryRow("SELECT claim_nonce FROM purchase_attempt_claims WHERE attempt_id=?", claim.Attempt.ID).Scan(&nonce); err != nil { + t.Fatal(err) + } + corruptGeneration := claim.Attempt.ClaimGeneration + 1 + corruptToken := deriveToken(secret, testDeviceA, testTaskA, testAuthA, claim.Attempt.ID, corruptGeneration, nonce) + if _, err := database.Exec("PRAGMA foreign_keys=OFF"); err != nil { + t.Fatal(err) + } + if _, err := database.Exec(`UPDATE purchase_attempt_claims SET claim_generation=?,claim_token_sha256=? WHERE attempt_id=?`, + corruptGeneration, tokenHash(corruptToken), claim.Attempt.ID); err != nil { + t.Fatalf("inject generation corruption: %v", err) + } + if _, err := NewStore(database, secret, time.Minute); err == nil { + t.Fatal("NewStore accepted claim generation different from its attempt") + } +} + +func TestRevocationLinearizesBeforeOrAfterClaim(t *testing.T) { + t.Run("revocation first", func(t *testing.T) { + database := openClaimTestDatabase(t) + insertDevice(t, database, testDeviceA, []byte("device-a")) + insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(time.Minute), true) + if _, err := database.Exec(`UPDATE device_credentials SET status='REVOKED', revoked_at=? WHERE device_id=?`, formatTime(testNow), testDeviceA); err != nil { + t.Fatal(err) + } + store := mustStore(t, database, bytes.Repeat([]byte{0x55}, 32), 30*time.Second) + if _, _, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}); !errors.Is(err, ErrDeviceInactive) { + t.Fatalf("ClaimNext error = %v, want inactive", err) + } + assertClaimState(t, database, 0, "PENDING", "ACTIVE") + }) + + t.Run("claim write position first", func(t *testing.T) { + database := openClaimTestDatabase(t) + insertDevice(t, database, testDeviceA, []byte("device-a")) + insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(time.Minute), true) + store := mustStore(t, database, bytes.Repeat([]byte{0x66}, 32), 30*time.Second) + store.now = func() time.Time { return testNow } + linearized := make(chan struct{}) + release := make(chan struct{}) + store.afterLinearization = func() { close(linearized); <-release } + claimResult := make(chan error, 1) + go func() { + _, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}) + if err == nil && !found { + err = errors.New("claim unexpectedly empty") + } + claimResult <- err + }() + <-linearized + revocationStarted := make(chan struct{}) + revocationResult := make(chan error, 1) + go func() { + close(revocationStarted) + _, err := database.Exec(`UPDATE device_credentials SET status='REVOKED', revoked_at=? WHERE device_id=? AND status='ACTIVE'`, formatTime(testNow.Add(time.Second)), testDeviceA) + revocationResult <- err + }() + <-revocationStarted + close(release) + if err := <-claimResult; err != nil { + t.Fatalf("claim holding first write position: %v", err) + } + if err := <-revocationResult; err != nil { + t.Fatalf("revocation after claim: %v", err) + } + assertClaimState(t, database, 1, "CLAIMED", "CLAIMED") + }) +} + +func TestTokenDomainSeparationAndDeviceSecretIsolation(t *testing.T) { + secret := bytes.Repeat([]byte{0x77}, 32) + nonce := bytes.Repeat([]byte{0x88}, 32) + base := deriveToken(secret, testDeviceA, testTaskA, testAuthA, "70000000-0000-4000-8000-000000000001", 1, nonce) + variants := [][]byte{ + deriveToken(secret, testDeviceB, testTaskA, testAuthA, "70000000-0000-4000-8000-000000000001", 1, nonce), + deriveToken(secret, testDeviceA, testTaskB, testAuthA, "70000000-0000-4000-8000-000000000001", 1, nonce), + deriveToken(secret, testDeviceA, testTaskA, testAuthB, "70000000-0000-4000-8000-000000000001", 1, nonce), + deriveToken(secret, testDeviceA, testTaskA, testAuthA, "70000000-0000-4000-8000-000000000002", 1, nonce), + deriveToken(secret, testDeviceA, testTaskA, testAuthA, "70000000-0000-4000-8000-000000000001", 2, nonce), + } + for index, variant := range variants { + if matchingHash(base, variant) { + t.Fatalf("token variant %d was not domain separated", index) + } + } + + database := openClaimTestDatabase(t) + insertDevice(t, database, testDeviceA, secret) + if _, err := NewStore(database, secret, time.Minute); err == nil { + t.Fatal("NewStore accepted a key equal to a device token") + } +} + +func openClaimTestDatabase(t *testing.T) *sql.DB { + t.Helper() + path := filepath.ToSlash(filepath.Join(t.TempDir(), "claim.db")) + database, err := sqlite.Open("file:" + path + "?_busy_timeout=5000&_journal_mode=WAL") + if err != nil { + t.Fatalf("open database: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := migrations.Up(context.Background(), database, claimMigrationDirectory(t)); err != nil { + t.Fatalf("migrate database: %v", err) + } + return database +} + +func claimMigrationDirectory(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate test file") + } + return filepath.Join(filepath.Dir(file), "..", "..", "migrations") +} + +func mustStore(t *testing.T, database *sql.DB, secret []byte, ttl time.Duration) *Store { + t.Helper() + store, err := NewStore(database, secret, ttl) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + return store +} + +func claimedRenewFixture(t *testing.T, secretByte byte) (*sql.DB, *Store, ClaimResponse) { + t.Helper() + database := openClaimTestDatabase(t) + insertDevice(t, database, testDeviceA, []byte("device-a")) + insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(10*time.Minute), true) + store := mustStore(t, database, bytes.Repeat([]byte{secretByte}, 32), time.Minute) + store.now = func() time.Time { return testNow } + claim, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}) + if err != nil || !found { + t.Fatalf("ClaimNext = found %v, err %v", found, err) + } + store.now = func() time.Time { return testNow.Add(10 * time.Second) } + return database, store, claim +} + +func renewCommandFor(claim ClaimResponse, requestID string) RenewCommand { + return RenewCommand{TaskID: testTaskA, RenewRequestID: requestID, SessionID: testSessionA, + AttemptID: claim.Attempt.ID, ClaimGeneration: claim.Attempt.ClaimGeneration, + ClaimToken: claim.Attempt.ClaimToken, ExpectedLeaseExpiresAt: claim.Attempt.LeaseExpiresAt} +} + +func insertDevice(t *testing.T, database *sql.DB, deviceID string, token []byte) { + t.Helper() + digest := sha256.Sum256(token) + if _, err := database.Exec(`INSERT INTO device_credentials + (device_id,display_name,token_sha256,status,created_at,revoked_at) + VALUES (?, ?, ?, 'ACTIVE', ?, NULL)`, deviceID, "test device", digest[:], formatTime(testNow.Add(-time.Hour))); err != nil { + t.Fatalf("insert device: %v", err) + } +} + +func insertCandidate(t *testing.T, database *sql.DB, taskID, authorizationID string, createdAt, expiresAt time.Time, snapshotMatches bool) { + t.Helper() + if _, err := database.Exec(`INSERT INTO tasks + (id,source,title,goods_id,sku_color,sku_size,quantity,max_total_price,status,version,created_at,updated_at) + VALUES (?, 'MANUAL', '测试商品', '937122477375', '黑色', 'M', 2, '30.00', 'PENDING', 2, ?, ?)`, + taskID, formatTime(createdAt), formatTime(createdAt)); err != nil { + t.Fatalf("insert task: %v", err) + } + color := "黑色" + if !snapshotMatches { + color = "白色" + } + if _, err := database.Exec(`INSERT INTO order_authorizations + (id,task_id,task_version,start_key,goods_id,sku_color,sku_size,quantity,total_price_cap,status,created_by,created_at,expires_at) + VALUES (?, ?, 2, ?, '937122477375', ?, 'M', 2, '30.00', 'ACTIVE', 'admin', ?, ?)`, + authorizationID, taskID, authorizationID, color, formatTime(createdAt), formatTime(expiresAt)); err != nil { + t.Fatalf("insert authorization: %v", err) + } +} + +func assertClaimState(t *testing.T, database *sql.DB, wantClaims int, wantTaskStatus, wantAuthorizationStatus string) { + t.Helper() + var count int + if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempt_claims").Scan(&count); err != nil || count != wantClaims { + t.Fatalf("claim count = %d, err %v, want %d", count, err, wantClaims) + } + var taskStatus, authorizationStatus string + if err := database.QueryRow(`SELECT tasks.status, order_authorizations.status FROM tasks + JOIN order_authorizations ON order_authorizations.task_id=tasks.id + WHERE tasks.id=?`, testTaskA).Scan(&taskStatus, &authorizationStatus); err != nil { + t.Fatal(err) + } + if taskStatus != wantTaskStatus || authorizationStatus != wantAuthorizationStatus { + t.Fatalf("states = %s/%s, want %s/%s", taskStatus, authorizationStatus, wantTaskStatus, wantAuthorizationStatus) + } +} + +func assertNoPlaintextTokenColumnOrValue(t *testing.T, database *sql.DB, token string) { + t.Helper() + rows, err := database.Query("PRAGMA table_info(purchase_attempt_claims)") + if err != nil { + t.Fatal(err) + } + defer rows.Close() + for rows.Next() { + var cid, notNull, primaryKey int + var name, kind string + var defaultValue any + if err := rows.Scan(&cid, &name, &kind, ¬Null, &defaultValue, &primaryKey); err != nil { + t.Fatal(err) + } + if name == "claim_token" { + t.Fatal("schema contains a plaintext claim_token column") + } + } + decoded, _ := hex.DecodeString(token) + var nonce, storedHash []byte + if err := database.QueryRow("SELECT claim_nonce, claim_token_sha256 FROM purchase_attempt_claims").Scan(&nonce, &storedHash); err != nil { + t.Fatal(err) + } + if bytes.Equal(nonce, decoded) || bytes.Equal(storedHash, decoded) || len(nonce) != 32 || len(storedHash) != 32 { + t.Fatal("database contains plaintext token or malformed token metadata") + } +} diff --git a/admin/internal/taskclaim/token.go b/admin/internal/taskclaim/token.go new file mode 100644 index 0000000..fd19f61 --- /dev/null +++ b/admin/internal/taskclaim/token.go @@ -0,0 +1,52 @@ +package taskclaim + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/binary" + "encoding/hex" + "hash" +) + +const tokenDomain = "cmbuyer/task-claim-token/v1\x00" + +func deriveToken(secret []byte, deviceID, taskID, authorizationID, attemptID string, generation int, nonce []byte) []byte { + mac := hmac.New(sha256.New, secret) + _, _ = mac.Write([]byte(tokenDomain)) + writeTokenField(mac, deviceID) + writeTokenField(mac, taskID) + writeTokenField(mac, authorizationID) + writeTokenField(mac, attemptID) + var number [8]byte + binary.BigEndian.PutUint64(number[:], uint64(generation)) + _, _ = mac.Write(number[:]) + writeTokenBytes(mac, nonce) + return mac.Sum(nil) +} + +func writeTokenField(writer hash.Hash, value string) { writeTokenBytes(writer, []byte(value)) } + +func writeTokenBytes(writer hash.Hash, value []byte) { + var size [4]byte + binary.BigEndian.PutUint32(size[:], uint32(len(value))) + _, _ = writer.Write(size[:]) + _, _ = writer.Write(value) +} + +func tokenHash(token []byte) []byte { + sum := sha256.Sum256(token) + return sum[:] +} + +func matchingHash(left, right []byte) bool { + return len(left) == sha256.Size && len(right) == sha256.Size && subtle.ConstantTimeCompare(left, right) == 1 +} + +func decodeToken(value string) ([]byte, bool) { + if len(value) != sha256.Size*2 { + return nil, false + } + decoded, err := hex.DecodeString(value) + return decoded, err == nil && hex.EncodeToString(decoded) == value +} diff --git a/admin/internal/taskclaim/types.go b/admin/internal/taskclaim/types.go new file mode 100644 index 0000000..0a2163e --- /dev/null +++ b/admin/internal/taskclaim/types.go @@ -0,0 +1,74 @@ +// Package taskclaim owns the atomic task-claim and lease-renewal boundary. +// A claim token proves only ownership of one attempt; it is never permission to submit an order. +package taskclaim + +import ( + "context" + "errors" +) + +var ( + ErrInvalid = errors.New("invalid task claim request") + ErrIdempotencyConflict = errors.New("task claim idempotency conflict") + ErrRequiresManual = errors.New("task claim requires manual recovery") + ErrNotCurrent = errors.New("task claim is not current") + ErrDeviceInactive = errors.New("task claim device is inactive") +) + +type ClaimCommand struct { + SessionID string `json:"session_id"` + ClaimRequestID string `json:"claim_request_id"` +} + +type RenewCommand struct { + TaskID string `json:"-"` + RenewRequestID string `json:"renew_request_id"` + SessionID string `json:"session_id"` + AttemptID string `json:"attempt_id"` + ClaimGeneration int `json:"claim_generation"` + ClaimToken string `json:"claim_token"` + ExpectedLeaseExpiresAt string `json:"expected_lease_expires_at"` +} + +type ClaimedTask struct { + ID string `json:"id"` + Version int `json:"version"` + Title string `json:"title"` + ProductURL string `json:"product_url"` + GoodsID string `json:"goods_id"` + SKUColor string `json:"sku_color"` + SKUSize string `json:"sku_size"` + Quantity int `json:"quantity"` + MaxTotalPrice string `json:"max_total_price"` +} + +type ClaimedAuthorization struct { + ID string `json:"id"` + TaskVersion int `json:"task_version"` + ExpiresAt string `json:"expires_at"` +} + +type ClaimedAttempt struct { + ID string `json:"id"` + ClaimToken string `json:"claim_token"` + ClaimGeneration int `json:"claim_generation"` + LeaseExpiresAt string `json:"lease_expires_at"` +} + +type ClaimResponse struct { + Task ClaimedTask `json:"task"` + Authorization ClaimedAuthorization `json:"authorization"` + Attempt ClaimedAttempt `json:"attempt"` +} + +type RenewResponse struct { + TaskID string `json:"task_id"` + AttemptID string `json:"attempt_id"` + ClaimGeneration int `json:"claim_generation"` + LeaseExpiresAt string `json:"lease_expires_at"` +} + +type Service interface { + ClaimNext(context.Context, string, ClaimCommand) (ClaimResponse, bool, error) + Renew(context.Context, string, RenewCommand) (RenewResponse, error) +} diff --git a/admin/internal/taskdetail/store_test.go b/admin/internal/taskdetail/store_test.go index 44688a0..75d9eff 100644 --- a/admin/internal/taskdetail/store_test.go +++ b/admin/internal/taskdetail/store_test.go @@ -14,14 +14,20 @@ import ( ) const ( - detailTask = "a3c9f507-7473-4fa6-8d71-8786c34c6301" - detailAuth = "b3c9f507-7473-4fa6-8d71-8786c34c6301" - detailTry = "c3c9f507-7473-4fa6-8d71-8786c34c6301" + detailTask = "a3c9f507-7473-4fa6-8d71-8786c34c6301" + detailAuth = "b3c9f507-7473-4fa6-8d71-8786c34c6301" + detailTry = "c3c9f507-7473-4fa6-8d71-8786c34c6301" + detailDevice = "e3c9f507-7473-4fa6-8d71-8786c34c6301" ) func TestSQLiteStoreReturnsOnlyPersistedAuditFacts(t *testing.T) { database := openDetailDatabase(t) timestamp := "2026-08-04T00:00:00Z" + if _, err := database.Exec(`INSERT INTO device_credentials + (device_id,display_name,token_sha256,status,created_at,revoked_at) + VALUES (?, 'detail test device', zeroblob(32), 'ACTIVE', ?, NULL)`, detailDevice, timestamp); err != nil { + t.Fatalf("insert device: %v", err) + } if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', 'shirt', '123', 'black', 'M', 2, '30.00', 'CLAIMED', 3, ?, ?)`, detailTask, timestamp, timestamp); err != nil { t.Fatalf("insert task: %v", err) } @@ -31,8 +37,17 @@ func TestSQLiteStoreReturnsOnlyPersistedAuditFacts(t *testing.T) { if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, detailTry, detailTask, detailAuth, timestamp); err != nil { t.Fatalf("insert attempt: %v", err) } + if _, err := database.Exec(`INSERT INTO purchase_attempt_claims + (attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation, + task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity, + total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at) + VALUES (?, ?, ?, ?, 'f3c9f507-7473-4fa6-8d71-8786c34c6301', 1, 3, 'shirt', + 2, '123', 'black', 'M', 2, '30.00', ?, zeroblob(32), zeroblob(32), + '2026-08-04T00:05:00Z', ?, NULL)`, detailTry, detailTask, detailAuth, detailDevice, timestamp, timestamp); err != nil { + t.Fatalf("insert claim: %v", err) + } hash := strings.Repeat("a", 64) - if _, err := database.Exec(`INSERT INTO evidence_assets (id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at) VALUES ('d3c9f507-7473-4fa6-8d71-8786c34c6301', 'upload', ?, ?, 'SKU_PANEL_GATE_1', 'INTERNAL_RAW', ?, 100, 'image/png', 10, 20, ?, 'device', ?, ?)`, detailTask, detailTry, hash, "aa/"+hash+".png", timestamp, timestamp); err != nil { + if _, err := database.Exec(`INSERT INTO evidence_assets (id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at) VALUES ('d3c9f507-7473-4fa6-8d71-8786c34c6301', 'upload', ?, ?, 'SKU_PANEL_GATE_1', 'INTERNAL_RAW', ?, 100, 'image/png', 10, 20, ?, ?, ?, ?)`, detailTask, detailTry, hash, "aa/"+hash+".png", detailDevice, timestamp, timestamp); err != nil { t.Fatalf("insert evidence: %v", err) } store, err := NewSQLiteStore(database) diff --git a/admin/migrations/00005_task_claims.sql b/admin/migrations/00005_task_claims.sql new file mode 100644 index 0000000..c53a438 --- /dev/null +++ b/admin/migrations/00005_task_claims.sql @@ -0,0 +1,272 @@ +-- +goose Up +-- v4 中的 attempt、submission 或证据没有设备/session/租约归属,不能安全猜测成 claim。 +-- 在同一迁移事务中拒绝这类数据库,避免补出虚假的所有权审计链。 +CREATE TABLE task_claim_upgrade_guard ( + valid INTEGER NOT NULL CHECK (valid = 1) +); + +INSERT INTO task_claim_upgrade_guard (valid) +SELECT CASE WHEN + (SELECT COUNT(*) FROM purchase_attempts) = 0 + AND (SELECT COUNT(*) FROM order_submissions) = 0 + AND (SELECT COUNT(*) FROM evidence_assets) = 0 +THEN 1 ELSE 0 END; + +DROP TABLE task_claim_upgrade_guard; + +-- 该唯一索引把“一条授权只能产生一个 attempt”下沉到数据库;应用层检查不能替代它。 +CREATE UNIQUE INDEX purchase_attempts_one_per_authorization_idx +ON purchase_attempts (authorization_id); + +-- claim_generation 是 attempt lineage 的组成部分,不能只在应用层比较。 +CREATE UNIQUE INDEX purchase_attempts_claim_lineage_idx +ON purchase_attempts (task_id, authorization_id, id, claim_generation); + +CREATE TABLE purchase_attempt_claims ( + attempt_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + authorization_id TEXT NOT NULL UNIQUE, + claimed_by_device_id TEXT NOT NULL, + session_id TEXT NOT NULL CHECK ( + length(session_id) = 36 + AND substr(session_id, 9, 1) = '-' + AND substr(session_id, 14, 1) = '-' + AND substr(session_id, 19, 1) = '-' + AND substr(session_id, 24, 1) = '-' + AND length(replace(session_id, '-', '')) = 32 + AND replace(session_id, '-', '') NOT GLOB '*[^0-9a-f]*' + AND substr(session_id, 15, 1) = '4' + AND substr(session_id, 20, 1) IN ('8', '9', 'a', 'b') + ), + claim_generation INTEGER NOT NULL CHECK ( + typeof(claim_generation) = 'integer' AND claim_generation > 0 + ), + task_version INTEGER NOT NULL CHECK ( + typeof(task_version) = 'integer' AND task_version > 0 + ), + task_title TEXT NOT NULL CHECK (trim(task_title) <> ''), + authorization_task_version INTEGER NOT NULL CHECK ( + typeof(authorization_task_version) = 'integer' AND authorization_task_version > 0 + ), + goods_id TEXT NOT NULL CHECK (trim(goods_id) <> ''), + sku_color TEXT NOT NULL CHECK (trim(sku_color) <> ''), + sku_size TEXT NOT NULL CHECK (trim(sku_size) <> ''), + quantity INTEGER NOT NULL CHECK (typeof(quantity) = 'integer' AND quantity > 0), + total_price_cap TEXT NOT NULL CHECK (trim(total_price_cap) <> ''), + authorization_expires_at TEXT NOT NULL CHECK ( + authorization_expires_at = trim(authorization_expires_at) + AND length(authorization_expires_at) >= 20 + AND substr(authorization_expires_at, 11, 1) = 'T' + AND substr(authorization_expires_at, -1, 1) = 'Z' + AND julianday(authorization_expires_at) IS NOT NULL + ), + claim_nonce BLOB NOT NULL CHECK ( + typeof(claim_nonce) = 'blob' AND length(claim_nonce) = 32 + ), + claim_token_sha256 BLOB NOT NULL CHECK ( + typeof(claim_token_sha256) = 'blob' AND length(claim_token_sha256) = 32 + ), + lease_expires_at TEXT NOT NULL CHECK ( + lease_expires_at = trim(lease_expires_at) + AND length(lease_expires_at) >= 20 + AND substr(lease_expires_at, 11, 1) = 'T' + AND substr(lease_expires_at, -1, 1) = 'Z' + AND julianday(lease_expires_at) IS NOT NULL + ), + claimed_at TEXT NOT NULL CHECK ( + claimed_at = trim(claimed_at) + AND length(claimed_at) >= 20 + AND substr(claimed_at, 11, 1) = 'T' + AND substr(claimed_at, -1, 1) = 'Z' + AND julianday(claimed_at) IS NOT NULL + ), + closed_at TEXT CHECK ( + closed_at IS NULL OR ( + closed_at = trim(closed_at) + AND length(closed_at) >= 20 + AND substr(closed_at, 11, 1) = 'T' + AND substr(closed_at, -1, 1) = 'Z' + AND julianday(closed_at) IS NOT NULL + AND julianday(closed_at) >= julianday(claimed_at) + ) + ), + UNIQUE (task_id, attempt_id), + UNIQUE (attempt_id, claimed_by_device_id, session_id), + UNIQUE ( + task_id, attempt_id, claimed_by_device_id, session_id, + claim_generation, claim_token_sha256 + ), + UNIQUE (task_id, authorization_id, attempt_id), + FOREIGN KEY (task_id, authorization_id, attempt_id, claim_generation) + REFERENCES purchase_attempts(task_id, authorization_id, id, claim_generation), + FOREIGN KEY (claimed_by_device_id) REFERENCES device_credentials(device_id) +); + +-- 过期、撤销或停轮询都不会自动关闭 claim;partial unique 因而阻止另一条开放归属。 +CREATE UNIQUE INDEX purchase_attempt_claims_one_open_per_device_idx +ON purchase_attempt_claims (claimed_by_device_id) +WHERE closed_at IS NULL; + +CREATE TABLE task_claim_requests ( + claim_request_id TEXT PRIMARY KEY CHECK ( + length(claim_request_id) = 36 + AND substr(claim_request_id, 9, 1) = '-' + AND substr(claim_request_id, 14, 1) = '-' + AND substr(claim_request_id, 19, 1) = '-' + AND substr(claim_request_id, 24, 1) = '-' + AND length(replace(claim_request_id, '-', '')) = 32 + AND replace(claim_request_id, '-', '') NOT GLOB '*[^0-9a-f]*' + AND substr(claim_request_id, 15, 1) = '4' + AND substr(claim_request_id, 20, 1) IN ('8', '9', 'a', 'b') + ), + device_id TEXT NOT NULL, + session_id TEXT NOT NULL CHECK ( + length(session_id) = 36 + AND substr(session_id, 9, 1) = '-' + AND substr(session_id, 14, 1) = '-' + AND substr(session_id, 19, 1) = '-' + AND substr(session_id, 24, 1) = '-' + AND length(replace(session_id, '-', '')) = 32 + AND replace(session_id, '-', '') NOT GLOB '*[^0-9a-f]*' + AND substr(session_id, 15, 1) = '4' + AND substr(session_id, 20, 1) IN ('8', '9', 'a', 'b') + ), + outcome TEXT NOT NULL CHECK (outcome IN ('CLAIMED', 'EMPTY', 'BLOCKED')), + attempt_id TEXT, + response_lease_expires_at TEXT CHECK ( + response_lease_expires_at IS NULL OR ( + response_lease_expires_at = trim(response_lease_expires_at) + AND length(response_lease_expires_at) >= 20 + AND substr(response_lease_expires_at, 11, 1) = 'T' + AND substr(response_lease_expires_at, -1, 1) = 'Z' + AND julianday(response_lease_expires_at) IS NOT NULL + ) + ), + error_code TEXT CHECK (error_code IS NULL OR error_code = 'manual_recovery_required'), + created_at TEXT NOT NULL CHECK ( + created_at = trim(created_at) + AND length(created_at) >= 20 + AND substr(created_at, 11, 1) = 'T' + AND substr(created_at, -1, 1) = 'Z' + AND julianday(created_at) IS NOT NULL + ), + CHECK ( + (outcome = 'CLAIMED' AND attempt_id IS NOT NULL AND response_lease_expires_at IS NOT NULL AND error_code IS NULL) + OR (outcome = 'EMPTY' AND attempt_id IS NULL AND response_lease_expires_at IS NULL AND error_code IS NULL) + OR (outcome = 'BLOCKED' AND attempt_id IS NULL AND response_lease_expires_at IS NULL AND error_code = 'manual_recovery_required') + ), + FOREIGN KEY (device_id) REFERENCES device_credentials(device_id), + -- EMPTY/BLOCKED 行的 attempt_id 为 NULL,SQLite 会跳过复合 FK;CLAIMED 行则必须 + -- 同时匹配原 claim 的设备和 session,不能由应用 bug 写成跨设备重放。 + FOREIGN KEY (attempt_id, device_id, session_id) + REFERENCES purchase_attempt_claims(attempt_id, claimed_by_device_id, session_id) +); + +CREATE TABLE purchase_attempt_lease_renewals ( + renew_request_id TEXT PRIMARY KEY CHECK ( + length(renew_request_id) = 36 + AND substr(renew_request_id, 9, 1) = '-' + AND substr(renew_request_id, 14, 1) = '-' + AND substr(renew_request_id, 19, 1) = '-' + AND substr(renew_request_id, 24, 1) = '-' + AND length(replace(renew_request_id, '-', '')) = 32 + AND replace(renew_request_id, '-', '') NOT GLOB '*[^0-9a-f]*' + AND substr(renew_request_id, 15, 1) = '4' + AND substr(renew_request_id, 20, 1) IN ('8', '9', 'a', 'b') + ), + task_id TEXT NOT NULL, + attempt_id TEXT NOT NULL, + device_id TEXT NOT NULL, + session_id TEXT NOT NULL CHECK ( + length(session_id) = 36 + AND substr(session_id, 9, 1) = '-' + AND substr(session_id, 14, 1) = '-' + AND substr(session_id, 19, 1) = '-' + AND substr(session_id, 24, 1) = '-' + AND length(replace(session_id, '-', '')) = 32 + AND replace(session_id, '-', '') NOT GLOB '*[^0-9a-f]*' + AND substr(session_id, 15, 1) = '4' + AND substr(session_id, 20, 1) IN ('8', '9', 'a', 'b') + ), + claim_generation INTEGER NOT NULL CHECK ( + typeof(claim_generation) = 'integer' AND claim_generation > 0 + ), + claim_token_sha256 BLOB NOT NULL CHECK ( + typeof(claim_token_sha256) = 'blob' AND length(claim_token_sha256) = 32 + ), + expected_lease_expires_at TEXT NOT NULL CHECK ( + expected_lease_expires_at = trim(expected_lease_expires_at) + AND length(expected_lease_expires_at) >= 20 + AND substr(expected_lease_expires_at, 11, 1) = 'T' + AND substr(expected_lease_expires_at, -1, 1) = 'Z' + AND julianday(expected_lease_expires_at) IS NOT NULL + ), + lease_expires_at TEXT NOT NULL CHECK ( + lease_expires_at = trim(lease_expires_at) + AND length(lease_expires_at) >= 20 + AND substr(lease_expires_at, 11, 1) = 'T' + AND substr(lease_expires_at, -1, 1) = 'Z' + AND julianday(lease_expires_at) IS NOT NULL + ), + created_at TEXT NOT NULL CHECK ( + created_at = trim(created_at) + AND length(created_at) >= 20 + AND substr(created_at, 11, 1) = 'T' + AND substr(created_at, -1, 1) = 'Z' + AND julianday(created_at) IS NOT NULL + ), + FOREIGN KEY ( + task_id, attempt_id, device_id, session_id, + claim_generation, claim_token_sha256 + ) REFERENCES purchase_attempt_claims( + task_id, attempt_id, claimed_by_device_id, session_id, + claim_generation, claim_token_sha256 + ) +); + +CREATE INDEX order_authorizations_claim_candidate_idx +ON order_authorizations (status, created_at, id); + +-- 首次证据写入必须属于认证设备当前未关闭的 claim。历史资产的幂等重放不触发 INSERT, +-- 因而未来人工关闭 claim 后仍可稳定返回原资产。 +-- +goose StatementBegin +CREATE TRIGGER evidence_assets_claim_owner_insert +BEFORE INSERT ON evidence_assets +FOR EACH ROW +WHEN NOT EXISTS ( + SELECT 1 FROM purchase_attempt_claims AS claims + WHERE claims.task_id = NEW.task_id + AND claims.attempt_id = NEW.attempt_id + AND claims.claimed_by_device_id = NEW.uploaded_by_device_id + AND claims.closed_at IS NULL +) +BEGIN + SELECT RAISE(ABORT, 'evidence claim ownership required'); +END; +-- +goose StatementEnd + +-- +goose Down +-- 请求、续租、attempt、submission 和证据都是领取或下游审计事实,回滚不得静默删除。 +CREATE TABLE task_claim_downgrade_guard ( + valid INTEGER NOT NULL CHECK (valid = 1) +); + +INSERT INTO task_claim_downgrade_guard (valid) +SELECT CASE WHEN + (SELECT COUNT(*) FROM task_claim_requests) = 0 + AND (SELECT COUNT(*) FROM purchase_attempt_lease_renewals) = 0 + AND (SELECT COUNT(*) FROM purchase_attempt_claims) = 0 + AND (SELECT COUNT(*) FROM purchase_attempts) = 0 + AND (SELECT COUNT(*) FROM order_submissions) = 0 + AND (SELECT COUNT(*) FROM evidence_assets) = 0 +THEN 1 ELSE 0 END; + +DROP TABLE task_claim_downgrade_guard; +DROP TRIGGER evidence_assets_claim_owner_insert; +DROP INDEX order_authorizations_claim_candidate_idx; +DROP TABLE purchase_attempt_lease_renewals; +DROP TABLE task_claim_requests; +DROP INDEX purchase_attempt_claims_one_open_per_device_idx; +DROP TABLE purchase_attempt_claims; +DROP INDEX purchase_attempts_claim_lineage_idx; +DROP INDEX purchase_attempts_one_per_authorization_idx; diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 8225c3c..792c55b 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -229,9 +229,42 @@ CREATE TABLE purchase_attempts ( failure_code TEXT, started_at TEXT NOT NULL, finished_at TEXT, - UNIQUE (task_id, claim_generation) + UNIQUE (task_id, claim_generation), + UNIQUE (task_id, authorization_id, id, claim_generation) ); +-- attempt 的设备/session 所有权与可恢复租约;token 明文永不入库 +CREATE TABLE purchase_attempt_claims ( + attempt_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + authorization_id TEXT NOT NULL UNIQUE, + claimed_by_device_id TEXT NOT NULL REFERENCES device_credentials(device_id), + session_id TEXT NOT NULL, + claim_generation INTEGER NOT NULL, + task_version INTEGER NOT NULL, + task_title TEXT NOT NULL, + authorization_task_version INTEGER NOT NULL, + goods_id TEXT NOT NULL, + sku_color TEXT NOT NULL, + sku_size TEXT NOT NULL, + quantity INTEGER NOT NULL, + total_price_cap TEXT NOT NULL, + authorization_expires_at TEXT NOT NULL, + claim_nonce BLOB NOT NULL, -- 32 字节随机 nonce + claim_token_sha256 BLOB NOT NULL, -- 32 字节 hash,不是 token 明文 + lease_expires_at TEXT NOT NULL, + claimed_at TEXT NOT NULL, + closed_at TEXT, + FOREIGN KEY (task_id, authorization_id, attempt_id, claim_generation) + REFERENCES purchase_attempts(task_id, authorization_id, id, claim_generation) +); + +CREATE UNIQUE INDEX purchase_attempt_claims_one_open_per_device_idx +ON purchase_attempt_claims (claimed_by_device_id) WHERE closed_at IS NULL; + +-- claim-next 的 CLAIMED / EMPTY / BLOCKED 与 renew CAS 都持久化,保证跨重启幂等。 +-- 复合外键同时绑定 attempt、设备、session、generation 与 token hash,应用 bug 不能跨归属写事实。 + -- 真机真实点击前建立;一份授权最多一条 CREATE TABLE order_submissions ( id TEXT PRIMARY KEY, @@ -327,6 +360,20 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED ### 5.3 授权、租约与恢复 +- claim/renew 使用独立 32 字节 HMAC secret;配置为 64 位小写十六进制,不得等于 session secret 或 + 任一设备 token。每条 claim 以版本域分隔 HMAC 绑定 device/task/authorization/attempt/generation/ + 32 字节随机 nonce,SQLite 只保存 nonce 和 token SHA-256。启动会重建并恒定时核对所有 open/closed + claim;secret 错误时失败闭合,不轮换 token。 +- claim 与 renew 的 SQLite 事务必须先通过条件 no-op UPDATE 取得写入线性化位置并复核设备 ACTIVE, + 才能读取或重放 request。撤销先提交则请求失败;claim/renew 先取得写位置则该事务可完成,随后撤销 + 不会自动释放已建立的 claim。 +- 同一授权最多一个 attempt,同一设备最多一个未关闭 claim。EMPTY、人工恢复冲突和续租响应都持久化; + 同幂等键只能重放原结果,不能因候选变化、续租或服务重启递增 generation、轮换 token 或延长第二次。 +- claim 行同时冻结成功响应所需的 task 标题/版本以及 authorization 版本、规格、数量、总价上限和到期 + 时间。旧 request 跨重启只从该快照重建;源 task/authorization 后续漂移不能改变历史响应,并会让新 + 恢复或续租失败闭合。进入 `ORDERING` 的同 attempt 仅接受 task version 恰好比 claim 快照加一。 +- 租约 TTL 显式配置为正且严格短于授权 TTL;新到期时间不得超过授权到期。租约与授权边界相等即过期, + 没有宽限;过期、撤销、停止轮询和进程退出均不关闭 claim、不释放授权、不允许另一设备接管。 - 授权带 `expires_at`,只有围栏前可转 `EXPIRED` / `ABANDONED`;任务回到 `DRAFT`,必须重新点击 开始采购。旧授权永不复活。 - 设备租约丢失不等于授权可安全重用。只有服务端确认该 attempt 未建立围栏,才能关闭 attempt 并 @@ -353,6 +400,9 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED PNG 魔数、完整解码、字节数、尺寸和调用方声明的 SHA-256。 - 上传 handler 必须先通过逐请求 SQLite 设备认证并再次校验规范 principal,再解析 Content-Type 或 读取 body。空库、无效或已撤销凭据拒绝,认证存储故障返回 503;不把管理员 session 当设备身份。 +- 首次 evidence INSERT 必须由 store 校验和 SQLite trigger 双重证明 `(task, attempt, authenticated device)` + 对应未关闭 claim。历史同设备/upload key 重放先于该检查,因此人工关闭 claim 不会破坏已提交资产的 + 幂等读取;关闭后禁止新 upload key,且不为此增加 token/session 字段或放宽截图 kind。 - 文件写入显式配置的私有证据根目录:同目录随机临时文件 → 流式 hash → 校验 → `fsync` → 原子 rename 到 SHA-256 内容地址 → 最后事务写数据库。数据库永远不指向半文件或缺失文件。 - SQLite 与文件系统不能组成跨资源事务;极端故障最多留下不可达孤儿文件。不得为清理孤儿而删除 diff --git a/docs/api.md b/docs/api.md index e990c4f..c2bb03b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -48,7 +48,7 @@ "code": "version_conflict", "message": "任务已变化,请刷新后重选", "retryable": false, - "request_id": "018f..." + "request_id": "c3c9f507-7473-4fa6-8d71-8786c34c6301" } } ``` @@ -85,7 +85,7 @@ ```json { - "create_key": "018f...", + "create_key": "d3c9f507-7473-4fa6-8d71-8786c34c6301", "title": "纯棉短袖", "product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375", "sku_color": "黑色CHA(纯棉)", @@ -103,10 +103,10 @@ ```json { - "start_key": "018f...", + "start_key": "63c9f507-7473-4fa6-8d71-8786c34c6301", "tasks": [ - {"task_id": "018f-task-1", "expected_task_version": 1}, - {"task_id": "018f-task-2", "expected_task_version": 1} + {"task_id": "83c9f507-7473-4fa6-8d71-8786c34c6301", "expected_task_version": 1}, + {"task_id": "93c9f507-7473-4fa6-8d71-8786c34c6301", "expected_task_version": 1} ] } ``` @@ -125,11 +125,11 @@ ```json { - "start_key": "018f...", + "start_key": "63c9f507-7473-4fa6-8d71-8786c34c6301", "authorized_count": 2, "tasks": [ - {"task_id": "018f-task-1", "task_version": 2, "authorization_id": "018f-auth-1"}, - {"task_id": "018f-task-2", "task_version": 2, "authorization_id": "018f-auth-2"} + {"task_id": "83c9f507-7473-4fa6-8d71-8786c34c6301", "task_version": 2, "authorization_id": "a3c9f507-7473-4fa6-8d71-8786c34c6301"}, + {"task_id": "93c9f507-7473-4fa6-8d71-8786c34c6301", "task_version": 2, "authorization_id": "b3c9f507-7473-4fa6-8d71-8786c34c6301"} ], "payment_automated": false } @@ -160,7 +160,7 @@ ```json { - "device_id": "desk-01", + "device_id": "e3c9f507-7473-4fa6-8d71-8786c34c6301", "client_version": "0.1.0", "adb_serial": "192.168.0.173:5555", "android_release": "16", @@ -173,12 +173,22 @@ ### `POST /api/v1/tasks/claim-next` -请求携带 `device_id`、`session_id`、`claim_request_id`。领取与授权绑定且具租约: +设备 id 只来自已经认证的 `X-CMBuyer-Device-ID`,不得放进 JSON。请求体上限 4096 字节,只接受 +以下两个字段;二者都必须是规范小写 UUIDv4,未知字段、重复字段和额外 JSON 均拒绝: + +```json +{ + "session_id": "23c9f507-7473-4fa6-8d71-8786c34c6301", + "claim_request_id": "33c9f507-7473-4fa6-8d71-8786c34c6301" +} +``` + +成功领取或同一会话恢复返回 `200`: ```json { "task": { - "id": "018f-task", + "id": "13c9f507-7473-4fa6-8d71-8786c34c6301", "version": 3, "title": "纯棉短袖", "product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375", @@ -189,24 +199,71 @@ "max_total_price": "30.00" }, "authorization": { - "id": "018f-auth", + "id": "73c9f507-7473-4fa6-8d71-8786c34c6301", "task_version": 2, "expires_at": "2026-08-04T10:00:00Z" }, "attempt": { - "id": "018f-attempt", - "claim_token": "opaque-single-claim-token", + "id": "53c9f507-7473-4fa6-8d71-8786c34c6301", + "claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "claim_generation": 1, "lease_expires_at": "2026-08-04T09:05:00Z" } } ``` -- 只返回有 `ACTIVE` 授权的 `PENDING`;服务端在一个事务中转为 `CLAIMED` 并创建 attempt。 -- 同一 `claim_request_id` 同载荷重放同一结果;并发设备只有一个成功。 -- 一个设备有未结束领取时优先重放该领取,不能悄悄领第二条。 +- 设备认证发生在 Content-Type 解析和 body 读取前。事务的第一条数据库业务语句取得 SQLite 写入 + 位置并再次条件确认设备仍为 `ACTIVE`;之后才允许查幂等记录、候选或返回 EMPTY/冲突。 +- 只返回 `PENDING + ACTIVE + 严格未过期` 且 task/version/规格/数量/总价快照完全一致的最早授权; + 服务端在同一事务中创建唯一 attempt/claim/request,并转为 `CLAIMED`。 +- 同一 `claim_request_id` 同设备、同 session 稳定重放原结果;同键异载荷返回 + `409 {"error":"idempotency_conflict"}`。没有候选返回空 `204`,且 EMPTY 也持久化稳定重放。 +- claim 持久化完整成功响应快照;领取后的 task/authorization 源行变化不得让旧 request 的标题、规格、 + 数量、金额、版本或到期时间漂移。源快照不一致时,新恢复/续租失败闭合。 +- 一个设备最多有一个未关闭 claim。同 session 且租约有效时重放原 attempt;同一 attempt 已按服务端 + 首事件原子进入 `ORDERING` 时也只在 task version 恰好为 claim 版本 +1 时恢复。不同 session、租约 + 过期或业务状态异常固定返回 `409 {"error":"claim_requires_manual"}`,不释放、不转领、不新建 attempt。 +- `claim_token` 是 32 字节 HMAC 的 64 位小写十六进制表示,只证明一个 attempt 的归属,不是提交许可。 + SQLite 仅保存随机 nonce 与 token SHA-256;同一 secret 重启后重建相同 token,错误 secret 拒绝启动。 - 响应不得包含自由动作脚本、CSS/XPath、通用坐标或支付能力。 +### `POST /api/v1/tasks/{id}/lease/renew` + +请求体同样限 4096 字节并执行严格 JSON 校验: + +```json +{ + "renew_request_id": "43c9f507-7473-4fa6-8d71-8786c34c6301", + "session_id": "23c9f507-7473-4fa6-8d71-8786c34c6301", + "attempt_id": "53c9f507-7473-4fa6-8d71-8786c34c6301", + "claim_generation": 1, + "claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "expected_lease_expires_at": "2026-08-04T09:05:00Z" +} +``` + +成功返回 `200`;响应不回显 token: + +```json +{ + "task_id": "13c9f507-7473-4fa6-8d71-8786c34c6301", + "attempt_id": "53c9f507-7473-4fa6-8d71-8786c34c6301", + "claim_generation": 1, + "lease_expires_at": "2026-08-04T09:06:00Z" +} +``` + +- 原设备/session/attempt/generation/token 必须同时匹配,当前租约与授权都必须严格晚于服务端 UTC + 当前时间,`expected_lease_expires_at` 必须逐字等于数据库当前值;边界相等即过期且不能复活。 +- 新到期时间是 `min(server_now + CMBUYER_CLAIM_LEASE_TTL, authorization.expires_at)`。续租不改变 + token、generation、任务版本或业务状态。 +- 成功续租先持久化 request 与响应;同一 `renew_request_id` 同载荷只重放旧响应,不再次 CAS 或延长。 + 同键异载荷返回 `idempotency_conflict`;非当前 claim 固定返回 `claim_not_current`,两者均为 `409`。 + +claim/renew 的格式错误固定为 `400 {"error":"invalid_request"}`,超限为 +`413 {"error":"request_too_large"}`,Content-Type 错误为 +`415 {"error":"unsupported_media_type"}`;设备认证/事务内撤销为无诊断 `401`,存储故障为无诊断 `503`。 + ### 事件与证据 事件只包含固定 `step` / `outcome` / `reason_code` 和非敏感摘要。禁止把完整 XML、地址、手机号、 @@ -234,8 +291,10 @@ - 恰好一个带 `Content-Type: image/png` 的显式文件;除上述六个元数据字段外,未知或重复字段均拒绝。 - 单文件最多 10 MiB、单边最多 8192 px、总像素最多 16,777,216;服务端校验 PNG 魔数、完整解码、 字节数、尺寸与调用方声明的 64 位小写 SHA-256。 -- `attempt_id` 必须由数据库复合外键证明属于 URL 中的 task。认证必须先于 Content-Type 解析和请求体读取。 -- 同一设备主体和 `upload_key` 的同载荷重放返回原资产;任务、attempt、截图或元数据变化返回 `409`。 +- `attempt_id` 必须由数据库复合外键证明属于 URL 中的 task,且首次写入必须存在由认证设备持有的 + 未关闭 claim;设备 A 不能向设备 B 的 attempt 上传。认证必须先于 Content-Type 解析和请求体读取。 +- 同一设备主体和 `upload_key` 的同载荷重放返回原资产;即使 claim 后续由人工关闭,已成功资产仍先 + 重放历史结果。关闭后不得用新 upload key 写新证据;任务、attempt、截图或元数据变化返回 `409`。 - 首次成功返回 `201`,幂等重放返回 `200`。响应只含资产 id、关联 id、kind/tier、hash、字节数、 MIME、宽高和采集时间,不含设备 token、原文件名或存储路径。 - 生产上传使用逐请求 SQLite 设备认证;空凭据库、未知或已撤销设备均拒绝。不得使用管理员 session、 @@ -247,11 +306,11 @@ ```json { - "fence_key": "018f-fence-request", - "task_id": "018f-task", + "fence_key": "e3c9f507-7473-4fa6-8d71-8786c34c6301", + "task_id": "13c9f507-7473-4fa6-8d71-8786c34c6301", "expected_task_version": 3, - "authorization_id": "018f-auth", - "claim_token": "opaque-single-claim-token", + "authorization_id": "73c9f507-7473-4fa6-8d71-8786c34c6301", + "claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "selected_color": "黑色CHA(纯棉)", "selected_size": "M(建议100-115)", "gate1_unit_price": "12.88", @@ -271,7 +330,7 @@ ```json { - "submission_id": "018f-submission", + "submission_id": "f3c9f507-7473-4fa6-8d71-8786c34c6301", "status": "FENCED", "click_permitted": true, "submit_text": "提交订单" @@ -288,10 +347,10 @@ ```json { - "result_key": "018f-result", - "attempt_id": "018f-attempt", + "result_key": "03c9f507-7473-4fa6-8d71-8786c34c6301", + "attempt_id": "53c9f507-7473-4fa6-8d71-8786c34c6301", "observation": "SUBMITTED", - "evidence_asset_id": "018f-asset" + "evidence_asset_id": "63c9f507-7473-4fa6-8d71-8786c34c6301" } ```