From 66355a7f8971938d73a386d2aeb5d24eb043df9f Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 4 Aug 2026 20:57:33 +0800 Subject: [PATCH] feat(admin): add device credential isolation --- admin/README.md | 28 +- admin/cmd/device-credentials/main.go | 200 +++++++++ admin/cmd/device-credentials/main_test.go | 293 +++++++++++++ admin/cmd/server/main.go | 12 +- admin/cmd/server/main_test.go | 9 + admin/internal/deviceauth/credentials.go | 212 +++++++++ admin/internal/deviceauth/deviceauth.go | 168 ++++++++ admin/internal/deviceauth/deviceauth_test.go | 407 ++++++++++++++++++ admin/internal/evidence/evidence.go | 22 +- admin/internal/migrations/migrations_test.go | 88 +++- admin/internal/server/evidence.go | 16 +- admin/internal/server/evidence_test.go | 193 ++++++++- admin/internal/server/router.go | 7 +- admin/internal/server/router_test.go | 13 +- admin/internal/server/task_detail_test.go | 8 +- admin/internal/storage/evidence/store.go | 18 +- admin/internal/storage/evidence/store_test.go | 20 +- admin/migrations/00004_device_credentials.sql | 62 +++ docs/04-architecture.md | 28 +- docs/api.md | 28 +- 20 files changed, 1745 insertions(+), 87 deletions(-) create mode 100644 admin/cmd/device-credentials/main.go create mode 100644 admin/cmd/device-credentials/main_test.go create mode 100644 admin/cmd/server/main_test.go create mode 100644 admin/internal/deviceauth/credentials.go create mode 100644 admin/internal/deviceauth/deviceauth.go create mode 100644 admin/internal/deviceauth/deviceauth_test.go create mode 100644 admin/migrations/00004_device_credentials.sql diff --git a/admin/README.md b/admin/README.md index c4a3a25..bbc8423 100644 --- a/admin/README.md +++ b/admin/README.md @@ -30,11 +30,35 @@ go run ./cmd/migrate -database $env:CMBUYER_DATABASE_SOURCE up go run ./cmd/server ``` +MVP 服务固定绑定 IPv4 回环 `127.0.0.1:8080`,只供同一运营电脑上的采购服务和采购工具使用。不要把监听地址 +改成 `0.0.0.0` 或局域网地址;未来若需要非回环访问,必须先单独建立并验收 HTTPS/TLS 终止与代理 +信任边界,设备 Bearer 不得经过明文局域网。 + +数据库迁移完成后,用同一个显式 SQLite data source 管理设备凭据: + +```powershell +# 签发:token 只在本次成功输出中显示一次,请立即放入采购工具的受控本机配置。 +go run ./cmd/device-credentials -database $env:CMBUYER_DATABASE_SOURCE issue -name '<非秘密设备名称>' + +# 仅显示设备 id、名称、状态和时间,不显示 token/hash。 +go run ./cmd/device-credentials -database $env:CMBUYER_DATABASE_SOURCE list + +# 撤销立即影响之后开始的每次设备请求;重复执行保持 REVOKED,不恢复旧 token。 +go run ./cmd/device-credentials -database $env:CMBUYER_DATABASE_SOURCE revoke -device-id '<签发时的设备UUID>' +``` + +该 CLI 只接受已存在、已迁移的文件型 SQLite 普通路径或 `file:` URI,并强制以 `mode=rw` 打开; +路径拼错或文件缺失时 SQLite 原子拒绝且不会留下空数据库,也不接受内存、只读或可创建模式。CLI +不自动迁移。签发 token 是 32 字节加密随机值的 64 位小写十六进制表示; +SQLite 只保存原始 token 的 32 字节 SHA-256 BLOB。签发输出以外的 list/revoke、日志、错误和 HTTP +响应都不会显示 token 或 hash。 + 采购服务会话仅保存在当前进程内;进程重启后既有登录会话会安全失效。 管理员的“开始采购(只创建待付款订单)”只签发一次性授权并创建待付款订单的资格;服务不会自动付款,也不包含任何支付操作。 `GET /tasks/{id}` 直接访问时渲染完整详情页,任务列表以同一 URL 加载详情抽屉。内部截图只通过 `GET /evidence/{asset_id}` 向有效管理员会话提供,并始终返回 `no-store`;文件不在静态目录中。 -T-301 接入真实设备凭据之前,`POST /api/v1/tasks/{id}/evidence` 的生产认证器固定拒绝全部请求。 -测试可以注入 fake 设备主体验证上传契约,但不得用管理员会话、临时 token 或共享密钥绕过该边界。 +`POST /api/v1/tasks/{id}/evidence` 使用 `Authorization: Bearer ` 和 +`X-CMBuyer-Device-ID: <小写UUIDv4>` 逐请求查库认证;管理员会话不能代替设备身份。凭据错误统一空 +401,SQLite 认证故障为空 503,且两者都发生在上传 body 被读取之前。 diff --git a/admin/cmd/device-credentials/main.go b/admin/cmd/device-credentials/main.go new file mode 100644 index 0000000..940692d --- /dev/null +++ b/admin/cmd/device-credentials/main.go @@ -0,0 +1,200 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "log" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "cmbuyer/admin/internal/deviceauth" + "cmbuyer/admin/internal/storage/sqlite" +) + +func main() { + if err := run(context.Background(), os.Args[1:], os.Stdout, os.Stderr); err != nil { + log.Print(err) + os.Exit(1) + } +} + +func run(ctx context.Context, args []string, stdout, stderr io.Writer) error { + flags := flag.NewFlagSet("device-credentials", flag.ContinueOnError) + flags.SetOutput(stderr) + databaseSource := flags.String("database", "", "explicit migrated SQLite data source") + if err := flags.Parse(args); err != nil { + return err + } + if *databaseSource == "" { + return errors.New("-database is required") + } + if flags.NArg() < 1 { + return errors.New("usage: device-credentials -database [options]") + } + command := flags.Arg(0) + commandArgs := flags.Args()[1:] + var issueName, revokeDeviceID string + switch command { + case "issue": + commandFlags := flag.NewFlagSet("issue", flag.ContinueOnError) + commandFlags.SetOutput(stderr) + commandFlags.StringVar(&issueName, "name", "", "non-secret device display name") + if err := commandFlags.Parse(commandArgs); err != nil { + return err + } + if issueName == "" || commandFlags.NArg() != 0 { + return errors.New("usage: device-credentials -database issue -name ") + } + case "list": + if len(commandArgs) != 0 { + return errors.New("usage: device-credentials -database list") + } + case "revoke": + commandFlags := flag.NewFlagSet("revoke", flag.ContinueOnError) + commandFlags.SetOutput(stderr) + commandFlags.StringVar(&revokeDeviceID, "device-id", "", "canonical device UUID") + if err := commandFlags.Parse(commandArgs); err != nil { + return err + } + if revokeDeviceID == "" || commandFlags.NArg() != 0 { + return errors.New("usage: device-credentials -database revoke -device-id ") + } + default: + return fmt.Errorf("unsupported device credential command %q", command) + } + if command == "issue" && !deviceauth.ValidDisplayName(issueName) { + return deviceauth.ErrInvalidCredential + } + if command == "revoke" && !deviceauth.ValidDeviceID(revokeDeviceID) { + return deviceauth.ErrInvalidCredential + } + + existingSource, err := existingSQLiteDataSource(*databaseSource) + if err != nil { + return err + } + database, err := sqlite.Open(existingSource) + if err != nil { + return fmt.Errorf("open SQLite database: %w", err) + } + defer database.Close() + store, err := deviceauth.NewCredentialStore(database) + if err != nil { + return err + } + + switch command { + case "issue": + issued, err := store.Issue(ctx, issueName) + if err != nil { + return err + } + // The token has json:"-" and is printed only by this explicit post-commit path. Generic + // serialization, list, revoke, errors, and server responses therefore cannot disclose it. + if _, err := fmt.Fprintf(stdout, "device_id=%s\ndisplay_name=%s\ntoken=%s\ncreated_at=%s\n", + issued.DeviceID, issued.DisplayName, issued.Token, issued.CreatedAt.Format(time.RFC3339Nano)); err != nil { + return errors.New("write issued device credential") + } + return nil + case "list": + credentials, err := store.List(ctx) + if err != nil { + return err + } + return writeJSON(stdout, credentials) + case "revoke": + credential, changed, err := store.Revoke(ctx, revokeDeviceID) + if err != nil { + return err + } + return writeJSON(stdout, struct { + Credential deviceauth.Credential `json:"credential"` + RevokedNow bool `json:"revoked_now"` + }{Credential: credential, RevokedNow: changed}) + } + return errors.New("unreachable device credential command") +} + +func existingSQLiteDataSource(value string) (string, error) { + if value == "" || strings.TrimSpace(value) != value { + return "", errors.New("-database must name an existing file-backed SQLite database") + } + + var parsed *url.URL + var query url.Values + if strings.HasPrefix(strings.ToLower(value), "file:") { + var err error + parsed, err = url.Parse(value) + if err != nil || !strings.EqualFold(parsed.Scheme, "file") || parsed.User != nil || parsed.Host != "" || parsed.Fragment != "" { + return "", errors.New("-database file URI is invalid") + } + // go-sqlite3 recognizes URI filenames only with the exact lowercase file: prefix. + // Canonicalize accepted scheme casing before mode=rw reaches the driver, otherwise a + // mixed-case input could be treated as a plain filename and recreate a missing database. + parsed.Scheme = "file" + query, err = url.ParseQuery(parsed.RawQuery) + if err != nil { + return "", errors.New("-database query parameters are invalid") + } + fileName := parsed.Path + if parsed.Opaque != "" { + fileName = parsed.Opaque + } + decodedName, err := url.PathUnescape(fileName) + if err != nil || fileName == "" || strings.EqualFold(decodedName, ":memory:") { + return "", errors.New("-database must name an existing file-backed SQLite database") + } + } else { + pathPart, rawQuery, hasQuery := strings.Cut(value, "?") + if pathPart == "" || strings.EqualFold(pathPart, ":memory:") || strings.Contains(pathPart, "://") { + return "", errors.New("-database must name an existing file-backed SQLite database") + } + var err error + query, err = url.ParseQuery(rawQuery) + if err != nil { + return "", errors.New("-database query parameters are invalid") + } + normalizedPath := filepath.ToSlash(pathPart) + if filepath.VolumeName(pathPart) != "" && !strings.HasPrefix(normalizedPath, "/") { + normalizedPath = "/" + normalizedPath + } + parsed = &url.URL{Scheme: "file", Path: normalizedPath} + if !hasQuery { + query = make(url.Values) + } + } + + modes := query["mode"] + if len(modes) > 1 || len(modes) == 1 && modes[0] != "rw" { + return "", errors.New("-database only permits SQLite mode=rw") + } + if len(modes) == 0 { + query.Set("mode", "rw") + } + for _, name := range []string{"immutable", "_query_only"} { + for _, setting := range query[name] { + if setting != "0" && !strings.EqualFold(setting, "false") { + return "", errors.New("-database contains a read-only SQLite option") + } + } + } + parsed.RawQuery = query.Encode() + parsed.ForceQuery = false + return parsed.String(), nil +} + +func writeJSON(writer io.Writer, value any) error { + encoder := json.NewEncoder(writer) + encoder.SetEscapeHTML(true) + if err := encoder.Encode(value); err != nil { + return errors.New("write device credential metadata") + } + return nil +} diff --git a/admin/cmd/device-credentials/main_test.go b/admin/cmd/device-credentials/main_test.go new file mode 100644 index 0000000..ea43da4 --- /dev/null +++ b/admin/cmd/device-credentials/main_test.go @@ -0,0 +1,293 @@ +package main + +import ( + "bytes" + "context" + "database/sql" + "errors" + "io" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "cmbuyer/admin/internal/deviceauth" + "cmbuyer/admin/internal/migrations" + "cmbuyer/admin/internal/storage/sqlite" +) + +func TestIssueListAndIdempotentRevokeNeverRediscloseSecret(t *testing.T) { + databaseSource := migratedDatabase(t) + var issued bytes.Buffer + if err := run(context.Background(), []string{"-database", databaseSource, "issue", "-name", "采购工具一号"}, &issued, io.Discard); err != nil { + t.Fatalf("issue: %v", err) + } + fields := outputFields(t, issued.String()) + deviceID, token := fields["device_id"], fields["token"] + if len(token) != 64 || strings.Count(issued.String(), token) != 1 { + t.Fatalf("issue token occurrence/length = %d/%d", strings.Count(issued.String(), token), len(token)) + } + + var listed bytes.Buffer + if err := run(context.Background(), []string{"-database", databaseSource, "list"}, &listed, io.Discard); err != nil { + t.Fatalf("list: %v", err) + } + assertNoSecretMetadata(t, listed.String(), token) + if !strings.Contains(listed.String(), deviceID) || !strings.Contains(listed.String(), "采购工具一号") { + t.Fatalf("list omitted safe metadata: %s", listed.String()) + } + + var revoked bytes.Buffer + if err := run(context.Background(), []string{"-database", databaseSource, "revoke", "-device-id", deviceID}, &revoked, io.Discard); err != nil { + t.Fatalf("revoke: %v", err) + } + assertNoSecretMetadata(t, revoked.String(), token) + if !strings.Contains(revoked.String(), `"revoked_now":true`) { + t.Fatalf("first revoke output = %s", revoked.String()) + } + var repeated bytes.Buffer + if err := run(context.Background(), []string{"-database", databaseSource, "revoke", "-device-id", deviceID}, &repeated, io.Discard); err != nil { + t.Fatalf("repeat revoke: %v", err) + } + assertNoSecretMetadata(t, repeated.String(), token) + if !strings.Contains(repeated.String(), `"revoked_now":false`) { + t.Fatalf("repeat revoke output = %s", repeated.String()) + } +} + +func TestIssueOutputFailureLeavesCommittedCredentialWithoutSecretInError(t *testing.T) { + databaseSource := migratedDatabase(t) + writer := &recordingFailureWriter{} + err := run(context.Background(), []string{"-database", databaseSource, "issue", "-name", "output failure"}, writer, io.Discard) + if err == nil || err.Error() != "write issued device credential" { + t.Fatalf("issue output failure error = %v", err) + } + fields := outputFields(t, writer.contents.String()) + if strings.Contains(err.Error(), fields["token"]) { + t.Fatal("output error disclosed token") + } + database, err := sql.Open("sqlite3", databaseSource) + if err != nil { + t.Fatalf("open database: %v", err) + } + defer database.Close() + var count int + if err := database.QueryRow(`SELECT COUNT(*) FROM device_credentials`).Scan(&count); err != nil || count != 1 { + t.Fatalf("committed credential count = %d, err=%v", count, err) + } +} + +func TestCLIRequiresPreMigratedExplicitDatabase(t *testing.T) { + if err := run(context.Background(), []string{"list"}, io.Discard, io.Discard); err == nil { + t.Fatal("command without -database succeeded") + } + missing := filepath.Join(t.TempDir(), "missing.db") + if err := run(context.Background(), []string{"-database", missing, "list"}, io.Discard, io.Discard); err == nil { + t.Fatal("list opened a missing database") + } + if _, err := os.Stat(missing); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("missing database was created: %v", err) + } + + unmigrated := filepath.Join(t.TempDir(), "unmigrated.db") + unmigratedDatabase, err := sqlite.Open(unmigrated) + if err != nil { + t.Fatalf("create unmigrated database: %v", err) + } + if _, err := unmigratedDatabase.Exec(`CREATE TABLE unrelated (id INTEGER)`); err != nil { + _ = unmigratedDatabase.Close() + t.Fatalf("initialize unmigrated database: %v", err) + } + if err := unmigratedDatabase.Close(); err != nil { + t.Fatalf("close unmigrated database: %v", err) + } + if err := run(context.Background(), []string{"-database", unmigrated, "list"}, io.Discard, io.Discard); err == nil { + t.Fatal("list accepted an unmigrated database") + } + database, err := sql.Open("sqlite3", unmigrated) + if err != nil { + t.Fatalf("open unmigrated database: %v", err) + } + defer database.Close() + var count int + if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='device_credentials'`).Scan(&count); err != nil || count != 0 { + t.Fatalf("device_credentials table count = %d, err=%v", count, err) + } + + undeclared := filepath.Join(t.TempDir(), "undeclared.db") + if err := run(context.Background(), []string{"-database", undeclared, "rotate"}, io.Discard, io.Discard); err == nil { + t.Fatal("undeclared command succeeded") + } + if _, err := os.Stat(undeclared); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("undeclared command opened database: %v", err) + } + + invalidIssue := filepath.Join(t.TempDir(), "invalid-issue.db") + if err := run(context.Background(), []string{"-database", invalidIssue, "issue", "-name", " padded"}, io.Discard, io.Discard); !errors.Is(err, deviceauth.ErrInvalidCredential) { + t.Fatalf("invalid issue error = %v", err) + } + if _, err := os.Stat(invalidIssue); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("invalid issue opened database: %v", err) + } + + invalidRevoke := filepath.Join(t.TempDir(), "invalid-revoke.db") + if err := run(context.Background(), []string{"-database", invalidRevoke, "revoke", "-device-id", "not-a-uuid"}, io.Discard, io.Discard); !errors.Is(err, deviceauth.ErrInvalidCredential) { + t.Fatalf("invalid revoke error = %v", err) + } + if _, err := os.Stat(invalidRevoke); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("invalid revoke opened database: %v", err) + } + + migrated := migratedDatabase(t) + unknownID := "13c9f507-7473-4fa6-8d71-8786c34c6301" + var unknownOutput bytes.Buffer + err = run(context.Background(), []string{"-database", migrated, "revoke", "-device-id", unknownID}, &unknownOutput, io.Discard) + if !errors.Is(err, deviceauth.ErrCredentialNotFound) || unknownOutput.Len() != 0 || strings.Contains(err.Error(), unknownID) { + t.Fatalf("unknown revoke = output %q, error %v", unknownOutput.String(), err) + } +} + +func TestExistingSQLiteDataSourcePreservesSafeOptionsAndRejectsCreationModes(t *testing.T) { + databaseSource := migratedDatabase(t) + fileURI := (&url.URL{ + Scheme: "file", + Path: sqliteURIPath(databaseSource), + RawQuery: "_busy_timeout=5000&cache=shared", + }).String() + normalized, err := existingSQLiteDataSource(fileURI) + if err != nil { + t.Fatalf("normalize file URI: %v", err) + } + parsed, err := url.Parse(normalized) + if err != nil { + t.Fatalf("parse normalized URI: %v", err) + } + query := parsed.Query() + if query.Get("mode") != "rw" || query.Get("_busy_timeout") != "5000" || query.Get("cache") != "shared" { + t.Fatalf("normalized query = %v", query) + } + if err := run(context.Background(), []string{"-database", fileURI, "list"}, io.Discard, io.Discard); err != nil { + t.Fatalf("list existing file URI: %v", err) + } + + plainNormalized, err := existingSQLiteDataSource(databaseSource + "?_foreign_keys=on") + if err != nil { + t.Fatalf("normalize ordinary path: %v", err) + } + plainURI, err := url.Parse(plainNormalized) + if err != nil || plainURI.Scheme != "file" || plainURI.Query().Get("mode") != "rw" || plainURI.Query().Get("_foreign_keys") != "on" { + t.Fatalf("ordinary path normalization = %q, err=%v", plainNormalized, err) + } + + missing := filepath.Join(t.TempDir(), "missing-uri.db") + missingURI := (&url.URL{Scheme: "file", Path: sqliteURIPath(missing)}).String() + if err := run(context.Background(), []string{"-database", missingURI, "list"}, io.Discard, io.Discard); err == nil { + t.Fatal("missing file URI succeeded") + } + if _, err := os.Stat(missing); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("missing file URI created a file: %v", err) + } + for _, scheme := range []string{"FILE", "File"} { + mixedMissing := filepath.Join(t.TempDir(), strings.ToLower(scheme)+"-missing.db") + canonical := (&url.URL{Scheme: "file", Path: sqliteURIPath(mixedMissing)}).String() + mixedURI := scheme + canonical[len("file"):] + normalized, err := existingSQLiteDataSource(mixedURI) + if err != nil || !strings.HasPrefix(normalized, "file:") { + t.Fatalf("normalize %s URI = %q, err=%v", scheme, normalized, err) + } + if err := run(context.Background(), []string{"-database", mixedURI, "list"}, io.Discard, io.Discard); err == nil { + t.Fatalf("missing %s URI succeeded", scheme) + } + if _, err := os.Stat(mixedMissing); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("missing %s URI created a file: %v", scheme, err) + } + } + + for name, source := range map[string]string{ + "plain memory": ":memory:", + "URI memory": "file::memory:?cache=shared", + "memory mode": fileURI + "&mode=memory", + "read only mode": fileURI + "&mode=ro", + "create mode": fileURI + "&mode=rwc", + "duplicate mode": fileURI + "&mode=rw&mode=rw", + "immutable": fileURI + "&immutable=1", + "query only": fileURI + "&_query_only=1", + "remote authority": "file://server/share/database.db?mode=rw", + } { + t.Run(name, func(t *testing.T) { + if _, err := existingSQLiteDataSource(source); err == nil { + t.Fatalf("unsafe source accepted: %q", source) + } + }) + } +} + +func sqliteURIPath(path string) string { + normalized := filepath.ToSlash(path) + if filepath.VolumeName(path) != "" && !strings.HasPrefix(normalized, "/") { + return "/" + normalized + } + return normalized +} + +type recordingFailureWriter struct { + contents bytes.Buffer +} + +func (writer *recordingFailureWriter) Write(value []byte) (int, error) { + _, _ = writer.contents.Write(value) + return 0, errors.New("injected stdout failure") +} + +func assertNoSecretMetadata(t *testing.T, output, token string) { + t.Helper() + if strings.Contains(output, token) || strings.Contains(output, "token") || strings.Contains(output, "hash") || strings.Contains(output, "sha256") { + t.Fatalf("metadata output disclosed secret material: %s", output) + } +} + +func outputFields(t *testing.T, output string) map[string]string { + t.Helper() + fields := make(map[string]string) + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + name, value, found := strings.Cut(line, "=") + if !found || name == "" || value == "" { + t.Fatalf("invalid issue output line %q", line) + } + fields[name] = value + } + for _, required := range []string{"device_id", "display_name", "token", "created_at"} { + if fields[required] == "" { + t.Fatalf("issue output missing %s: %q", required, output) + } + } + return fields +} + +func migratedDatabase(t *testing.T) string { + t.Helper() + databaseSource := filepath.Join(t.TempDir(), "credentials.db") + database, err := sqlite.Open(databaseSource) + if err != nil { + t.Fatalf("open database: %v", err) + } + if err := migrations.Up(context.Background(), database, commandMigrationDirectory(t)); err != nil { + _ = database.Close() + t.Fatalf("migrate database: %v", err) + } + if err := database.Close(); err != nil { + t.Fatalf("close migrated database: %v", err) + } + return databaseSource +} + +func commandMigrationDirectory(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate migrations") + } + return filepath.Join(filepath.Dir(file), "..", "..", "migrations") +} diff --git a/admin/cmd/server/main.go b/admin/cmd/server/main.go index aa331c1..ba762b5 100644 --- a/admin/cmd/server/main.go +++ b/admin/cmd/server/main.go @@ -7,7 +7,7 @@ import ( "cmbuyer/admin/internal/auth" "cmbuyer/admin/internal/config" - "cmbuyer/admin/internal/evidence" + "cmbuyer/admin/internal/deviceauth" "cmbuyer/admin/internal/server" evidencestorage "cmbuyer/admin/internal/storage/evidence" "cmbuyer/admin/internal/storage/sqlite" @@ -15,7 +15,9 @@ import ( "cmbuyer/admin/internal/tasks" ) -const listenAddress = ":8080" +// Device Bearer credentials must not cross a plaintext LAN. The MVP is a same-computer +// deployment, so widening this address requires a separately reviewed TLS boundary first. +const listenAddress = "127.0.0.1:8080" func main() { if err := run(); err != nil { @@ -46,6 +48,10 @@ func run() error { if err != nil { return err } + deviceAuthenticator, err := deviceauth.NewSQLiteAuthenticator(database) + if err != nil { + return err + } router, err := server.NewRouter(server.Options{ AdminUsername: configuration.AdminUsername, @@ -54,7 +60,7 @@ func run() error { Tasks: taskStore, TaskDetails: detailStore, Evidence: evidenceStore, - DeviceAuthenticator: evidence.RejectAllDeviceAuthenticator{}, + DeviceAuthenticator: deviceAuthenticator, }) if err != nil { return err diff --git a/admin/cmd/server/main_test.go b/admin/cmd/server/main_test.go new file mode 100644 index 0000000..32acc45 --- /dev/null +++ b/admin/cmd/server/main_test.go @@ -0,0 +1,9 @@ +package main + +import "testing" + +func TestListenAddressIsIPv4LoopbackOnly(t *testing.T) { + if listenAddress != "127.0.0.1:8080" { + t.Fatalf("listenAddress = %q, want loopback-only endpoint", listenAddress) + } +} diff --git a/admin/internal/deviceauth/credentials.go b/admin/internal/deviceauth/credentials.go new file mode 100644 index 0000000..69e1d9e --- /dev/null +++ b/admin/internal/deviceauth/credentials.go @@ -0,0 +1,212 @@ +package deviceauth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "io" + "strings" + "sync" + "time" + "unicode" +) + +const ( + StatusActive = "ACTIVE" + StatusRevoked = "REVOKED" +) + +var ( + ErrInvalidCredential = errors.New("invalid device credential input") + ErrCredentialNotFound = errors.New("device credential not found") +) + +type Credential struct { + DeviceID string `json:"device_id"` + DisplayName string `json:"display_name"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + RevokedAt *time.Time `json:"revoked_at,omitempty"` +} + +// IssuedCredential is the only value that can carry the plaintext token. It is returned only +// after SQLite has committed the hash and is intended for the management CLI's one stdout write. +type IssuedCredential struct { + Credential + Token string `json:"-"` +} + +type CredentialStore struct { + database *sql.DB + now func() time.Time + random io.Reader + randomMu sync.Mutex +} + +func NewCredentialStore(database *sql.DB) (*CredentialStore, error) { + if database == nil { + return nil, errors.New("device credential database is required") + } + if _, err := database.Exec("SELECT device_id FROM device_credentials LIMIT 1"); err != nil { + return nil, errors.New("device credential migration is not available") + } + return &CredentialStore{database: database, now: time.Now, random: rand.Reader}, nil +} + +func (store *CredentialStore) Issue(ctx context.Context, displayName string) (IssuedCredential, error) { + if !ValidDisplayName(displayName) { + return IssuedCredential{}, ErrInvalidCredential + } + randomBytes := make([]byte, 16+32) + store.randomMu.Lock() + _, randomErr := io.ReadFull(store.random, randomBytes) + store.randomMu.Unlock() + if randomErr != nil { + return IssuedCredential{}, fmt.Errorf("generate device credential: %w", randomErr) + } + deviceID := formatUUIDv4(randomBytes[:16]) + token := hex.EncodeToString(randomBytes[16:]) + tokenHash := sha256.Sum256(randomBytes[16:]) + createdAt := store.now().UTC() + if createdAt.IsZero() { + return IssuedCredential{}, errors.New("device credential clock is invalid") + } + _, err := store.database.ExecContext(ctx, `INSERT INTO device_credentials + (device_id, display_name, token_sha256, status, created_at, revoked_at) + VALUES (?, ?, ?, ?, ?, NULL)`, + deviceID, displayName, tokenHash[:], StatusActive, createdAt.Format(time.RFC3339Nano)) + if err != nil { + return IssuedCredential{}, fmt.Errorf("persist device credential: %w", err) + } + return IssuedCredential{Credential: Credential{ + DeviceID: deviceID, DisplayName: displayName, Status: StatusActive, CreatedAt: createdAt, + }, Token: token}, nil +} + +func (store *CredentialStore) List(ctx context.Context) ([]Credential, error) { + rows, err := store.database.QueryContext(ctx, `SELECT device_id, display_name, status, created_at, revoked_at + FROM device_credentials ORDER BY created_at, device_id`) + if err != nil { + return nil, fmt.Errorf("list device credentials: %w", err) + } + defer rows.Close() + credentials := make([]Credential, 0) + for rows.Next() { + credential, err := scanCredential(rows) + if err != nil { + return nil, err + } + credentials = append(credentials, credential) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list device credentials: %w", err) + } + return credentials, nil +} + +func (store *CredentialStore) Revoke(ctx context.Context, deviceID string) (Credential, bool, error) { + if !ValidDeviceID(deviceID) { + return Credential{}, false, ErrInvalidCredential + } + revokedAt := store.now().UTC() + if revokedAt.IsZero() { + return Credential{}, false, errors.New("device credential clock is invalid") + } + transaction, err := store.database.BeginTx(ctx, nil) + if err != nil { + return Credential{}, false, fmt.Errorf("begin device credential revocation: %w", err) + } + defer transaction.Rollback() + result, err := transaction.ExecContext(ctx, `UPDATE device_credentials + SET status = ?, revoked_at = ? WHERE device_id = ? AND status = ?`, + StatusRevoked, revokedAt.Format(time.RFC3339Nano), deviceID, StatusActive) + if err != nil { + return Credential{}, false, fmt.Errorf("revoke device credential: %w", err) + } + changedRows, err := result.RowsAffected() + if err != nil { + return Credential{}, false, fmt.Errorf("inspect device credential revocation: %w", err) + } + credential, err := scanCredential(transaction.QueryRowContext(ctx, `SELECT device_id, display_name, status, created_at, revoked_at + FROM device_credentials WHERE device_id = ?`, deviceID)) + if errors.Is(err, sql.ErrNoRows) { + return Credential{}, false, ErrCredentialNotFound + } + if err != nil { + return Credential{}, false, err + } + if err := transaction.Commit(); err != nil { + return Credential{}, false, fmt.Errorf("commit device credential revocation: %w", err) + } + return credential, changedRows == 1, nil +} + +type rowScanner interface { + Scan(...any) error +} + +func scanCredential(row rowScanner) (Credential, error) { + var credential Credential + var created string + var revoked sql.NullString + if err := row.Scan(&credential.DeviceID, &credential.DisplayName, &credential.Status, &created, &revoked); err != nil { + return Credential{}, err + } + if !ValidDeviceID(credential.DeviceID) || !ValidDisplayName(credential.DisplayName) || (credential.Status != StatusActive && credential.Status != StatusRevoked) { + return Credential{}, errors.New("stored device credential metadata is invalid") + } + createdAt, err := parseStoredTime(created) + if err != nil { + return Credential{}, err + } + credential.CreatedAt = createdAt + if revoked.Valid { + revokedAt, err := parseStoredTime(revoked.String) + if err != nil { + return Credential{}, err + } + if revokedAt.Before(createdAt) { + return Credential{}, errors.New("stored device credential status is invalid") + } + credential.RevokedAt = &revokedAt + } + if (credential.Status == StatusActive) != (credential.RevokedAt == nil) { + return Credential{}, errors.New("stored device credential status is invalid") + } + return credential, nil +} + +func ValidDisplayName(value string) bool { + if value == "" || len([]rune(value)) > 128 || strings.TrimSpace(value) != value { + return false + } + for _, character := range value { + if unicode.IsControl(character) { + return false + } + } + return true +} + +func parseStoredTime(value string) (time.Time, error) { + if strings.TrimSpace(value) != value || !strings.HasSuffix(value, "Z") { + return time.Time{}, errors.New("stored device credential time is invalid") + } + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil || parsed.Location() != time.UTC { + return time.Time{}, errors.New("stored device credential time is invalid") + } + return parsed, nil +} + +func formatUUIDv4(bytes []byte) string { + copyBytes := append([]byte(nil), bytes...) + copyBytes[6] = (copyBytes[6] & 0x0f) | 0x40 + copyBytes[8] = (copyBytes[8] & 0x3f) | 0x80 + encoded := hex.EncodeToString(copyBytes) + return encoded[:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:] +} diff --git a/admin/internal/deviceauth/deviceauth.go b/admin/internal/deviceauth/deviceauth.go new file mode 100644 index 0000000..6d2b038 --- /dev/null +++ b/admin/internal/deviceauth/deviceauth.go @@ -0,0 +1,168 @@ +// Package deviceauth owns the machine identity boundary shared by all device routes. +package deviceauth + +import ( + "crypto/sha256" + "crypto/subtle" + "database/sql" + "encoding/hex" + "errors" + "net/http" + "strings" +) + +const ( + AuthorizationHeader = "Authorization" + DeviceIDHeader = "X-CMBuyer-Device-ID" + tokenHexLength = 64 +) + +var ( + // ErrUnauthenticated deliberately covers every credential defect. Callers must not reveal + // whether a device exists, is revoked, or supplied a mismatched token. + ErrUnauthenticated = errors.New("device authentication failed") + // ErrUnavailable is distinct so a storage outage is not disguised as a bad credential. + // HTTP callers still return no diagnostic body because database details are server-only. + ErrUnavailable = errors.New("device authentication unavailable") +) + +type Principal struct { + ID string +} + +type Authenticator interface { + Authenticate(*http.Request) (Principal, error) +} + +// RejectAllAuthenticator is useful for tests and for fail-closed wiring where no credential +// store is available. Production startup uses SQLiteAuthenticator. +type RejectAllAuthenticator struct{} + +func (RejectAllAuthenticator) Authenticate(*http.Request) (Principal, error) { + return Principal{}, ErrUnauthenticated +} + +type SQLiteAuthenticator struct { + database *sql.DB +} + +func NewSQLiteAuthenticator(database *sql.DB) (*SQLiteAuthenticator, error) { + if database == nil { + return nil, errors.New("device credential database is required") + } + if _, err := database.Exec("SELECT device_id FROM device_credentials LIMIT 1"); err != nil { + return nil, errors.New("device credential migration is not available") + } + return &SQLiteAuthenticator{database: database}, nil +} + +func (authenticator *SQLiteAuthenticator) Authenticate(request *http.Request) (Principal, error) { + if request == nil { + return Principal{}, ErrUnauthenticated + } + deviceID, token, ok := requestCredentials(request) + if !ok { + return Principal{}, ErrUnauthenticated + } + + candidateHash := sha256.Sum256(token) + var storedHash []byte + var hashType string + var hashLength sql.NullInt64 + var status sql.NullString + var revokedAt sql.NullString + var found bool + err := authenticator.database.QueryRowContext( + request.Context(), + `SELECT CASE WHEN credentials.device_id IS NULL THEN zeroblob(32) ELSE credentials.token_sha256 END, + typeof(credentials.token_sha256), + length(credentials.token_sha256), + credentials.status, + credentials.revoked_at, + credentials.device_id IS NOT NULL + FROM (SELECT 1) AS singleton + LEFT JOIN device_credentials AS credentials ON credentials.device_id = ?`, + deviceID, + ).Scan(&storedHash, &hashType, &hashLength, &status, &revokedAt, &found) + if err != nil { + return Principal{}, ErrUnavailable + } + if len(storedHash) != sha256.Size { + return Principal{}, ErrUnavailable + } + matched := subtle.ConstantTimeCompare(candidateHash[:], storedHash) == 1 + if !found { + // The LEFT JOIN supplies a 32-byte dummy hash, so unknown ids take the same compare path + // as known credentials without requiring a plaintext token lookup. + return Principal{}, ErrUnauthenticated + } + if hashType != "blob" || !hashLength.Valid || hashLength.Int64 != sha256.Size || len(storedHash) != sha256.Size || !status.Valid { + return Principal{}, ErrUnavailable + } + switch status.String { + case StatusActive: + if revokedAt.Valid { + return Principal{}, ErrUnavailable + } + case StatusRevoked: + if !revokedAt.Valid { + return Principal{}, ErrUnavailable + } + if _, err := parseStoredTime(revokedAt.String); err != nil { + return Principal{}, ErrUnavailable + } + default: + return Principal{}, ErrUnavailable + } + if !matched || status.String == StatusRevoked { + return Principal{}, ErrUnauthenticated + } + return Principal{ID: deviceID}, nil +} + +func requestCredentials(request *http.Request) (string, []byte, bool) { + authorizations := request.Header.Values(AuthorizationHeader) + deviceIDs := request.Header.Values(DeviceIDHeader) + if len(authorizations) != 1 || len(deviceIDs) != 1 { + return "", nil, false + } + authorization := authorizations[0] + if len(authorization) != len("Bearer ")+tokenHexLength || !strings.EqualFold(authorization[:len("Bearer")], "Bearer") || authorization[len("Bearer")] != ' ' { + return "", nil, false + } + tokenHex := authorization[len("Bearer "):] + if !validLowerHex(tokenHex, tokenHexLength) || !ValidDeviceID(deviceIDs[0]) { + return "", nil, false + } + token, err := hex.DecodeString(tokenHex) + if err != nil { + return "", nil, false + } + return deviceIDs[0], token, true +} + +func ValidDeviceID(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 validLowerHex(value string, length int) bool { + if len(value) != length { + return false + } + decoded, err := hex.DecodeString(value) + return err == nil && hex.EncodeToString(decoded) == value +} diff --git a/admin/internal/deviceauth/deviceauth_test.go b/admin/internal/deviceauth/deviceauth_test.go new file mode 100644 index 0000000..c07ea0d --- /dev/null +++ b/admin/internal/deviceauth/deviceauth_test.go @@ -0,0 +1,407 @@ +package deviceauth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + "cmbuyer/admin/internal/migrations" + "cmbuyer/admin/internal/storage/sqlite" +) + +func TestIssueStoresOnlyRawTokenHashAndGenericJSONOmitsSecret(t *testing.T) { + database, store := newCredentialStore(t) + first, err := store.Issue(context.Background(), "采购工具一号") + if err != nil { + t.Fatalf("Issue first: %v", err) + } + second, err := store.Issue(context.Background(), "采购工具二号") + if err != nil { + t.Fatalf("Issue second: %v", err) + } + if first.DeviceID == second.DeviceID || first.Token == second.Token || !ValidDeviceID(first.DeviceID) || !validLowerHex(first.Token, tokenHexLength) { + t.Fatalf("issued identifiers are not independent canonical values") + } + + rawToken, err := hex.DecodeString(first.Token) + if err != nil { + t.Fatalf("decode issued token: %v", err) + } + wantHash := sha256.Sum256(rawToken) + var storedHash []byte + var storageType string + if err := database.QueryRow(`SELECT token_sha256, typeof(token_sha256) FROM device_credentials WHERE device_id = ?`, first.DeviceID).Scan(&storedHash, &storageType); err != nil { + t.Fatalf("read stored hash: %v", err) + } + if storageType != "blob" || len(storedHash) != sha256.Size || !equalBytes(storedHash, wantHash[:]) { + t.Fatalf("stored hash type/length/value = %q/%d/%t", storageType, len(storedHash), equalBytes(storedHash, wantHash[:])) + } + var leakedCopies int + if err := database.QueryRow(`SELECT COUNT(*) FROM device_credentials WHERE CAST(token_sha256 AS TEXT) IN (?, ?)`, first.Token, hex.EncodeToString(wantHash[:])).Scan(&leakedCopies); err != nil { + t.Fatalf("search token copies: %v", err) + } + if leakedCopies != 0 { + t.Fatal("database stored a plaintext or hex-encoded token/hash copy") + } + encoded, err := json.Marshal(first) + if err != nil { + t.Fatalf("marshal issued credential: %v", err) + } + if strings.Contains(string(encoded), first.Token) || strings.Contains(string(encoded), "token") { + t.Fatalf("generic serialization disclosed token field: %s", encoded) + } +} + +func TestAuthenticateStrictHeaderMatrixAndBinding(t *testing.T) { + _, store := newCredentialStore(t) + first, err := store.Issue(context.Background(), "one") + if err != nil { + t.Fatalf("issue first: %v", err) + } + second, err := store.Issue(context.Background(), "two") + if err != nil { + t.Fatalf("issue second: %v", err) + } + authenticator := authenticatorForStore(t, store) + + for _, scheme := range []string{"Bearer", "bearer", "BEARER"} { + request := credentialRequest(first.DeviceID, scheme+" "+first.Token) + principal, err := authenticator.Authenticate(request) + if err != nil || principal.ID != first.DeviceID { + t.Fatalf("scheme %q Authenticate = (%q, %v)", scheme, principal.ID, err) + } + } + + unknownID := newRuntimeUUID(t) + wrongToken := newRuntimeToken(t) + tests := []struct { + name string + mutate func(*http.Request) + }{ + {"missing authorization", func(request *http.Request) { request.Header.Del(AuthorizationHeader) }}, + {"missing device", func(request *http.Request) { request.Header.Del(DeviceIDHeader) }}, + {"empty authorization", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "") }}, + {"empty device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, "") }}, + {"duplicate authorization", func(request *http.Request) { request.Header.Add(AuthorizationHeader, "Bearer "+first.Token) }}, + {"duplicate device", func(request *http.Request) { request.Header.Add(DeviceIDHeader, first.DeviceID) }}, + {"combined authorization", func(request *http.Request) { + request.Header.Set(AuthorizationHeader, "Bearer "+first.Token+", Bearer "+first.Token) + }}, + {"combined device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, first.DeviceID+", "+first.DeviceID) }}, + {"extra separator", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token) }}, + {"tab separator", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer\t"+first.Token) }}, + {"uppercase token", func(request *http.Request) { + request.Header.Set(AuthorizationHeader, "Bearer "+strings.ToUpper(first.Token)) + }}, + {"short token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token[:62]) }}, + {"long token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token+"00") }}, + {"non hex token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token[:63]+"g") }}, + {"token separator", func(request *http.Request) { + request.Header.Set(AuthorizationHeader, "Bearer "+first.Token[:32]+"-"+first.Token[33:]) + }}, + {"uppercase device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, strings.ToUpper(first.DeviceID)) }}, + {"padded device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, " "+first.DeviceID) }}, + {"wrong uuid version", func(request *http.Request) { + request.Header.Set(DeviceIDHeader, first.DeviceID[:14]+"3"+first.DeviceID[15:]) + }}, + {"wrong uuid variant", func(request *http.Request) { + request.Header.Set(DeviceIDHeader, first.DeviceID[:19]+"7"+first.DeviceID[20:]) + }}, + {"unknown device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, unknownID) }}, + {"wrong token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+wrongToken) }}, + {"token device mismatch", func(request *http.Request) { request.Header.Set(DeviceIDHeader, second.DeviceID) }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := credentialRequest(first.DeviceID, "Bearer "+first.Token) + test.mutate(request) + principal, err := authenticator.Authenticate(request) + if !errors.Is(err, ErrUnauthenticated) || principal != (Principal{}) { + t.Fatalf("Authenticate = (%#v, %v), want empty unauthenticated", principal, err) + } + }) + } + if principal, err := authenticator.Authenticate(nil); !errors.Is(err, ErrUnauthenticated) || principal != (Principal{}) { + t.Fatalf("Authenticate(nil) = (%#v, %v)", principal, err) + } +} + +func TestRevokeIsImmediateAndIdempotent(t *testing.T) { + _, store := newCredentialStore(t) + issued, err := store.Issue(context.Background(), "device") + if err != nil { + t.Fatalf("Issue: %v", err) + } + authenticator := authenticatorForStore(t, store) + request := credentialRequest(issued.DeviceID, "Bearer "+issued.Token) + if _, err := authenticator.Authenticate(request); err != nil { + t.Fatalf("Authenticate before revoke: %v", err) + } + + first, changed, err := store.Revoke(context.Background(), issued.DeviceID) + if err != nil || !changed || first.Status != StatusRevoked || first.RevokedAt == nil { + t.Fatalf("first Revoke = (%#v, %t, %v)", first, changed, err) + } + if principal, err := authenticator.Authenticate(request); !errors.Is(err, ErrUnauthenticated) || principal != (Principal{}) { + t.Fatalf("Authenticate after committed revoke = (%#v, %v)", principal, err) + } + second, changed, err := store.Revoke(context.Background(), issued.DeviceID) + if err != nil || changed || second.RevokedAt == nil || !second.RevokedAt.Equal(*first.RevokedAt) { + t.Fatalf("second Revoke = (%#v, %t, %v)", second, changed, err) + } + listed, err := store.List(context.Background()) + if err != nil || len(listed) != 1 || listed[0].Status != StatusRevoked { + t.Fatalf("List = (%#v, %v)", listed, err) + } +} + +func TestConcurrentAuthenticationAndRevocation(t *testing.T) { + _, store := newCredentialStore(t) + issued, err := store.Issue(context.Background(), "concurrent") + if err != nil { + t.Fatalf("Issue: %v", err) + } + authenticator := authenticatorForStore(t, store) + request := func() *http.Request { return credentialRequest(issued.DeviceID, "Bearer "+issued.Token) } + start := make(chan struct{}) + results := make(chan error, 16) + var wait sync.WaitGroup + for index := 0; index < 16; index++ { + wait.Add(1) + go func() { + defer wait.Done() + <-start + _, err := authenticator.Authenticate(request()) + results <- err + }() + } + close(start) + if _, _, err := store.Revoke(context.Background(), issued.DeviceID); err != nil { + t.Fatalf("Revoke: %v", err) + } + wait.Wait() + close(results) + for err := range results { + if err != nil && !errors.Is(err, ErrUnauthenticated) { + t.Fatalf("concurrent Authenticate error = %v", err) + } + } + for index := 0; index < 16; index++ { + if _, err := authenticator.Authenticate(request()); !errors.Is(err, ErrUnauthenticated) { + t.Fatalf("post-commit Authenticate %d error = %v", index, err) + } + } +} + +func TestAuthenticationDatabaseFaultAndCorruptionAreUnavailable(t *testing.T) { + database, store := newCredentialStore(t) + issued, err := store.Issue(context.Background(), "device") + if err != nil { + t.Fatalf("Issue: %v", err) + } + authenticator := authenticatorForStore(t, store) + if err := database.Close(); err != nil { + t.Fatalf("close database: %v", err) + } + if _, err := authenticator.Authenticate(credentialRequest(issued.DeviceID, "Bearer "+issued.Token)); !errors.Is(err, ErrUnavailable) { + t.Fatalf("closed database Authenticate error = %v", err) + } + + corruptDB, err := sqlite.Open(filepath.Join(t.TempDir(), "corrupt.db")) + if err != nil { + t.Fatalf("open corrupt database: %v", err) + } + t.Cleanup(func() { _ = corruptDB.Close() }) + if _, err := corruptDB.Exec(`CREATE TABLE device_credentials (device_id TEXT PRIMARY KEY, token_sha256 BLOB, status TEXT, revoked_at TEXT)`); err != nil { + t.Fatalf("create corrupt table: %v", err) + } + corruptAuthenticator, err := NewSQLiteAuthenticator(corruptDB) + if err != nil { + t.Fatalf("new corrupt authenticator: %v", err) + } + tests := []struct { + name string + hashValue func([sha256.Size]byte) any + status string + revokedAt any + }{ + {name: "null hash", hashValue: func([sha256.Size]byte) any { return nil }, status: StatusActive}, + {name: "matching text hash", hashValue: func(hash [sha256.Size]byte) any { return string(hash[:]) }, status: StatusActive}, + {name: "unknown status", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: "BROKEN"}, + {name: "active with revoked time", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: StatusActive, revokedAt: "2026-08-04T00:00:00Z"}, + {name: "revoked without time", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: StatusRevoked}, + {name: "revoked with invalid time", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: StatusRevoked, revokedAt: "not-a-time"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + rawToken, token := newRuntimeTokenPair(t) + hash := sha256.Sum256(rawToken) + deviceID := newRuntimeUUID(t) + if _, err := corruptDB.Exec(`INSERT INTO device_credentials VALUES (?, ?, ?, ?)`, deviceID, test.hashValue(hash), test.status, test.revokedAt); err != nil { + t.Fatalf("insert corrupt row: %v", err) + } + principal, err := corruptAuthenticator.Authenticate(credentialRequest(deviceID, "Bearer "+token)) + if !errors.Is(err, ErrUnavailable) || principal != (Principal{}) { + t.Fatalf("corrupt Authenticate = (%#v, %v), want unavailable", principal, err) + } + }) + } +} + +func TestCredentialAuthenticationSurvivesDatabaseReopen(t *testing.T) { + databaseSource := filepath.Join(t.TempDir(), "reopen.db") + database, err := sqlite.Open(databaseSource) + if err != nil { + t.Fatalf("open database: %v", err) + } + if err := migrations.Up(context.Background(), database, deviceMigrationDirectory(t)); err != nil { + _ = database.Close() + t.Fatalf("migrate database: %v", err) + } + store, err := NewCredentialStore(database) + if err != nil { + _ = database.Close() + t.Fatalf("new store: %v", err) + } + issued, err := store.Issue(context.Background(), "reopen") + if err != nil { + _ = database.Close() + t.Fatalf("issue: %v", err) + } + if err := database.Close(); err != nil { + t.Fatalf("close database: %v", err) + } + + reopened, err := sqlite.Open(databaseSource) + if err != nil { + t.Fatalf("reopen database: %v", err) + } + defer reopened.Close() + authenticator, err := NewSQLiteAuthenticator(reopened) + if err != nil { + t.Fatalf("new reopened authenticator: %v", err) + } + principal, err := authenticator.Authenticate(credentialRequest(issued.DeviceID, "Bearer "+issued.Token)) + if err != nil || principal.ID != issued.DeviceID { + t.Fatalf("Authenticate after reopen = (%#v, %v)", principal, err) + } +} + +func TestCredentialInputAndMigrationAreRequired(t *testing.T) { + database, err := sqlite.Open(filepath.Join(t.TempDir(), "unmigrated.db")) + if err != nil { + t.Fatalf("open database: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + if _, err := NewCredentialStore(database); err == nil { + t.Fatal("NewCredentialStore accepted an unmigrated database") + } + if _, err := NewSQLiteAuthenticator(database); err == nil { + t.Fatal("NewSQLiteAuthenticator accepted an unmigrated database") + } + + _, store := newCredentialStore(t) + for _, name := range []string{"", " leading", "trailing ", "line\nbreak", strings.Repeat("名", 129)} { + if _, err := store.Issue(context.Background(), name); !errors.Is(err, ErrInvalidCredential) { + t.Fatalf("Issue(%q) error = %v", name, err) + } + } + if _, _, err := store.Revoke(context.Background(), "not-a-uuid"); !errors.Is(err, ErrInvalidCredential) { + t.Fatalf("Revoke invalid id error = %v", err) + } + if _, _, err := store.Revoke(context.Background(), newRuntimeUUID(t)); !errors.Is(err, ErrCredentialNotFound) { + t.Fatalf("Revoke unknown id error = %v", err) + } +} + +func newCredentialStore(t *testing.T) (*sql.DB, *CredentialStore) { + t.Helper() + databaseSource := filepath.Join(t.TempDir(), "device-auth.db") + "?_busy_timeout=5000&_journal_mode=WAL" + database, err := sqlite.Open(databaseSource) + if err != nil { + t.Fatalf("open database: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := migrations.Up(context.Background(), database, deviceMigrationDirectory(t)); err != nil { + t.Fatalf("migrate database: %v", err) + } + store, err := NewCredentialStore(database) + if err != nil { + t.Fatalf("NewCredentialStore: %v", err) + } + store.now = func() time.Time { return time.Date(2026, 8, 4, 12, 0, 0, 123, time.UTC) } + return database, store +} + +func authenticatorForStore(t *testing.T, store *CredentialStore) *SQLiteAuthenticator { + t.Helper() + authenticator, err := NewSQLiteAuthenticator(store.database) + if err != nil { + t.Fatalf("NewSQLiteAuthenticator: %v", err) + } + return authenticator +} + +func credentialRequest(deviceID, authorization string) *http.Request { + request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/id/evidence", nil) + request.Header.Set(DeviceIDHeader, deviceID) + request.Header.Set(AuthorizationHeader, authorization) + return request +} + +func newRuntimeToken(t *testing.T) string { + t.Helper() + _, token := newRuntimeTokenPair(t) + return token +} + +func newRuntimeTokenPair(t *testing.T) ([]byte, string) { + t.Helper() + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + t.Fatalf("generate runtime token: %v", err) + } + return raw, hex.EncodeToString(raw) +} + +func newRuntimeUUID(t *testing.T) string { + t.Helper() + raw := make([]byte, 16) + if _, err := rand.Read(raw); err != nil { + t.Fatalf("generate runtime UUID: %v", err) + } + return formatUUIDv4(raw) +} + +func equalBytes(left, right []byte) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + +func deviceMigrationDirectory(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate migrations") + } + return filepath.Join(filepath.Dir(file), "..", "..", "migrations") +} diff --git a/admin/internal/evidence/evidence.go b/admin/internal/evidence/evidence.go index 91f08c1..66d4e70 100644 --- a/admin/internal/evidence/evidence.go +++ b/admin/internal/evidence/evidence.go @@ -5,8 +5,9 @@ import ( "context" "errors" "io" - "net/http" "time" + + "cmbuyer/admin/internal/deviceauth" ) const ( @@ -25,23 +26,6 @@ var ( ErrTooLarge = errors.New("evidence file too large") ) -// DevicePrincipal is the already-authenticated device identity used only for audit and idempotency. -type DevicePrincipal struct { - ID string -} - -// DeviceAuthenticator deliberately has no token implementation in T-204. T-301 will supply one. -type DeviceAuthenticator interface { - Authenticate(*http.Request) (DevicePrincipal, bool) -} - -// RejectAllDeviceAuthenticator keeps the production upload route fail closed until T-301 wires credentials. -type RejectAllDeviceAuthenticator struct{} - -func (RejectAllDeviceAuthenticator) Authenticate(*http.Request) (DevicePrincipal, bool) { - return DevicePrincipal{}, false -} - type UploadMetadata struct { UploadKey string TaskID string @@ -83,6 +67,6 @@ type Asset struct { type Store interface { Stage(io.Reader, string) (StagedFile, error) Discard(StagedFile) - Commit(context.Context, DevicePrincipal, UploadMetadata, StagedFile) (Asset, bool, error) + Commit(context.Context, deviceauth.Principal, UploadMetadata, StagedFile) (Asset, bool, error) Open(context.Context, string) (Asset, io.ReadSeekCloser, error) } diff --git a/admin/internal/migrations/migrations_test.go b/admin/internal/migrations/migrations_test.go index 8a59cab..af8d302 100644 --- a/admin/internal/migrations/migrations_test.go +++ b/admin/internal/migrations/migrations_test.go @@ -26,19 +26,27 @@ func TestUpDownAndIdempotence(t *testing.T) { if err := migrations.Up(context, database, directory); err != nil { t.Fatalf("apply migrations: %v", err) } - assertVersion(t, database, 3) + assertVersion(t, database, 4) assertTableExists(t, database, "tasks", true) assertTableExists(t, database, "spec_trials", false) assertTableExists(t, database, "order_authorizations", true) assertTableExists(t, database, "purchase_attempts", true) assertTableExists(t, database, "order_submissions", true) assertTableExists(t, database, "evidence_assets", true) + assertTableExists(t, database, "device_credentials", 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, 4) + + if err := migrations.Down(context, database, directory); err != nil { + t.Fatalf("roll back device credential migration: %v", err) + } assertVersion(t, database, 3) + assertTableExists(t, database, "device_credentials", false) + assertTableExists(t, database, "evidence_assets", true) if err := migrations.Down(context, database, directory); err != nil { t.Fatalf("roll back evidence migration: %v", err) @@ -57,7 +65,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, 3) + assertVersion(t, database, 4) } func TestUpgradePreservesManualDraftLosslessly(t *testing.T) { @@ -76,7 +84,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, 3) + assertVersion(t, database, 4) var got struct { id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string quantity, version int @@ -263,6 +271,9 @@ 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") } @@ -274,6 +285,75 @@ 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) + } + deviceID := "13c9f507-7473-4fa6-8d71-8786c34c6301" + hash := make([]byte, 32) + for index := range hash { + hash[index] = byte(index + 1) + } + insert := `INSERT INTO device_credentials (device_id, display_name, token_sha256, status, created_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?)` + valid := []any{deviceID, "采购工具一号", hash, "ACTIVE", migrationTime, nil} + if _, err := database.Exec(insert, valid...); err != nil { + t.Fatalf("insert valid credential: %v", err) + } + for name, mutate := range map[string]func([]any){ + "uppercase uuid": func(values []any) { values[0], values[2] = strings.ToUpper(deviceID), append([]byte(nil), hash...) }, + "wrong uuid version": func(values []any) { + values[0], values[2] = "23c9f507-7473-3fa6-8d71-8786c34c6301", append([]byte(nil), hash...) + }, + "blank display name": func(values []any) { + values[0], values[1], values[2] = "33c9f507-7473-4fa6-8d71-8786c34c6301", "", append([]byte(nil), hash...) + }, + "padded display name": func(values []any) { + values[0], values[1], values[2] = "43c9f507-7473-4fa6-8d71-8786c34c6301", " padded", append([]byte(nil), hash...) + }, + "text hash": func(values []any) { + values[0], values[2] = "53c9f507-7473-4fa6-8d71-8786c34c6301", strings.Repeat("a", 32) + }, + "short blob hash": func(values []any) { values[0], values[2] = "63c9f507-7473-4fa6-8d71-8786c34c6301", make([]byte, 31) }, + "unknown status": func(values []any) { + values[0], values[2], values[3] = "73c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), "UNKNOWN" + }, + "active with revoke time": func(values []any) { + values[0], values[2], values[5] = "83c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), migrationTime + }, + "revoked without time": func(values []any) { + values[0], values[2], values[3] = "93c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), "REVOKED" + }, + "revoke before creation": func(values []any) { + values[0], values[2], values[3], values[4], values[5] = "b3c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), "REVOKED", "2026-08-04T01:00:00Z", "2026-08-04T00:00:00Z" + }, + "non UTC created time": func(values []any) { + values[0], values[2], values[4] = "a3c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), "2026-08-04T08:00:00+08:00" + }, + } { + t.Run(name, func(t *testing.T) { + values := append([]any(nil), valid...) + mutate(values) + if bytesValue, ok := values[2].([]byte); ok && len(bytesValue) == 32 { + bytesValue[0]++ + } + if _, err := database.Exec(insert, values...); err == nil { + t.Fatal("invalid device credential row succeeded") + } + }) + } + + if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil { + t.Fatal("credential-bearing schema downgraded successfully") + } + assertVersion(t, database, 4) + assertTableExists(t, database, "device_credentials", true) + var count int + if err := database.QueryRow(`SELECT COUNT(*) FROM device_credentials`).Scan(&count); err != nil || count != 1 { + t.Fatalf("credentials after rejected downgrade = %d, err=%v", count, err) + } +} + func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) { tests := []struct { name string @@ -456,7 +536,7 @@ func assertColumnType(t *testing.T, database *sql.DB, table, column, want string } func TestMigrationsDoNotDisableForeignKeys(t *testing.T) { - for _, name := range []string{"00002_single_pass_model.sql", "00003_evidence_assets.sql"} { + for _, name := range []string{"00002_single_pass_model.sql", "00003_evidence_assets.sql", "00004_device_credentials.sql"} { contents, err := os.ReadFile(filepath.Join(migrationDirectory(t), name)) if err != nil { t.Fatalf("read %s: %v", name, err) diff --git a/admin/internal/server/evidence.go b/admin/internal/server/evidence.go index 84c59ef..638a839 100644 --- a/admin/internal/server/evidence.go +++ b/admin/internal/server/evidence.go @@ -11,6 +11,7 @@ import ( "time" "unicode/utf8" + "cmbuyer/admin/internal/deviceauth" "cmbuyer/admin/internal/evidence" "github.com/gin-gonic/gin" @@ -29,11 +30,22 @@ func uploadEvidence(options Options) gin.HandlerFunc { return func(context *gin.Context) { // Authentication deliberately precedes content-type parsing and every body read. A rejected // device must not make the service spool or inspect a potentially sensitive upload. - principal, authenticated := options.DeviceAuthenticator.Authenticate(context.Request) - if !authenticated { + principal, err := options.DeviceAuthenticator.Authenticate(context.Request) + if errors.Is(err, deviceauth.ErrUnauthenticated) { + context.Header("WWW-Authenticate", "Bearer") context.Status(http.StatusUnauthorized) return } + if err != nil { + context.Status(http.StatusServiceUnavailable) + return + } + if !deviceauth.ValidDeviceID(principal.ID) { + // A custom authenticator is still an untrusted boundary. Do not defer principal + // validation until Commit because multipart bytes would already have been read. + context.Status(http.StatusServiceUnavailable) + return + } boundary, ok := multipartBoundary(context.GetHeader("Content-Type")) if !ok { diff --git a/admin/internal/server/evidence_test.go b/admin/internal/server/evidence_test.go index 8a0cd90..87532c7 100644 --- a/admin/internal/server/evidence_test.go +++ b/admin/internal/server/evidence_test.go @@ -19,6 +19,7 @@ import ( "strings" "testing" + "cmbuyer/admin/internal/deviceauth" "cmbuyer/admin/internal/evidence" "cmbuyer/admin/internal/migrations" evidencestorage "cmbuyer/admin/internal/storage/evidence" @@ -30,6 +31,7 @@ const ( evidenceAuthID = "73c9f507-7473-4fa6-8d71-8786c34c6301" evidenceAttemptID = "83c9f507-7473-4fa6-8d71-8786c34c6301" evidenceUploadKey = "93c9f507-7473-4fa6-8d71-8786c34c6301" + evidenceDeviceID = "13c9f507-7473-4fa6-8d71-8786c34c6301" ) func TestEvidenceUploadAuthenticatesBeforeReadingBody(t *testing.T) { @@ -43,12 +45,60 @@ func TestEvidenceUploadAuthenticatesBeforeReadingBody(t *testing.T) { router.ServeHTTP(response, request) - if response.Code != http.StatusUnauthorized || poison.reads != 0 || authenticator.calls != 1 { + if response.Code != http.StatusUnauthorized || response.Body.Len() != 0 || response.Header().Get("WWW-Authenticate") != "Bearer" || poison.reads != 0 || authenticator.calls != 1 { t.Fatalf("status/reads/auth calls = %d/%d/%d, want 401/0/1", response.Code, poison.reads, authenticator.calls) } assertSecurityHeaders(t, response) } +func TestEvidenceUploadAuthenticationStorageFailureBeforeReadingBody(t *testing.T) { + database, err := sqlite.Open(filepath.Join(t.TempDir(), "authentication-failure.db")) + if err != nil { + t.Fatalf("open database: %v", err) + } + if err := migrations.Up(context.Background(), database, testMigrationDirectory(t)); err != nil { + t.Fatalf("migrate database: %v", err) + } + authenticator, err := deviceauth.NewSQLiteAuthenticator(database) + if err != nil { + t.Fatalf("new authenticator: %v", err) + } + credentialStore, err := deviceauth.NewCredentialStore(database) + if err != nil { + t.Fatalf("new credential store: %v", err) + } + issued, err := credentialStore.Issue(context.Background(), "test device") + if err != nil { + t.Fatalf("issue credential: %v", err) + } + if err := database.Close(); err != nil { + t.Fatalf("close database: %v", err) + } + router, _ := newRouterWithDependencies(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator) + poison := &poisonBody{} + request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+evidenceTaskID+"/evidence", nil) + request.Body = poison + request.Header.Set(deviceauth.AuthorizationHeader, "Bearer "+issued.Token) + request.Header.Set(deviceauth.DeviceIDHeader, issued.DeviceID) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusServiceUnavailable || response.Body.Len() != 0 || poison.reads != 0 { + t.Fatalf("storage failure status/body/reads = %d/%q/%d, want 503/empty/0", response.Code, response.Body.String(), poison.reads) + } +} + +func TestEvidenceUploadRejectsInvalidSuccessfulPrincipalBeforeReadingBody(t *testing.T) { + router, _ := newRouterWithDependencies(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, uncheckedDeviceAuthenticator{}) + poison := &poisonBody{} + request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+evidenceTaskID+"/evidence", nil) + request.Body = poison + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusServiceUnavailable || response.Body.Len() != 0 || poison.reads != 0 { + t.Fatalf("invalid principal status/body/reads = %d/%q/%d, want 503/empty/0", response.Code, response.Body.String(), poison.reads) + } +} + func TestAdminSessionCannotActAsDeviceUploader(t *testing.T) { router, _ := newRouter(t) cookie := authenticate(t, router) @@ -62,8 +112,96 @@ func TestAdminSessionCannotActAsDeviceUploader(t *testing.T) { } } +func TestRealDeviceCredentialIdentityIsolationAndMixedCredentials(t *testing.T) { + database, err := sqlite.Open(filepath.Join(t.TempDir(), "identity-isolation.db")) + if err != nil { + t.Fatalf("open database: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := migrations.Up(context.Background(), database, testMigrationDirectory(t)); err != nil { + t.Fatalf("migrate database: %v", err) + } + insertEvidenceAttempt(t, database) + assetStore, err := evidencestorage.NewStore(database, filepath.Join(t.TempDir(), "assets")) + if err != nil { + t.Fatalf("new evidence store: %v", err) + } + credentialStore, err := deviceauth.NewCredentialStore(database) + if err != nil { + t.Fatalf("new credential store: %v", err) + } + issued, err := credentialStore.Issue(context.Background(), "采购工具一号") + if err != nil { + t.Fatalf("issue credential: %v", err) + } + authenticator, err := deviceauth.NewSQLiteAuthenticator(database) + if err != nil { + t.Fatalf("new authenticator: %v", err) + } + taskStore := &memoryStore{} + router, _ := newRouterWithDependencies(t, taskStore, emptyDetailStore{}, assetStore, authenticator) + addDeviceHeaders := func(request *http.Request) { + request.Header.Set(deviceauth.AuthorizationHeader, "Bearer "+issued.Token) + request.Header.Set(deviceauth.DeviceIDHeader, issued.DeviceID) + } + + start := newStartRequest(t, validStartBody(), "application/json", "", nil) + addDeviceHeaders(start) + startResponse := httptest.NewRecorder() + router.ServeHTTP(startResponse, start) + create := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader("title=device")) + create.Header.Set("Content-Type", "application/x-www-form-urlencoded") + addDeviceHeaders(create) + createResponse := httptest.NewRecorder() + router.ServeHTTP(createResponse, create) + if startResponse.Code != http.StatusUnauthorized || createResponse.Code != http.StatusUnauthorized || taskStore.startCalls != 0 || len(taskStore.drafts) != 0 { + t.Fatalf("device management isolation = start %d/create %d/calls %d/drafts %d", startResponse.Code, createResponse.Code, taskStore.startCalls, len(taskStore.drafts)) + } + + adminCookie, csrf := authenticatedStartSession(t, router) + mixedWithoutCSRF := newStartRequest(t, validStartBody(), "application/json", "", adminCookie) + addDeviceHeaders(mixedWithoutCSRF) + mixedWithoutCSRFResponse := httptest.NewRecorder() + router.ServeHTTP(mixedWithoutCSRFResponse, mixedWithoutCSRF) + if mixedWithoutCSRFResponse.Code != http.StatusForbidden || taskStore.startCalls != 0 { + t.Fatalf("mixed request bypassed admin CSRF: status/calls=%d/%d", mixedWithoutCSRFResponse.Code, taskStore.startCalls) + } + mixedAdmin := newStartRequest(t, validStartBody(), "application/json", csrf, adminCookie) + addDeviceHeaders(mixedAdmin) + mixedAdminResponse := httptest.NewRecorder() + router.ServeHTTP(mixedAdminResponse, mixedAdmin) + if mixedAdminResponse.Code != http.StatusBadRequest || taskStore.startCalls != 1 { + t.Fatalf("mixed admin request changed identity domain: status/calls=%d/%d", mixedAdminResponse.Code, taskStore.startCalls) + } + + pngBytes := serverTestPNG(t, 3, 2) + upload := newEvidenceUploadRequest(t, evidenceTaskID, validEvidenceFields(pngBytes), pngBytes, evidence.PNGContentType, "raw.png", nil) + addDeviceHeaders(upload) + upload.AddCookie(adminCookie) + uploadResponse := httptest.NewRecorder() + router.ServeHTTP(uploadResponse, upload) + if uploadResponse.Code != http.StatusCreated { + t.Fatalf("mixed upload status/body = %d/%q", uploadResponse.Code, uploadResponse.Body.String()) + } + var uploadedBy string + if err := database.QueryRow(`SELECT uploaded_by_device_id FROM evidence_assets`).Scan(&uploadedBy); err != nil || uploadedBy != issued.DeviceID { + t.Fatalf("uploaded principal = %q, err=%v", uploadedBy, err) + } + + if _, _, err := credentialStore.Revoke(context.Background(), issued.DeviceID); err != nil { + t.Fatalf("revoke credential: %v", err) + } + revokedUpload := newEvidenceUploadRequest(t, evidenceTaskID, validEvidenceFields(pngBytes), pngBytes, evidence.PNGContentType, "raw.png", nil) + addDeviceHeaders(revokedUpload) + revokedResponse := httptest.NewRecorder() + router.ServeHTTP(revokedResponse, revokedUpload) + if revokedResponse.Code != http.StatusUnauthorized || revokedResponse.Body.Len() != 0 { + t.Fatalf("revoked upload = %d/%q", revokedResponse.Code, revokedResponse.Body.String()) + } +} + func TestEvidenceUploadReplayConflictAndProtectedRead(t *testing.T) { - router, database := newEvidenceRouter(t, &fakeDeviceAuthenticator{allowed: true, principal: evidence.DevicePrincipal{ID: "device-one"}}) + router, database := newEvidenceRouter(t, &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: evidenceDeviceID}}) pngBytes := serverTestPNG(t, 6, 4) fields := validEvidenceFields(pngBytes) @@ -118,7 +256,7 @@ func TestEvidenceUploadReplayConflictAndProtectedRead(t *testing.T) { } func TestEvidenceUploadRejectsStrictMultipartViolations(t *testing.T) { - router, database := newEvidenceRouter(t, &fakeDeviceAuthenticator{allowed: true, principal: evidence.DevicePrincipal{ID: "device-one"}}) + router, database := newEvidenceRouter(t, &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: evidenceDeviceID}}) pngBytes := serverTestPNG(t, 2, 2) base := validEvidenceFields(pngBytes) wrongHash := copyStringMap(base) @@ -174,36 +312,44 @@ func TestEvidenceUploadRejectsStrictMultipartViolations(t *testing.T) { } type fakeDeviceAuthenticator struct { - allowed bool - principal evidence.DevicePrincipal + principal deviceauth.Principal + err error calls int } -func (authenticator *fakeDeviceAuthenticator) Authenticate(*http.Request) (evidence.DevicePrincipal, bool) { +func (authenticator *fakeDeviceAuthenticator) Authenticate(*http.Request) (deviceauth.Principal, error) { authenticator.calls++ - return authenticator.principal, authenticator.allowed + if authenticator.err != nil { + return deviceauth.Principal{}, authenticator.err + } + if authenticator.principal.ID == "" { + return deviceauth.Principal{}, deviceauth.ErrUnauthenticated + } + return authenticator.principal, nil } type poisonBody struct{ reads int } +type uncheckedDeviceAuthenticator struct{} + +func (uncheckedDeviceAuthenticator) Authenticate(*http.Request) (deviceauth.Principal, error) { + return deviceauth.Principal{}, nil +} + func (body *poisonBody) Read([]byte) (int, error) { body.reads++ return 0, io.ErrUnexpectedEOF } func (*poisonBody) Close() error { return nil } -func newEvidenceRouter(t *testing.T, authenticator evidence.DeviceAuthenticator) (http.Handler, *sql.DB) { +func newEvidenceRouter(t *testing.T, authenticator deviceauth.Authenticator) (http.Handler, *sql.DB) { t.Helper() database, err := sqlite.Open(filepath.Join(t.TempDir(), "server-evidence.db")) if err != nil { t.Fatalf("open database: %v", err) } t.Cleanup(func() { _ = database.Close() }) - _, file, _, ok := runtime.Caller(0) - if !ok { - t.Fatal("locate migration directory") - } - if err := migrations.Up(context.Background(), database, filepath.Join(filepath.Dir(file), "..", "..", "migrations")); err != nil { + if err := migrations.Up(context.Background(), database, testMigrationDirectory(t)); err != nil { t.Fatalf("migrate database: %v", err) } insertEvidenceAttempt(t, database) @@ -215,6 +361,15 @@ func newEvidenceRouter(t *testing.T, authenticator evidence.DeviceAuthenticator) return router, database } +func testMigrationDirectory(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate migration directory") + } + return filepath.Join(filepath.Dir(file), "..", "..", "migrations") +} + func insertEvidenceAttempt(t *testing.T, database *sql.DB) { t.Helper() timestamp := "2026-08-04T00:00:00Z" @@ -230,6 +385,14 @@ func insertEvidenceAttempt(t *testing.T, database *sql.DB) { } 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) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + return response +} + +func newEvidenceUploadRequest(t *testing.T, taskID string, fields map[string]string, file []byte, fileContentType, filename string, extra func(*multipart.Writer) error) *http.Request { t.Helper() var body bytes.Buffer writer := multipart.NewWriter(&body) @@ -258,9 +421,7 @@ func serveEvidenceUpload(t *testing.T, router http.Handler, taskID string, field } request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+taskID+"/evidence", bytes.NewReader(body.Bytes())) request.Header.Set("Content-Type", writer.FormDataContentType()) - response := httptest.NewRecorder() - router.ServeHTTP(response, request) - return response + return request } func validEvidenceFields(pngBytes []byte) map[string]string { diff --git a/admin/internal/server/router.go b/admin/internal/server/router.go index cad9468..7007624 100644 --- a/admin/internal/server/router.go +++ b/admin/internal/server/router.go @@ -14,6 +14,7 @@ import ( "unicode/utf8" "cmbuyer/admin/internal/auth" + "cmbuyer/admin/internal/deviceauth" "cmbuyer/admin/internal/evidence" "cmbuyer/admin/internal/taskdetail" "cmbuyer/admin/internal/tasks" @@ -34,7 +35,7 @@ type Options struct { Tasks tasks.Store TaskDetails taskdetail.Store Evidence evidence.Store - DeviceAuthenticator evidence.DeviceAuthenticator + DeviceAuthenticator deviceauth.Authenticator } // NewRouter 返回当前服务范围内的完整 HTTP 路由。 @@ -266,6 +267,10 @@ func newTaskPage(options Options) gin.HandlerFunc { } func createTask(options Options) gin.HandlerFunc { return func(context *gin.Context) { + if !options.Sessions.IsAuthenticated(context.Request) { + context.Status(http.StatusUnauthorized) + return + } if !parseForm(context) { return } diff --git a/admin/internal/server/router_test.go b/admin/internal/server/router_test.go index 3ede303..b0ba53c 100644 --- a/admin/internal/server/router_test.go +++ b/admin/internal/server/router_test.go @@ -12,6 +12,7 @@ import ( "time" "cmbuyer/admin/internal/auth" + "cmbuyer/admin/internal/deviceauth" "cmbuyer/admin/internal/evidence" "cmbuyer/admin/internal/server" "cmbuyer/admin/internal/taskdetail" @@ -389,8 +390,8 @@ func TestTasksPageRerendersAccessibleFilterErrorsAndKeepsValues(t *testing.T) { func TestTaskCreationRequiresAuthenticationAndCSRF(t *testing.T) { router, _ := newRouter(t) - if response := serve(router, http.MethodPost, "/tasks", url.Values{}, nil); response.Code != http.StatusForbidden { - t.Fatalf("anonymous POST /tasks = %d, want 403", response.Code) + if response := serve(router, http.MethodPost, "/tasks", url.Values{}, nil); response.Code != http.StatusUnauthorized { + t.Fatalf("anonymous POST /tasks = %d, want 401", response.Code) } cookie := authenticate(t, router) if response := serve(router, http.MethodPost, "/tasks", url.Values{}, cookie); response.Code != http.StatusForbidden { @@ -488,10 +489,10 @@ func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) { } func newRouterWithStore(t *testing.T, store tasks.Store) (*gin.Engine, *auth.Manager) { - return newRouterWithDependencies(t, store, emptyDetailStore{}, emptyEvidenceStore{}, evidence.RejectAllDeviceAuthenticator{}) + return newRouterWithDependencies(t, store, emptyDetailStore{}, emptyEvidenceStore{}, deviceauth.RejectAllAuthenticator{}) } -func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator evidence.DeviceAuthenticator) (*gin.Engine, *auth.Manager) { +func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator deviceauth.Authenticator) (*gin.Engine, *auth.Manager) { t.Helper() gin.SetMode(gin.TestMode) hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost) @@ -526,7 +527,7 @@ func (emptyEvidenceStore) Stage(io.Reader, string) (evidence.StagedFile, error) return evidence.StagedFile{}, evidence.ErrInvalid } func (emptyEvidenceStore) Discard(evidence.StagedFile) {} -func (emptyEvidenceStore) Commit(context.Context, evidence.DevicePrincipal, evidence.UploadMetadata, evidence.StagedFile) (evidence.Asset, bool, error) { +func (emptyEvidenceStore) Commit(context.Context, deviceauth.Principal, evidence.UploadMetadata, evidence.StagedFile) (evidence.Asset, bool, error) { return evidence.Asset{}, false, evidence.ErrInvalid } func (emptyEvidenceStore) Open(context.Context, string) (evidence.Asset, io.ReadSeekCloser, error) { @@ -538,6 +539,7 @@ type memoryStore struct { rows []tasks.TaskRow listDraftsCalls int listTasksCalls int + startCalls int } func (store *memoryStore) CreateDraft(_ context.Context, draft tasks.Draft) (tasks.Draft, error) { @@ -568,6 +570,7 @@ func (store *memoryStore) ListTasks(_ context.Context, _ tasks.TaskFilter) ([]ta return result, nil } func (store *memoryStore) StartPurchases(_ context.Context, _ tasks.StartCommand, _ string) (tasks.StartResult, error) { + store.startCalls++ return tasks.StartResult{}, tasks.ErrInvalidStart } diff --git a/admin/internal/server/task_detail_test.go b/admin/internal/server/task_detail_test.go index b250ce8..f3b3bc6 100644 --- a/admin/internal/server/task_detail_test.go +++ b/admin/internal/server/task_detail_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "cmbuyer/admin/internal/evidence" + "cmbuyer/admin/internal/deviceauth" "cmbuyer/admin/internal/taskdetail" ) @@ -16,7 +16,7 @@ const detailTaskID = "a3c9f507-7473-4fa6-8d71-8786c34c6301" func TestTaskDetailRequiresAdminBeforeLookup(t *testing.T) { details := &recordingDetailStore{detail: taskDetailFixture()} - router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, evidence.RejectAllDeviceAuthenticator{}) + router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, deviceauth.RejectAllAuthenticator{}) response := serve(router, http.MethodGet, "/tasks/"+detailTaskID, nil, nil) if response.Code != http.StatusSeeOther || !strings.HasPrefix(response.Header().Get("Location"), "/login?return_to=") || details.calls != 0 { t.Fatalf("anonymous detail = %d/%q, calls=%d", response.Code, response.Header().Get("Location"), details.calls) @@ -25,7 +25,7 @@ func TestTaskDetailRequiresAdminBeforeLookup(t *testing.T) { func TestTaskDetailFullPageAndDrawerShareAuditContent(t *testing.T) { details := &recordingDetailStore{detail: taskDetailFixture()} - router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, evidence.RejectAllDeviceAuthenticator{}) + router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, deviceauth.RejectAllAuthenticator{}) cookie := authenticate(t, router) full := serve(router, http.MethodGet, "/tasks/"+detailTaskID, nil, cookie) if full.Code != http.StatusOK || !strings.Contains(full.Body.String(), "") || !strings.Contains(full.Body.String(), `data-task-detail-content`) { @@ -59,7 +59,7 @@ func TestTaskDetailFullPageAndDrawerShareAuditContent(t *testing.T) { func TestTaskDetailRejectsForgedFragmentAndMissingTask(t *testing.T) { details := &recordingDetailStore{err: taskdetail.ErrNotFound} - router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, evidence.RejectAllDeviceAuthenticator{}) + router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, deviceauth.RejectAllAuthenticator{}) cookie := authenticate(t, router) for name, headers := range map[string]map[string]string{ "unknown view": {"X-CMBuyer-View": "xml", "Accept": "text/html"}, diff --git a/admin/internal/storage/evidence/store.go b/admin/internal/storage/evidence/store.go index 0fe542d..37077dd 100644 --- a/admin/internal/storage/evidence/store.go +++ b/admin/internal/storage/evidence/store.go @@ -16,8 +16,8 @@ import ( "strings" "sync" "time" - "unicode" + "cmbuyer/admin/internal/deviceauth" core "cmbuyer/admin/internal/evidence" ) @@ -152,7 +152,7 @@ func (store *Store) Discard(staged core.StagedFile) { } } -func (store *Store) Commit(ctx context.Context, principal core.DevicePrincipal, metadata core.UploadMetadata, staged core.StagedFile) (core.Asset, bool, error) { +func (store *Store) Commit(ctx context.Context, principal deviceauth.Principal, metadata core.UploadMetadata, staged core.StagedFile) (core.Asset, bool, error) { if !store.isStagedPath(staged.Path) || !validPrincipal(principal) || !validMetadata(metadata) || metadata.SHA256 != staged.SHA256 || staged.ContentType != core.PNGContentType || staged.ByteSize < 1 || staged.ByteSize > core.MaxFileBytes || staged.Width < 1 || staged.Height < 1 || staged.Width > core.MaxImageSide || staged.Height > core.MaxImageSide || int64(staged.Width)*int64(staged.Height) > core.MaxImagePixels { store.Discard(staged) return core.Asset{}, false, core.ErrInvalid @@ -447,16 +447,8 @@ func validMetadata(metadata core.UploadMetadata) bool { return validUUID(metadata.UploadKey) && validUUID(metadata.TaskID) && validUUID(metadata.AttemptID) && metadata.Kind == core.KindSKUPanelGate1 && metadata.PrivacyTier == core.PrivacyInternalRaw && validSHA256(metadata.SHA256) && !metadata.CapturedAt.IsZero() && metadata.CapturedAt.Location() == time.UTC } -func validPrincipal(principal core.DevicePrincipal) bool { - if principal.ID == "" || strings.TrimSpace(principal.ID) != principal.ID || len(principal.ID) > 128 { - return false - } - for _, character := range principal.ID { - if unicode.IsControl(character) { - return false - } - } - return true +func validPrincipal(principal deviceauth.Principal) bool { + return deviceauth.ValidDeviceID(principal.ID) } func validSHA256(value string) bool { @@ -548,6 +540,6 @@ func scanAsset(row rowScanner) (core.Asset, bool, error) { return asset, true, nil } -func sameUpload(asset core.Asset, principal core.DevicePrincipal, metadata core.UploadMetadata, staged core.StagedFile) bool { +func sameUpload(asset core.Asset, principal deviceauth.Principal, metadata core.UploadMetadata, staged core.StagedFile) bool { return asset.TaskID == metadata.TaskID && asset.AttemptID == metadata.AttemptID && asset.Kind == metadata.Kind && asset.PrivacyTier == metadata.PrivacyTier && asset.SHA256 == metadata.SHA256 && asset.ByteSize == staged.ByteSize && asset.ContentType == staged.ContentType && asset.Width == staged.Width && asset.Height == staged.Height && asset.UploadedByDeviceID == principal.ID && asset.CapturedAt.Equal(metadata.CapturedAt) } diff --git a/admin/internal/storage/evidence/store_test.go b/admin/internal/storage/evidence/store_test.go index 3f3fb92..1d25fcc 100644 --- a/admin/internal/storage/evidence/store_test.go +++ b/admin/internal/storage/evidence/store_test.go @@ -19,6 +19,7 @@ import ( "testing" "time" + "cmbuyer/admin/internal/deviceauth" core "cmbuyer/admin/internal/evidence" "cmbuyer/admin/internal/migrations" "cmbuyer/admin/internal/storage/sqlite" @@ -29,6 +30,7 @@ const ( testAuthID = "23c9f507-7473-4fa6-8d71-8786c34c6301" testAttemptID = "33c9f507-7473-4fa6-8d71-8786c34c6301" testUploadKey = "43c9f507-7473-4fa6-8d71-8786c34c6301" + testDeviceID = "53c9f507-7473-4fa6-8d71-8786c34c6301" ) func TestStageCommitReplayAndOpen(t *testing.T) { @@ -37,7 +39,7 @@ func TestStageCommitReplayAndOpen(t *testing.T) { pngBytes := makePNG(t, 8, 6) hash := sha256Hex(pngBytes) metadata := core.UploadMetadata{UploadKey: testUploadKey, TaskID: testTaskID, AttemptID: testAttemptID, Kind: core.KindSKUPanelGate1, PrivacyTier: core.PrivacyInternalRaw, SHA256: hash, CapturedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)} - principal := core.DevicePrincipal{ID: "device-one"} + principal := deviceauth.Principal{ID: testDeviceID} staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType) if err != nil { @@ -101,7 +103,7 @@ func TestConcurrentReplayCreatesOneAsset(t *testing.T) { for index := range staged { go func(index int) { defer wait.Done() - assets[index], replays[index], errorsSeen[index] = store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, metadata, staged[index]) + assets[index], replays[index], errorsSeen[index] = store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, metadata, staged[index]) }(index) } wait.Wait() @@ -206,7 +208,7 @@ func TestCommitSyncsShardAndRenameBeforeDatabaseWrite(t *testing.T) { return os.Rename(oldPath, newPath) } - if _, replayed, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, metadata, staged); err != nil || replayed { + if _, replayed, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, metadata, staged); err != nil || replayed { t.Fatalf("Commit = replayed %t, err %v", replayed, err) } if got, want := strings.Join(events, ","), "sync-root,sync-root,sync-file,rename,sync-shard"; got != want { @@ -241,7 +243,7 @@ func TestCommitDirectorySyncFailuresNeverWriteDatabase(t *testing.T) { return syncDirectory(path) } - if _, _, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, testMetadata(hash), staged); !errors.Is(err, injected) { + if _, _, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, testMetadata(hash), staged); !errors.Is(err, injected) { t.Fatalf("Commit error = %v, want injected sync failure", err) } if calls != failAt { @@ -275,7 +277,7 @@ func TestCommitRetriesShardParentSyncAfterPriorFailure(t *testing.T) { } injected := errors.New("injected first shard parent sync failure") store.syncDirectory = func(string) error { return injected } - if _, _, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, testMetadata(hash), firstStage); !errors.Is(err, injected) { + if _, _, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, testMetadata(hash), firstStage); !errors.Is(err, injected) { t.Fatalf("first Commit error = %v", err) } if info, err := os.Stat(filepath.Dir(finalPath)); err != nil || !info.IsDir() { @@ -292,7 +294,7 @@ func TestCommitRetriesShardParentSyncAfterPriorFailure(t *testing.T) { paths = append(paths, path) return syncDirectory(path) } - if _, replayed, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, testMetadata(hash), secondStage); err != nil || replayed { + if _, replayed, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, testMetadata(hash), secondStage); err != nil || replayed { t.Fatalf("retry Commit = replayed %t, err %v", replayed, err) } if len(paths) != 2 || paths[0] != store.root || paths[1] != filepath.Dir(finalPath) { @@ -323,7 +325,7 @@ func TestCommitPublicationFailuresCleanTempAndNeverWriteDatabase(t *testing.T) { } else { store.renameFile = func(string, string) error { return injected } } - if _, _, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, testMetadata(hash), staged); !errors.Is(err, injected) { + if _, _, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, testMetadata(hash), staged); !errors.Is(err, injected) { t.Fatalf("Commit error = %v, want injected publication failure", err) } if _, err := os.Stat(finalPath); !errors.Is(err, os.ErrNotExist) { @@ -377,7 +379,7 @@ func TestCommitDatabaseFailuresAfterDurableRenameLeaveOnlyOrphan(t *testing.T) { return syncDirectory(path) } - if _, _, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, testMetadata(hash), staged); err == nil { + if _, _, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, testMetadata(hash), staged); err == nil { t.Fatal("Commit unexpectedly succeeded") } if syncCalls != 3 { @@ -436,7 +438,7 @@ func TestCommitRequiresAttemptOwnedByTaskAndLowercaseHash(t *testing.T) { } metadata := base mutate(&metadata) - if _, _, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, metadata, staged); !errors.Is(err, core.ErrInvalid) { + if _, _, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, metadata, staged); !errors.Is(err, core.ErrInvalid) { t.Fatalf("Commit error = %v", err) } }) diff --git a/admin/migrations/00004_device_credentials.sql b/admin/migrations/00004_device_credentials.sql new file mode 100644 index 0000000..7b9d495 --- /dev/null +++ b/admin/migrations/00004_device_credentials.sql @@ -0,0 +1,62 @@ +-- +goose Up +CREATE TABLE device_credentials ( + device_id TEXT PRIMARY KEY CHECK ( + length(device_id) = 36 + AND substr(device_id, 9, 1) = '-' + AND substr(device_id, 14, 1) = '-' + AND substr(device_id, 19, 1) = '-' + AND substr(device_id, 24, 1) = '-' + AND length(replace(device_id, '-', '')) = 32 + AND replace(device_id, '-', '') NOT GLOB '*[^0-9a-f]*' + AND substr(device_id, 15, 1) = '4' + AND substr(device_id, 20, 1) IN ('8', '9', 'a', 'b') + ), + display_name TEXT NOT NULL CHECK ( + display_name = trim(display_name) + AND length(display_name) BETWEEN 1 AND 128 + ), + token_sha256 BLOB NOT NULL UNIQUE CHECK ( + typeof(token_sha256) = 'blob' + AND length(token_sha256) = 32 + ), + status TEXT NOT NULL CHECK (status IN ('ACTIVE', 'REVOKED')), + 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 + ), + revoked_at TEXT CHECK ( + revoked_at IS NULL OR ( + revoked_at = trim(revoked_at) + AND length(revoked_at) >= 20 + AND substr(revoked_at, 11, 1) = 'T' + AND substr(revoked_at, -1, 1) = 'Z' + AND julianday(revoked_at) IS NOT NULL + ) + ), + CHECK ( + (status = 'ACTIVE' AND revoked_at IS NULL) + OR ( + status = 'REVOKED' + AND revoked_at IS NOT NULL + AND julianday(revoked_at) >= julianday(created_at) + ) + ) +); + +CREATE INDEX device_credentials_status_created_idx +ON device_credentials (status, created_at, device_id); + +-- +goose Down +-- 已签发凭据是安全配置;回滚不得静默删除并让设备身份审计链消失。 +CREATE TABLE device_credentials_downgrade_guard ( + valid INTEGER NOT NULL CHECK (valid = 1) +); + +INSERT INTO device_credentials_downgrade_guard (valid) +SELECT CASE WHEN (SELECT COUNT(*) FROM device_credentials) = 0 THEN 1 ELSE 0 END; + +DROP TABLE device_credentials_downgrade_guard; +DROP TABLE device_credentials; diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 28beaa1..8225c3c 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -16,8 +16,8 @@ │ · 提交围栏、结果调和、内部证据与审计 │ │ · 服务端渲染管理页面 │ └───────────────────┬─────────────────────┘ - │ HTTPS / JSON - │ Bearer + 设备绑定 + │ 本机回环 HTTP / JSON(MVP) + │ Bearer + 设备绑定;非回环前必须先上 TLS v ┌─────────────────────────────────────────┐ │ 采购工具(client/,Python + PySide6) │ @@ -141,6 +141,8 @@ T-103 真机证据表明:拼多多 `8.17.0`、goods_id `937122477375` 通过 | 外部支付页 | 检测到外部支付交接立即停止,不读取、保存或输入凭据 | 凭据泄露 | | 安全校验 | 验证码、风控、人脸、短信出现即停止,不绕过 | 封号 / 违规 | | 内部截图 | 可上传页面已显示的地址/手机号;不解析成字段或日志,完整 XML 不上传 | 非必要扩散 | +| 身份隔离 | 管理 session+CSRF 与设备 Bearer 分属不同路由域,混合凭据不叠加权限 | 设备越权 / 会话冒充 | +| Bearer 传输 | MVP 仅绑定 IPv4 回环 `127.0.0.1:8080`;非回环访问先建立 HTTPS/TLS 终止 | 明文局域网泄露 token | | 授权一次性 | 一条任务版本只有一份有效授权;幂等重放不生成第二份 | 重复采购 | | 服务端提交围栏 | 点击前原子创建唯一提交记录;失败或响应不明不得点击 | 并发 / 断网重复下单 | | App 版本绑定 | 运行版本不同于证据版本时停止并重新取证 | 旧判据误点 | @@ -247,6 +249,18 @@ CREATE TABLE order_submissions ( UNIQUE (attempt_id) ); +-- 设备 token 明文只在签发完成后显示一次;数据库仅保存原始 32 字节 token 的 SHA-256 +CREATE TABLE device_credentials ( + device_id TEXT PRIMARY KEY, -- 规范小写 UUIDv4 + display_name TEXT NOT NULL, -- 非秘密运维名称 + token_sha256 BLOB NOT NULL UNIQUE, -- 恰好 32 字节 + status TEXT NOT NULL, -- ACTIVE | REVOKED + created_at TEXT NOT NULL, + revoked_at TEXT, + CHECK ((status = 'ACTIVE' AND revoked_at IS NULL) + OR (status = 'REVOKED' AND revoked_at IS NOT NULL AND revoked_at >= created_at)) +); + -- INTERNAL_RAW 原始截图;原文件名和客户端路径不进入数据库 CREATE TABLE evidence_assets ( id TEXT PRIMARY KEY, @@ -273,6 +287,12 @@ MVP 不再用 `spec_trials` 作为审批记录,也不存在 `authorized_unit_p `purchase_attempts` / `order_submissions` 的执行与审计事实;管理员授权的资金边界始终是 `total_price_cap`。 +设备凭据由本机管理 CLI 签发、列出和撤销。token 是 32 字节加密随机值,以 64 位小写十六进制 +只显示一次;服务端把 token 解码回原始字节后计算 SHA-256,并与按设备 id 查出的 32 字节 BLOB +恒定时间比较。未知 id 也执行固定宽度 dummy compare。认证逐请求查库,因此撤销事务提交后才开始的 +请求全部拒绝;提交前已经完成认证的在途请求不追溯取消。格式/未知/错配/撤销统一空 401,存储故障 +空 503,两类都在读取业务请求体前失败闭合。 + ### 5.2 状态机 ```text @@ -331,8 +351,8 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED - 单个 PNG 最大 10 MiB、单边最大 8192 px、总像素最大 16,777,216;同时验证 multipart MIME、 PNG 魔数、完整解码、字节数、尺寸和调用方声明的 SHA-256。 -- 上传 handler 必须先通过设备认证,再解析 Content-Type 或读取 body。T-301 前生产认证器固定拒绝, - 不创建临时 token,也不把管理员 session 当设备身份。 +- 上传 handler 必须先通过逐请求 SQLite 设备认证并再次校验规范 principal,再解析 Content-Type 或 + 读取 body。空库、无效或已撤销凭据拒绝,认证存储故障返回 503;不把管理员 session 当设备身份。 - 文件写入显式配置的私有证据根目录:同目录随机临时文件 → 流式 hash → 校验 → `fsync` → 原子 rename 到 SHA-256 内容地址 → 最后事务写数据库。数据库永远不指向半文件或缺失文件。 - SQLite 与文件系统不能组成跨资源事务;极端故障最多留下不可达孤儿文件。不得为清理孤儿而删除 diff --git a/docs/api.md b/docs/api.md index 16ab7aa..e990c4f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -16,11 +16,29 @@ | 身份 | 凭据 | 能力 | | --- | --- | --- | -| 管理员 | `HttpOnly; Secure; SameSite=Lax` 会话 cookie + CSRF | 建单、开始采购、查看内部证据、人工调和 | -| 设备 | `Authorization: Bearer ` + 设备 id | 心跳、领取、事件、截图、围栏与结果 | +| 管理员 | `HttpOnly; SameSite=Lax` 会话 cookie + CSRF;HTTPS 部署设 `Secure=true` | 建单、开始采购、查看内部证据、人工调和 | +| 设备 | `Authorization: Bearer ` + `X-CMBuyer-Device-ID` | 心跳、领取、事件、截图、围栏与结果 | | ERP(V2) | 独立凭据 | 只读来源同步,不访问采购结果 | -设备凭据不能建单或开始采购;管理会话不能调用设备接口。未认证统一返回 `401`,无权返回 `403`。 +设备凭据不能建单或开始采购;管理会话不能调用设备接口。凭据缺失或无效返回 `401`,已认证但无权 +或管理写请求缺少有效 CSRF 返回 `403`;认证存储故障按下述规则返回 `503`。 + +设备请求的认证头采用以下固定格式: + +- `Authorization` 和 `X-CMBuyer-Device-ID` 必须各出现且只出现一次;代理合并出的逗号列表也拒绝。 +- Authorization scheme 按 HTTP 规则大小写不敏感,但 scheme 后只允许一个 ASCII 空格;token 必须是 + 加密随机生成的 32 字节值对应的 64 位小写十六进制文本。 +- 设备 id 必须是规范小写 UUIDv4。token 与设备 id 同时绑定,未知、错配、格式错误和已撤销均返回 + 空 `401`,可带 `WWW-Authenticate: Bearer`,不区分具体原因。 +- 认证器逐请求读取 SQLite,不缓存 ACTIVE 结论。SQLite 查询或连接故障返回空 `503`;401 与 503 + 都必须发生在 Content-Type 解析和 body 读取之前。 +- 管理 cookie 不替代设备凭据;Bearer 也不替代管理 session/CSRF。两类凭据同时出现时,各路由仍只 + 采用自己的身份域,不把权限相加。 + +设备 token 由本机管理 CLI 签发,只在签发事务提交后向操作者显示一次;SQLite 仅保存 token 原始 +32 字节的 SHA-256(32 字节 BLOB),list/revoke、日志、错误和 HTTP 响应均不显示 token 或 hash。 +撤销幂等且不会恢复旧 token。MVP 服务只绑定 IPv4 回环 `127.0.0.1:8080`,设备 Bearer 只经过本机回环 HTTP; +未来非回环访问必须先建立 HTTPS/TLS 终止与代理信任边界。 ### 错误响应 @@ -220,8 +238,8 @@ - 同一设备主体和 `upload_key` 的同载荷重放返回原资产;任务、attempt、截图或元数据变化返回 `409`。 - 首次成功返回 `201`,幂等重放返回 `200`。响应只含资产 id、关联 id、kind/tier、hash、字节数、 MIME、宽高和采集时间,不含设备 token、原文件名或存储路径。 -- T-301 接入真实设备 Bearer 身份前,生产 `DeviceAuthenticator` 固定拒绝全部上传;不得使用管理员 - session、临时 token 或共享密钥代替设备身份。 +- 生产上传使用逐请求 SQLite 设备认证;空凭据库、未知或已撤销设备均拒绝。不得使用管理员 session、 + 临时共享密钥或其他身份代替设备凭据。 ### `POST /api/v1/purchase-attempts/{aid}/submission-fence`