// Package evidence stores INTERNAL_RAW PNG assets outside the public web tree. package evidence import ( "context" "crypto/rand" "crypto/sha256" "database/sql" "encoding/hex" "errors" "fmt" "image/png" "io" "os" "path/filepath" "strings" "sync" "time" "cmbuyer/admin/internal/deviceauth" core "cmbuyer/admin/internal/evidence" ) var pngSignature = []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a} type Store struct { database *sql.DB root string now func() time.Time random io.Reader syncDirectory func(string) error syncFile func(*os.File) error renameFile func(string, string) error commitTx func(*sql.Tx) error mu sync.Mutex } func NewStore(database *sql.DB, root string) (*Store, error) { return newStore(database, root, syncDirectory) } func newStore(database *sql.DB, root string, directorySync func(string) error) (*Store, error) { if database == nil { return nil, errors.New("evidence database is required") } if directorySync == nil { return nil, errors.New("evidence directory sync is required") } if root == "" || !filepath.IsAbs(root) { return nil, errors.New("evidence root must be an absolute path") } absolute, err := filepath.Abs(filepath.Clean(root)) if err != nil { return nil, fmt.Errorf("resolve evidence root: %w", err) } if filepath.Dir(absolute) == absolute { return nil, errors.New("evidence root cannot be a filesystem root") } if err := ensureDurableDirectory(absolute, 0o700, directorySync); err != nil { return nil, fmt.Errorf("create evidence root: %w", err) } // A prior startup may have created the root and then failed its parent sync. // Existence is therefore never accepted as proof that the directory entry is durable. if err := directorySync(filepath.Dir(absolute)); err != nil { return nil, fmt.Errorf("persist evidence root directory: %w", err) } if err := os.Chmod(absolute, 0o700); err != nil { return nil, fmt.Errorf("protect evidence root: %w", err) } staging := filepath.Join(absolute, ".staging") if err := ensureDurableDirectory(staging, 0o700, directorySync); err != nil { return nil, fmt.Errorf("create evidence staging directory: %w", err) } if err := directorySync(absolute); err != nil { return nil, fmt.Errorf("persist evidence staging directory: %w", err) } if err := os.Chmod(staging, 0o700); err != nil { return nil, fmt.Errorf("protect evidence staging directory: %w", err) } if _, err := database.Exec("SELECT storage_key FROM evidence_assets LIMIT 1"); err != nil { return nil, fmt.Errorf("evidence migration is not available: %w", err) } return &Store{ database: database, root: absolute, now: time.Now, random: rand.Reader, syncDirectory: directorySync, syncFile: func(file *os.File) error { return file.Sync() }, renameFile: os.Rename, commitTx: func(transaction *sql.Tx) error { return transaction.Commit() }, }, nil } func (store *Store) Stage(reader io.Reader, contentType string) (staged core.StagedFile, resultErr error) { if reader == nil || contentType != core.PNGContentType { return core.StagedFile{}, core.ErrInvalid } temporary, err := os.CreateTemp(filepath.Join(store.root, ".staging"), "upload-*.png") if err != nil { return core.StagedFile{}, err } staged.Path = temporary.Name() defer func() { if resultErr != nil { _ = temporary.Close() _ = os.Remove(staged.Path) } }() if err := temporary.Chmod(0o600); err != nil { return core.StagedFile{}, err } hasher := sha256.New() written, err := io.Copy(io.MultiWriter(temporary, hasher), io.LimitReader(reader, core.MaxFileBytes+1)) if err != nil { return core.StagedFile{}, err } if written > core.MaxFileBytes { return core.StagedFile{}, core.ErrTooLarge } if written == 0 { return core.StagedFile{}, core.ErrInvalid } if err := temporary.Sync(); err != nil { return core.StagedFile{}, err } if err := temporary.Close(); err != nil { return core.StagedFile{}, err } imageFile, err := os.Open(staged.Path) if err != nil { return core.StagedFile{}, err } defer imageFile.Close() width, height, err := validatePNG(imageFile) if err != nil { return core.StagedFile{}, err } staged.SHA256 = hex.EncodeToString(hasher.Sum(nil)) staged.ByteSize = written staged.ContentType = core.PNGContentType staged.Width = width staged.Height = height return staged, nil } func (store *Store) Discard(staged core.StagedFile) { if store.isStagedPath(staged.Path) { _ = os.Remove(staged.Path) } } 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 } defer store.Discard(staged) store.mu.Lock() defer store.mu.Unlock() transaction, err := store.database.BeginTx(ctx, nil) if err != nil { return core.Asset{}, false, err } defer transaction.Rollback() existing, found, err := findByUploadKey(ctx, transaction, principal.ID, metadata.UploadKey) if err != nil { return core.Asset{}, false, err } if found { if !sameUpload(existing, principal, metadata, staged) { return core.Asset{}, false, core.ErrConflict } if err := store.verifyStoredFile(existing); err != nil { return core.Asset{}, false, err } if err := store.commitTx(transaction); err != nil { return core.Asset{}, false, err } return existing, true, nil } var ownedClaimCount int if err := transaction.QueryRowContext(ctx, `SELECT COUNT(*) FROM purchase_attempt_claims WHERE task_id = ? AND attempt_id = ? AND claimed_by_device_id = ? AND closed_at IS NULL`, metadata.TaskID, metadata.AttemptID, principal.ID).Scan(&ownedClaimCount); err != nil { return core.Asset{}, false, err } // Evidence is auditable only when the authenticated device owns the current attempt. The // idempotent asset lookup above deliberately remains first so closing a claim later cannot // destroy stable replay of an already committed screenshot. if ownedClaimCount != 1 { return core.Asset{}, false, core.ErrInvalid } storageKey := storageKey(metadata.SHA256) finalPath, err := store.pathForKey(storageKey) if err != nil { return core.Asset{}, false, err } finalDirectory := filepath.Dir(finalPath) if err := ensureDurableDirectory(finalDirectory, 0o700, store.syncDirectory); err != nil { return core.Asset{}, false, err } // Always repeat the shard-parent boundary. If an earlier attempt created this // directory and its parent sync failed, a retry must not trust mere existence. if err := store.syncDirectory(store.root); err != nil { return core.Asset{}, false, fmt.Errorf("persist evidence shard directory: %w", err) } if err := os.Chmod(finalDirectory, 0o700); err != nil { return core.Asset{}, false, err } if info, statErr := os.Stat(finalPath); statErr == nil { if !info.Mode().IsRegular() || info.Size() != staged.ByteSize || fileSHA256(finalPath) != staged.SHA256 { return core.Asset{}, false, errors.New("stored evidence content does not match its key") } } else if !errors.Is(statErr, os.ErrNotExist) { return core.Asset{}, false, statErr } else { publishPath, err := store.preparePublishFile(staged, finalDirectory) if err != nil { return core.Asset{}, false, err } defer os.Remove(publishPath) if err := store.renameFile(publishPath, finalPath); err != nil { return core.Asset{}, false, err } } // The publication file was fsynced in this shard before its same-directory rename. // Persist the final directory entry before SQLite can expose a referencing row. // A directory sync failure is deliberately fatal; the unreachable file may remain // as an orphan, but no evidence_assets row may be committed for it. if err := store.syncDirectory(finalDirectory); err != nil { return core.Asset{}, false, fmt.Errorf("persist evidence directory entry: %w", err) } id, err := newUUID(store.random) if err != nil { return core.Asset{}, false, err } now := store.now().UTC() asset := core.Asset{ ID: id, TaskID: metadata.TaskID, AttemptID: metadata.AttemptID, Kind: metadata.Kind, PrivacyTier: metadata.PrivacyTier, SHA256: staged.SHA256, ByteSize: staged.ByteSize, ContentType: staged.ContentType, Width: staged.Width, Height: staged.Height, CapturedAt: metadata.CapturedAt.UTC(), UploadedByDeviceID: principal.ID, StorageKey: storageKey, CreatedAt: now, } _, err = transaction.ExecContext(ctx, `INSERT INTO evidence_assets (id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, asset.ID, metadata.UploadKey, asset.TaskID, asset.AttemptID, asset.Kind, asset.PrivacyTier, asset.SHA256, asset.ByteSize, asset.ContentType, asset.Width, asset.Height, asset.StorageKey, asset.UploadedByDeviceID, asset.CapturedAt.Format(time.RFC3339Nano), asset.CreatedAt.Format(time.RFC3339Nano)) if err != nil { return core.Asset{}, false, err } if err := store.commitTx(transaction); err != nil { return core.Asset{}, false, err } return asset, false, nil } func (store *Store) Open(ctx context.Context, id string) (core.Asset, io.ReadSeekCloser, error) { if !validUUID(id) { return core.Asset{}, nil, core.ErrNotFound } asset, found, err := findByID(ctx, store.database, id) if err != nil { return core.Asset{}, nil, err } if !found || asset.StorageKey != storageKey(asset.SHA256) { return core.Asset{}, nil, core.ErrNotFound } path, err := store.pathForKey(asset.StorageKey) if err != nil { return core.Asset{}, nil, core.ErrNotFound } file, err := os.Open(path) if errors.Is(err, os.ErrNotExist) { return core.Asset{}, nil, core.ErrNotFound } if err != nil { return core.Asset{}, nil, err } info, err := file.Stat() if err != nil || !info.Mode().IsRegular() || info.Size() != asset.ByteSize { _ = file.Close() if err != nil { return core.Asset{}, nil, err } return core.Asset{}, nil, core.ErrNotFound } return asset, file, nil } func (store *Store) verifyStoredFile(asset core.Asset) error { path, err := store.pathForKey(asset.StorageKey) if err != nil { return err } info, err := os.Stat(path) if err != nil || !info.Mode().IsRegular() || info.Size() != asset.ByteSize || fileSHA256(path) != asset.SHA256 { if err != nil { return err } return errors.New("stored evidence file is invalid") } return nil } func (store *Store) isStagedPath(path string) bool { if path == "" { return false } relative, err := filepath.Rel(filepath.Join(store.root, ".staging"), filepath.Clean(path)) return err == nil && relative != "." && relative != "" && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative) } func (store *Store) pathForKey(key string) (string, error) { path := filepath.Join(store.root, filepath.FromSlash(key)) relative, err := filepath.Rel(store.root, path) if err != nil || relative == "." || relative == "" || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) { return "", errors.New("invalid evidence storage key") } return path, nil } func (store *Store) preparePublishFile(staged core.StagedFile, directory string) (path string, resultErr error) { source, err := os.Open(staged.Path) if err != nil { return "", err } defer source.Close() temporary, err := os.CreateTemp(directory, ".publish-*.png") if err != nil { return "", err } temporaryPath := temporary.Name() path = temporaryPath defer func() { if resultErr != nil { _ = temporary.Close() _ = os.Remove(temporaryPath) } }() if err := temporary.Chmod(0o600); err != nil { return "", err } hasher := sha256.New() written, err := io.Copy(io.MultiWriter(temporary, hasher), source) if err != nil { return "", err } if written != staged.ByteSize || hex.EncodeToString(hasher.Sum(nil)) != staged.SHA256 { return "", errors.New("staged evidence changed before publication") } width, height, err := validatePNG(temporary) if err != nil { return "", err } if width != staged.Width || height != staged.Height { return "", errors.New("staged evidence dimensions changed before publication") } if err := store.syncFile(temporary); err != nil { return "", fmt.Errorf("sync evidence publication file: %w", err) } if err := temporary.Close(); err != nil { return "", err } return path, nil } func validatePNG(reader io.ReadSeeker) (int, int, error) { if _, err := reader.Seek(0, io.SeekStart); err != nil { return 0, 0, err } signature := make([]byte, len(pngSignature)) if _, err := io.ReadFull(reader, signature); err != nil || string(signature) != string(pngSignature) { return 0, 0, core.ErrInvalid } if _, err := reader.Seek(0, io.SeekStart); err != nil { return 0, 0, err } configuration, err := png.DecodeConfig(reader) if err != nil || configuration.Width < 1 || configuration.Height < 1 || configuration.Width > core.MaxImageSide || configuration.Height > core.MaxImageSide || int64(configuration.Width)*int64(configuration.Height) > core.MaxImagePixels { return 0, 0, core.ErrInvalid } if _, err := reader.Seek(0, io.SeekStart); err != nil { return 0, 0, err } if _, err := png.Decode(reader); err != nil { return 0, 0, core.ErrInvalid } var trailing [1]byte if count, err := reader.Read(trailing[:]); count != 0 || !errors.Is(err, io.EOF) { return 0, 0, core.ErrInvalid } return configuration.Width, configuration.Height, nil } func ensureDurableDirectory(path string, mode os.FileMode, syncParent func(string) error) error { info, err := os.Stat(path) if err == nil { if !info.IsDir() { return fmt.Errorf("path exists but is not a directory: %s", path) } return nil } if !errors.Is(err, os.ErrNotExist) { return err } parent := filepath.Dir(path) if parent == path { return fmt.Errorf("cannot create filesystem root as a managed directory: %s", path) } if err := ensureDurableDirectory(parent, mode, syncParent); err != nil { return err } if err := os.Mkdir(path, mode); err != nil && !errors.Is(err, os.ErrExist) { return err } info, err = os.Stat(path) if err != nil { return err } if !info.IsDir() { return fmt.Errorf("path exists but is not a directory: %s", path) } if err := os.Chmod(path, mode); err != nil { return err } // Syncing the parent makes creation of this directory durable. This also covers // a concurrent creator: returning success without the parent sync could otherwise // allow the following database transaction to outrun the directory entry. if err := syncParent(parent); err != nil { return fmt.Errorf("persist directory creation for %s: %w", path, err) } return nil } func storageKey(hash string) string { return hash[:2] + "/" + hash + ".png" } 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 deviceauth.Principal) bool { return deviceauth.ValidDeviceID(principal.ID) } func validSHA256(value string) bool { if len(value) != 64 { return false } for _, character := range value { if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') { return false } } return true } func validUUID(value string) bool { if len(value) != 36 { return false } for index, character := range value { if index == 8 || index == 13 || index == 18 || index == 23 { if character != '-' { return false } continue } if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') { return false } } return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b') } func newUUID(reader io.Reader) (string, error) { bytes := make([]byte, 16) if _, err := io.ReadFull(reader, bytes); err != nil { return "", err } bytes[6] = (bytes[6] & 0x0f) | 0x40 bytes[8] = (bytes[8] & 0x3f) | 0x80 encoded := hex.EncodeToString(bytes) return encoded[:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:], nil } func fileSHA256(path string) string { file, err := os.Open(path) if err != nil { return "" } defer file.Close() hasher := sha256.New() if _, err := io.Copy(hasher, file); err != nil { return "" } return hex.EncodeToString(hasher.Sum(nil)) } type rowScanner interface{ Scan(...any) error } func findByUploadKey(ctx context.Context, query interface { QueryRowContext(context.Context, string, ...any) *sql.Row }, deviceID, uploadKey string) (core.Asset, bool, error) { return scanAsset(query.QueryRowContext(ctx, `SELECT id, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at FROM evidence_assets WHERE uploaded_by_device_id = ? AND upload_key = ?`, deviceID, uploadKey)) } func findByID(ctx context.Context, query interface { QueryRowContext(context.Context, string, ...any) *sql.Row }, id string) (core.Asset, bool, error) { return scanAsset(query.QueryRowContext(ctx, `SELECT id, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at FROM evidence_assets WHERE id = ?`, id)) } func scanAsset(row rowScanner) (core.Asset, bool, error) { var asset core.Asset var captured, created string err := row.Scan(&asset.ID, &asset.TaskID, &asset.AttemptID, &asset.Kind, &asset.PrivacyTier, &asset.SHA256, &asset.ByteSize, &asset.ContentType, &asset.Width, &asset.Height, &asset.StorageKey, &asset.UploadedByDeviceID, &captured, &created) if errors.Is(err, sql.ErrNoRows) { return core.Asset{}, false, nil } if err != nil { return core.Asset{}, false, err } asset.CapturedAt, err = time.Parse(time.RFC3339Nano, captured) if err != nil { return core.Asset{}, false, err } asset.CreatedAt, err = time.Parse(time.RFC3339Nano, created) if err != nil { return core.Asset{}, false, err } return asset, true, nil } 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) }