Files
at-container-registry/pkg/appview/db/device_store.go
T
Evan JarrettandClaude Opus 5 13edb7184d appview: stop writing last_seen and last_used on every event
Neither is a correctness problem; both are round trips on hot paths for
timestamps nothing reads at that resolution.

UpdateUserLastSeen ran per Jetstream event for cached users, so once per indexed
record. DeviceStore.UpdateLastUsed ran per /auth/token call, so once per docker
push and pull including each layer's re-auth. Cheap against a local file, a
network round trip each against a remote primary, and the second sat on the
authentication path.

Both are now throttled to once per five minutes per subject. The MAU queries and
the admin views work in hours or days, so nothing loses meaning. The throttle
state is per-process and lost on restart, costing at most one extra write per
subject per boot; only the lease holder runs the consumer, so exactly one process
is doing the first of these at a time.

UpdateLastUsed stamps the throttle before writing rather than after, so a slow or
failing write cannot let every concurrent layer upload through to pile on more of
them.

Verified by disabling the throttle: 50 back-to-back calls then rewrite the
timestamp every time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 09:14:45 -05:00

578 lines
17 KiB
Go

package db
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"sync"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// Device represents an authorized device
type Device struct {
ID string `json:"id"`
DID string `json:"did"`
Handle string `json:"handle"`
Name string `json:"name"`
SecretHash string `json:"secret_hash"`
IPAddress string `json:"ip_address"`
Location string `json:"location"`
UserAgent string `json:"user_agent"`
CreatedAt time.Time `json:"created_at"`
LastUsed time.Time `json:"last_used"`
}
// PendingAuthorization represents a device awaiting user approval
type PendingAuthorization struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
DeviceName string `json:"device_name"`
IPAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
ExpiresAt time.Time `json:"expires_at"`
ApprovedDID *string `json:"approved_did"`
ApprovedAt *time.Time `json:"approved_at"`
DeviceSecret *string `json:"device_secret"`
}
// DeviceStore manages devices and pending authorizations with SQLite persistence
type DeviceStore struct {
db *sql.DB
// lastUsedWrite records when each device's last_used column was last
// written, so the timestamp is refreshed at most once per
// deviceLastUsedInterval instead of on every authentication.
lastUsedWrite sync.Map
}
// deviceLastUsedInterval is how often a device's last_used timestamp is actually
// written. It is shown in the UI as "last used" and read by the MAU queries,
// neither of which needs better than minute resolution.
//
// Without it, every /auth/token call writes — so every docker push and pull,
// including each layer's re-auth. Cheap against a local file; a network round
// trip against a remote primary, on the authentication path.
const deviceLastUsedInterval = 5 * time.Minute
// NewDeviceStore creates a new SQLite-backed device store
func NewDeviceStore(db *sql.DB) *DeviceStore {
return &DeviceStore{db: db}
}
// CreatePendingAuth creates a new pending device authorization
func (s *DeviceStore) CreatePendingAuth(deviceName, ip, userAgent string) (*PendingAuthorization, error) {
// Generate device code (long, random)
deviceCodeBytes := make([]byte, 32)
if _, err := rand.Read(deviceCodeBytes); err != nil {
return nil, fmt.Errorf("failed to generate device code: %w", err)
}
deviceCode := base64.RawURLEncoding.EncodeToString(deviceCodeBytes)
// Generate user code (short, human-readable)
userCode := generateUserCode()
expiresAt := time.Now().Add(10 * time.Minute)
_, err := s.db.Exec(`
INSERT INTO pending_device_auth (device_code, user_code, device_name, ip_address, user_agent, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
`, deviceCode, userCode, deviceName, ip, userAgent, expiresAt)
if err != nil {
return nil, fmt.Errorf("failed to create pending auth: %w", err)
}
pending := &PendingAuthorization{
DeviceCode: deviceCode,
UserCode: userCode,
DeviceName: deviceName,
IPAddress: ip,
UserAgent: userAgent,
ExpiresAt: expiresAt,
}
return pending, nil
}
// GetPendingByUserCode retrieves a pending auth by user code
func (s *DeviceStore) GetPendingByUserCode(userCode string) (*PendingAuthorization, bool) {
var pending PendingAuthorization
err := s.db.QueryRow(`
SELECT device_code, user_code, device_name, ip_address, user_agent, expires_at, approved_did, approved_at, device_secret
FROM pending_device_auth
WHERE user_code = ?
`, userCode).Scan(
&pending.DeviceCode,
&pending.UserCode,
&pending.DeviceName,
&pending.IPAddress,
&pending.UserAgent,
&pending.ExpiresAt,
&pending.ApprovedDID,
&pending.ApprovedAt,
&pending.DeviceSecret,
)
if err == sql.ErrNoRows {
return nil, false
}
if err != nil {
slog.Warn("Failed to query pending auth", "component", "device_store", "error", err)
return nil, false
}
// Check if expired
if time.Now().After(pending.ExpiresAt) {
return nil, false
}
return &pending, true
}
// GetPendingByDeviceCode retrieves a pending auth by device code
func (s *DeviceStore) GetPendingByDeviceCode(deviceCode string) (*PendingAuthorization, bool) {
var pending PendingAuthorization
err := s.db.QueryRow(`
SELECT device_code, user_code, device_name, ip_address, user_agent, expires_at, approved_did, approved_at, device_secret
FROM pending_device_auth
WHERE device_code = ?
`, deviceCode).Scan(
&pending.DeviceCode,
&pending.UserCode,
&pending.DeviceName,
&pending.IPAddress,
&pending.UserAgent,
&pending.ExpiresAt,
&pending.ApprovedDID,
&pending.ApprovedAt,
&pending.DeviceSecret,
)
if err == sql.ErrNoRows {
return nil, false
}
if err != nil {
slog.Warn("Failed to query pending auth", "component", "device_store", "error", err)
return nil, false
}
// Check if expired
if time.Now().After(pending.ExpiresAt) {
return nil, false
}
return &pending, true
}
// ApprovePending approves a pending authorization and generates device secret
func (s *DeviceStore) ApprovePending(userCode, did, handle string) (deviceSecret string, err error) {
// Start transaction
tx, err := s.db.Begin()
if err != nil {
return "", fmt.Errorf("failed to start transaction: %w", err)
}
defer tx.Rollback()
// Get pending auth
var pending PendingAuthorization
err = tx.QueryRow(`
SELECT device_code, user_code, device_name, ip_address, user_agent, expires_at, approved_did
FROM pending_device_auth
WHERE user_code = ?
`, userCode).Scan(
&pending.DeviceCode,
&pending.UserCode,
&pending.DeviceName,
&pending.IPAddress,
&pending.UserAgent,
&pending.ExpiresAt,
&pending.ApprovedDID,
)
if err == sql.ErrNoRows {
return "", fmt.Errorf("pending authorization not found")
}
if err != nil {
return "", fmt.Errorf("failed to query pending auth: %w", err)
}
// Check expiration
if time.Now().After(pending.ExpiresAt) {
return "", fmt.Errorf("authorization expired")
}
// Check if already approved
if pending.ApprovedDID != nil && *pending.ApprovedDID != "" {
return "", fmt.Errorf("already approved")
}
// Generate device secret
secretBytes := make([]byte, 32)
if _, err := rand.Read(secretBytes); err != nil {
return "", fmt.Errorf("failed to generate device secret: %w", err)
}
deviceSecret = "atcr_device_" + base64.RawURLEncoding.EncodeToString(secretBytes)
// Hash for storage
secretHashBytes, err := bcrypt.GenerateFromPassword([]byte(deviceSecret), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("failed to hash device secret: %w", err)
}
secretHash := string(secretHashBytes)
// Create device record
deviceID := uuid.New().String()
now := time.Now()
_, err = tx.Exec(`
INSERT INTO devices (id, did, handle, name, secret_hash, secret_lookup, ip_address, user_agent, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, deviceID, did, handle, pending.DeviceName, secretHash, deviceSecretLookup(deviceSecret),
pending.IPAddress, pending.UserAgent, now)
if err != nil {
return "", fmt.Errorf("failed to create device: %w", err)
}
// Update pending auth to mark as approved
_, err = tx.Exec(`
UPDATE pending_device_auth
SET approved_did = ?, approved_at = ?, device_secret = ?
WHERE user_code = ?
`, did, now, deviceSecret, userCode)
if err != nil {
return "", fmt.Errorf("failed to update pending auth: %w", err)
}
// Commit transaction
if err := tx.Commit(); err != nil {
return "", fmt.Errorf("failed to commit transaction: %w", err)
}
return deviceSecret, nil
}
// deviceSecretLookup derives the stored verifier for a device secret.
//
// Plain SHA-256 is deliberate, and it is the verifier rather than merely an
// index. Device secrets are 32 bytes from crypto/rand (see the secret
// construction in ApprovePending), so recovering one from its digest means a
// SHA-256 preimage or a 2^256 search. bcrypt's work factor exists to make
// guessing expensive when the input space is small enough to enumerate, which
// a 256-bit random token is not — it buys nothing here and costs ~65ms per
// comparison. This is the same pattern used for API tokens and session
// cookies generally.
//
// The entropy in the generated secret is therefore load-bearing for the whole
// auth model. If that generation is ever weakened, this must change with it.
func deviceSecretLookup(secret string) string {
sum := sha256.Sum256([]byte(secret))
return hex.EncodeToString(sum[:])
}
// ValidateDeviceSecret validates a device secret and returns the device.
//
// Fast path: fetch the single row whose secret_lookup matches. That match is
// the authentication — see deviceSecretLookup.
//
// Legacy path: rows created before migration 0028 have no lookup value, and
// their plaintext is not recoverable from the bcrypt hash, so they are scanned
// and bcrypt-verified once, then backfilled. bcrypt exists here solely to carry
// those rows across; once every device has authenticated once, this branch and
// the bcrypt dependency can be deleted.
//
// The scan used to be unconditional, which made every authentication O(number
// of devices) in bcrypt comparisons. At cost 10 and 244 devices that was ~15.8s
// of CPU per /auth/token, past Docker's client deadline, and it grew with every
// device registered.
func (s *DeviceStore) ValidateDeviceSecret(secret string) (*Device, error) {
if device, err := s.validateBySecretLookup(secret); err != nil {
return nil, err
} else if device != nil {
return device, nil
}
// Only rows that have not been backfilled still need scanning.
rows, err := s.db.Query(`
SELECT id, did, handle, name, secret_hash, ip_address, location, user_agent, created_at, last_used
FROM devices
WHERE secret_lookup IS NULL OR secret_lookup = ''
`)
if err != nil {
return nil, fmt.Errorf("failed to query devices: %w", err)
}
defer rows.Close()
// Record the match and finish iterating before writing. Issuing the
// backfill UPDATE inside this loop deadlocks: the open cursor holds a
// connection, and on a single-connection pool the write waits on a
// connection that only the cursor can release.
var matched *Device
for rows.Next() {
var device Device
var lastUsed sql.NullTime
var location sql.NullString
err := rows.Scan(
&device.ID,
&device.DID,
&device.Handle,
&device.Name,
&device.SecretHash,
&device.IPAddress,
&location,
&device.UserAgent,
&device.CreatedAt,
&lastUsed,
)
if err != nil {
continue
}
if lastUsed.Valid {
device.LastUsed = lastUsed.Time
}
if location.Valid {
device.Location = location.String
}
// Check if this device's hash matches the secret
if err := bcrypt.CompareHashAndPassword([]byte(device.SecretHash), []byte(secret)); err == nil {
matched = &device
break
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to scan devices: %w", err)
}
// Release the connection before writing.
rows.Close()
if matched == nil {
return nil, fmt.Errorf("invalid device secret")
}
// Backfill so this device never takes the scan path again.
if _, err := s.db.Exec(
`UPDATE devices SET secret_lookup = ? WHERE id = ?`,
deviceSecretLookup(secret), matched.ID,
); err != nil {
slog.Warn("Failed to backfill device secret_lookup",
"component", "db/devices", "deviceID", matched.ID, "error", err)
}
// Update last used asynchronously
go s.UpdateLastUsed(matched.SecretHash)
return matched, nil
}
// validateBySecretLookup resolves a secret via the indexed lookup column.
// Returns (nil, nil) when there is no indexed match, so the caller can fall
// back to scanning rows that predate migration 0028.
//
// The SHA-256 match IS the authentication. Presenting a value that hashes to a
// stored digest requires either a SHA-256 preimage or guessing the 256 bits of
// crypto/rand entropy in the secret, so no second factor is needed here; bcrypt
// is retained only on the legacy path below, where it is the sole verifier
// available for rows that have no lookup value yet.
//
// The comparison happens in SQL and so is not constant-time, which is
// immaterial: exploiting the timing would require iterating candidate secrets,
// and every candidate is a 256-bit guess.
func (s *DeviceStore) validateBySecretLookup(secret string) (*Device, error) {
var device Device
var lastUsed sql.NullTime
var location sql.NullString
err := s.db.QueryRow(`
SELECT id, did, handle, name, secret_hash, ip_address, location, user_agent, created_at, last_used
FROM devices
WHERE secret_lookup = ?
`, deviceSecretLookup(secret)).Scan(
&device.ID, &device.DID, &device.Handle, &device.Name, &device.SecretHash,
&device.IPAddress, &location, &device.UserAgent, &device.CreatedAt, &lastUsed,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to look up device by secret: %w", err)
}
if lastUsed.Valid {
device.LastUsed = lastUsed.Time
}
if location.Valid {
device.Location = location.String
}
go s.UpdateLastUsed(device.SecretHash)
return &device, nil
}
// ListDevices returns all devices for a DID
func (s *DeviceStore) ListDevices(did string) []*Device {
rows, err := s.db.Query(`
SELECT id, did, handle, name, ip_address, location, user_agent, created_at, last_used
FROM devices
WHERE did = ?
ORDER BY created_at DESC
`, did)
if err != nil {
return []*Device{}
}
defer rows.Close()
var devices []*Device
for rows.Next() {
var device Device
var lastUsed sql.NullTime
var location sql.NullString
err := rows.Scan(
&device.ID,
&device.DID,
&device.Handle,
&device.Name,
&device.IPAddress,
&location,
&device.UserAgent,
&device.CreatedAt,
&lastUsed,
)
if err != nil {
continue
}
if lastUsed.Valid {
device.LastUsed = lastUsed.Time
}
if location.Valid {
device.Location = location.String
}
devices = append(devices, &device)
}
return devices
}
// RevokeDevice removes a device
func (s *DeviceStore) RevokeDevice(did, deviceID string) error {
result, err := s.db.Exec(`
DELETE FROM devices
WHERE did = ? AND id = ?
`, did, deviceID)
if err != nil {
return fmt.Errorf("failed to revoke device: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("device not found")
}
return nil
}
// UpdateLastUsed updates the last used timestamp, at most once per
// deviceLastUsedInterval per device.
//
// Callers invoke this on every successful authentication. The throttle state is
// per-process and lost on restart, which costs one extra write per device per
// boot.
func (s *DeviceStore) UpdateLastUsed(secretHash string) {
now := time.Now()
if prev, ok := s.lastUsedWrite.Load(secretHash); ok {
if last, ok := prev.(time.Time); ok && now.Sub(last) < deviceLastUsedInterval {
return
}
}
// Stamp before writing rather than after: a slow or failing write must not
// let every concurrent layer upload through to pile on more of them.
s.lastUsedWrite.Store(secretHash, now)
_, err := s.db.Exec(`
UPDATE devices
SET last_used = ?
WHERE secret_hash = ?
`, now, secretHash)
if err != nil {
slog.Warn("Failed to update device last used timestamp", "component", "device_store", "error", err)
}
}
// CleanupExpired removes expired pending authorizations
func (s *DeviceStore) CleanupExpired() {
result, err := s.db.Exec(`
DELETE FROM pending_device_auth
WHERE expires_at < datetime('now')
`)
if err != nil {
slog.Warn("Failed to cleanup expired pending auths", "component", "device_store", "error", err)
return
}
deleted, _ := result.RowsAffected()
if deleted > 0 {
slog.Info("Cleaned up expired pending device auths", "count", deleted)
}
}
// CleanupExpiredContext is a context-aware version for background workers
func (s *DeviceStore) CleanupExpiredContext(ctx context.Context) error {
result, err := s.db.ExecContext(ctx, `
DELETE FROM pending_device_auth
WHERE expires_at < datetime('now')
`)
if err != nil {
return fmt.Errorf("failed to cleanup expired pending auths: %w", err)
}
deleted, _ := result.RowsAffected()
if deleted > 0 {
slog.Info("Cleaned up expired pending device auths", "count", deleted)
}
return nil
}
// generateUserCode creates a short, human-readable code
// Format: XXXX-XXXX (e.g., "WDJB-MJHT")
// Character set: A-Z excluding ambiguous chars (0, O, I, 1, L)
func generateUserCode() string {
chars := "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
code := make([]byte, 8)
if _, err := rand.Read(code); err != nil {
// Fallback to timestamp-based generation if crypto rand fails
now := time.Now().UnixNano()
for i := range code {
code[i] = byte(now >> (i * 8))
}
}
for i := range code {
code[i] = chars[int(code[i])%len(chars)]
}
return string(code[:4]) + "-" + string(code[4:])
}