feat(admin): add device credential isolation

This commit is contained in:
QiuSW
2026-08-04 20:57:33 +08:00
parent 41881e81f3
commit 66355a7f89
20 changed files with 1745 additions and 87 deletions
+26 -2
View File
@@ -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 <token>` 和
`X-CMBuyer-Device-ID: <小写UUIDv4>` 逐请求查库认证;管理员会话不能代替设备身份。凭据错误统一空
401,SQLite 认证故障为空 503,且两者都发生在上传 body 被读取之前。
+200
View File
@@ -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 <sqlite-data-source> <issue|list|revoke> [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 <sqlite-data-source> issue -name <display-name>")
}
case "list":
if len(commandArgs) != 0 {
return errors.New("usage: device-credentials -database <sqlite-data-source> 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 <sqlite-data-source> revoke -device-id <uuid>")
}
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
}
+293
View File
@@ -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")
}
+9 -3
View File
@@ -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
+9
View File
@@ -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)
}
}
+212
View File
@@ -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:]
}
+168
View File
@@ -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
}
@@ -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")
}
+3 -19
View File
@@ -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)
}
+84 -4
View File
@@ -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)
+14 -2
View File
@@ -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 {
+177 -16
View File
@@ -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 {
+6 -1
View File
@@ -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
}
+8 -5
View File
@@ -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
}
+4 -4
View File
@@ -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(), "<!doctype html>") || !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"},
+5 -13
View File
@@ -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)
}
+11 -9
View File
@@ -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)
}
})
@@ -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;
+24 -4
View File
@@ -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 与文件系统不能组成跨资源事务;极端故障最多留下不可达孤儿文件。不得为清理孤儿而删除
+23 -5
View File
@@ -16,11 +16,29 @@
| 身份 | 凭据 | 能力 |
| --- | --- | --- |
| 管理员 | `HttpOnly; Secure; SameSite=Lax` 会话 cookie + CSRF | 建单、开始采购、查看内部证据、人工调和 |
| 设备 | `Authorization: Bearer <device-token>` + 设备 id | 心跳、领取、事件、截图、围栏与结果 |
| 管理员 | `HttpOnly; SameSite=Lax` 会话 cookie + CSRF;HTTPS 部署设 `Secure=true` | 建单、开始采购、查看内部证据、人工调和 |
| 设备 | `Authorization: Bearer <device-token>` + `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`