A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at label-service 434 lines 11 kB view raw
1package db 2 3import ( 4 "context" 5 "crypto/rand" 6 "database/sql" 7 "encoding/base64" 8 "fmt" 9 "log/slog" 10 "time" 11 12 "github.com/google/uuid" 13 "golang.org/x/crypto/bcrypt" 14) 15 16// Device represents an authorized device 17type Device struct { 18 ID string `json:"id"` 19 DID string `json:"did"` 20 Handle string `json:"handle"` 21 Name string `json:"name"` 22 SecretHash string `json:"secret_hash"` 23 IPAddress string `json:"ip_address"` 24 Location string `json:"location"` 25 UserAgent string `json:"user_agent"` 26 CreatedAt time.Time `json:"created_at"` 27 LastUsed time.Time `json:"last_used"` 28} 29 30// PendingAuthorization represents a device awaiting user approval 31type PendingAuthorization struct { 32 DeviceCode string `json:"device_code"` 33 UserCode string `json:"user_code"` 34 DeviceName string `json:"device_name"` 35 IPAddress string `json:"ip_address"` 36 UserAgent string `json:"user_agent"` 37 ExpiresAt time.Time `json:"expires_at"` 38 ApprovedDID *string `json:"approved_did"` 39 ApprovedAt *time.Time `json:"approved_at"` 40 DeviceSecret *string `json:"device_secret"` 41} 42 43// DeviceStore manages devices and pending authorizations with SQLite persistence 44type DeviceStore struct { 45 db *sql.DB 46} 47 48// NewDeviceStore creates a new SQLite-backed device store 49func NewDeviceStore(db *sql.DB) *DeviceStore { 50 return &DeviceStore{db: db} 51} 52 53// CreatePendingAuth creates a new pending device authorization 54func (s *DeviceStore) CreatePendingAuth(deviceName, ip, userAgent string) (*PendingAuthorization, error) { 55 // Generate device code (long, random) 56 deviceCodeBytes := make([]byte, 32) 57 if _, err := rand.Read(deviceCodeBytes); err != nil { 58 return nil, fmt.Errorf("failed to generate device code: %w", err) 59 } 60 deviceCode := base64.RawURLEncoding.EncodeToString(deviceCodeBytes) 61 62 // Generate user code (short, human-readable) 63 userCode := generateUserCode() 64 65 expiresAt := time.Now().Add(10 * time.Minute) 66 67 _, err := s.db.Exec(` 68 INSERT INTO pending_device_auth (device_code, user_code, device_name, ip_address, user_agent, expires_at, created_at) 69 VALUES (?, ?, ?, ?, ?, ?, datetime('now')) 70 `, deviceCode, userCode, deviceName, ip, userAgent, expiresAt) 71 72 if err != nil { 73 return nil, fmt.Errorf("failed to create pending auth: %w", err) 74 } 75 76 pending := &PendingAuthorization{ 77 DeviceCode: deviceCode, 78 UserCode: userCode, 79 DeviceName: deviceName, 80 IPAddress: ip, 81 UserAgent: userAgent, 82 ExpiresAt: expiresAt, 83 } 84 85 return pending, nil 86} 87 88// GetPendingByUserCode retrieves a pending auth by user code 89func (s *DeviceStore) GetPendingByUserCode(userCode string) (*PendingAuthorization, bool) { 90 var pending PendingAuthorization 91 92 err := s.db.QueryRow(` 93 SELECT device_code, user_code, device_name, ip_address, user_agent, expires_at, approved_did, approved_at, device_secret 94 FROM pending_device_auth 95 WHERE user_code = ? 96 `, userCode).Scan( 97 &pending.DeviceCode, 98 &pending.UserCode, 99 &pending.DeviceName, 100 &pending.IPAddress, 101 &pending.UserAgent, 102 &pending.ExpiresAt, 103 &pending.ApprovedDID, 104 &pending.ApprovedAt, 105 &pending.DeviceSecret, 106 ) 107 108 if err == sql.ErrNoRows { 109 return nil, false 110 } 111 if err != nil { 112 slog.Warn("Failed to query pending auth", "component", "device_store", "error", err) 113 return nil, false 114 } 115 116 // Check if expired 117 if time.Now().After(pending.ExpiresAt) { 118 return nil, false 119 } 120 121 return &pending, true 122} 123 124// GetPendingByDeviceCode retrieves a pending auth by device code 125func (s *DeviceStore) GetPendingByDeviceCode(deviceCode string) (*PendingAuthorization, bool) { 126 var pending PendingAuthorization 127 128 err := s.db.QueryRow(` 129 SELECT device_code, user_code, device_name, ip_address, user_agent, expires_at, approved_did, approved_at, device_secret 130 FROM pending_device_auth 131 WHERE device_code = ? 132 `, deviceCode).Scan( 133 &pending.DeviceCode, 134 &pending.UserCode, 135 &pending.DeviceName, 136 &pending.IPAddress, 137 &pending.UserAgent, 138 &pending.ExpiresAt, 139 &pending.ApprovedDID, 140 &pending.ApprovedAt, 141 &pending.DeviceSecret, 142 ) 143 144 if err == sql.ErrNoRows { 145 return nil, false 146 } 147 if err != nil { 148 slog.Warn("Failed to query pending auth", "component", "device_store", "error", err) 149 return nil, false 150 } 151 152 // Check if expired 153 if time.Now().After(pending.ExpiresAt) { 154 return nil, false 155 } 156 157 return &pending, true 158} 159 160// ApprovePending approves a pending authorization and generates device secret 161func (s *DeviceStore) ApprovePending(userCode, did, handle string) (deviceSecret string, err error) { 162 // Start transaction 163 tx, err := s.db.Begin() 164 if err != nil { 165 return "", fmt.Errorf("failed to start transaction: %w", err) 166 } 167 defer tx.Rollback() 168 169 // Get pending auth 170 var pending PendingAuthorization 171 err = tx.QueryRow(` 172 SELECT device_code, user_code, device_name, ip_address, user_agent, expires_at, approved_did 173 FROM pending_device_auth 174 WHERE user_code = ? 175 `, userCode).Scan( 176 &pending.DeviceCode, 177 &pending.UserCode, 178 &pending.DeviceName, 179 &pending.IPAddress, 180 &pending.UserAgent, 181 &pending.ExpiresAt, 182 &pending.ApprovedDID, 183 ) 184 185 if err == sql.ErrNoRows { 186 return "", fmt.Errorf("pending authorization not found") 187 } 188 if err != nil { 189 return "", fmt.Errorf("failed to query pending auth: %w", err) 190 } 191 192 // Check expiration 193 if time.Now().After(pending.ExpiresAt) { 194 return "", fmt.Errorf("authorization expired") 195 } 196 197 // Check if already approved 198 if pending.ApprovedDID != nil && *pending.ApprovedDID != "" { 199 return "", fmt.Errorf("already approved") 200 } 201 202 // Generate device secret 203 secretBytes := make([]byte, 32) 204 if _, err := rand.Read(secretBytes); err != nil { 205 return "", fmt.Errorf("failed to generate device secret: %w", err) 206 } 207 deviceSecret = "atcr_device_" + base64.RawURLEncoding.EncodeToString(secretBytes) 208 209 // Hash for storage 210 secretHashBytes, err := bcrypt.GenerateFromPassword([]byte(deviceSecret), bcrypt.DefaultCost) 211 if err != nil { 212 return "", fmt.Errorf("failed to hash device secret: %w", err) 213 } 214 secretHash := string(secretHashBytes) 215 216 // Create device record 217 deviceID := uuid.New().String() 218 now := time.Now() 219 220 _, err = tx.Exec(` 221 INSERT INTO devices (id, did, handle, name, secret_hash, ip_address, user_agent, created_at) 222 VALUES (?, ?, ?, ?, ?, ?, ?, ?) 223 `, deviceID, did, handle, pending.DeviceName, secretHash, pending.IPAddress, pending.UserAgent, now) 224 225 if err != nil { 226 return "", fmt.Errorf("failed to create device: %w", err) 227 } 228 229 // Update pending auth to mark as approved 230 _, err = tx.Exec(` 231 UPDATE pending_device_auth 232 SET approved_did = ?, approved_at = ?, device_secret = ? 233 WHERE user_code = ? 234 `, did, now, deviceSecret, userCode) 235 236 if err != nil { 237 return "", fmt.Errorf("failed to update pending auth: %w", err) 238 } 239 240 // Commit transaction 241 if err := tx.Commit(); err != nil { 242 return "", fmt.Errorf("failed to commit transaction: %w", err) 243 } 244 245 return deviceSecret, nil 246} 247 248// ValidateDeviceSecret validates a device secret and returns the device 249func (s *DeviceStore) ValidateDeviceSecret(secret string) (*Device, error) { 250 // Query all devices and check bcrypt hash 251 rows, err := s.db.Query(` 252 SELECT id, did, handle, name, secret_hash, ip_address, location, user_agent, created_at, last_used 253 FROM devices 254 `) 255 if err != nil { 256 return nil, fmt.Errorf("failed to query devices: %w", err) 257 } 258 defer rows.Close() 259 260 for rows.Next() { 261 var device Device 262 var lastUsed sql.NullTime 263 var location sql.NullString 264 265 err := rows.Scan( 266 &device.ID, 267 &device.DID, 268 &device.Handle, 269 &device.Name, 270 &device.SecretHash, 271 &device.IPAddress, 272 &location, 273 &device.UserAgent, 274 &device.CreatedAt, 275 &lastUsed, 276 ) 277 if err != nil { 278 continue 279 } 280 281 if lastUsed.Valid { 282 device.LastUsed = lastUsed.Time 283 } 284 if location.Valid { 285 device.Location = location.String 286 } 287 288 // Check if this device's hash matches the secret 289 if err := bcrypt.CompareHashAndPassword([]byte(device.SecretHash), []byte(secret)); err == nil { 290 // Update last used asynchronously 291 go s.UpdateLastUsed(device.SecretHash) 292 293 return &device, nil 294 } 295 } 296 297 return nil, fmt.Errorf("invalid device secret") 298} 299 300// ListDevices returns all devices for a DID 301func (s *DeviceStore) ListDevices(did string) []*Device { 302 rows, err := s.db.Query(` 303 SELECT id, did, handle, name, ip_address, location, user_agent, created_at, last_used 304 FROM devices 305 WHERE did = ? 306 ORDER BY created_at DESC 307 `, did) 308 309 if err != nil { 310 return []*Device{} 311 } 312 defer rows.Close() 313 314 var devices []*Device 315 for rows.Next() { 316 var device Device 317 var lastUsed sql.NullTime 318 var location sql.NullString 319 320 err := rows.Scan( 321 &device.ID, 322 &device.DID, 323 &device.Handle, 324 &device.Name, 325 &device.IPAddress, 326 &location, 327 &device.UserAgent, 328 &device.CreatedAt, 329 &lastUsed, 330 ) 331 if err != nil { 332 continue 333 } 334 335 if lastUsed.Valid { 336 device.LastUsed = lastUsed.Time 337 } 338 if location.Valid { 339 device.Location = location.String 340 } 341 342 devices = append(devices, &device) 343 } 344 345 return devices 346} 347 348// RevokeDevice removes a device 349func (s *DeviceStore) RevokeDevice(did, deviceID string) error { 350 result, err := s.db.Exec(` 351 DELETE FROM devices 352 WHERE did = ? AND id = ? 353 `, did, deviceID) 354 355 if err != nil { 356 return fmt.Errorf("failed to revoke device: %w", err) 357 } 358 359 rows, _ := result.RowsAffected() 360 if rows == 0 { 361 return fmt.Errorf("device not found") 362 } 363 364 return nil 365} 366 367// UpdateLastUsed updates the last used timestamp 368func (s *DeviceStore) UpdateLastUsed(secretHash string) { 369 _, err := s.db.Exec(` 370 UPDATE devices 371 SET last_used = ? 372 WHERE secret_hash = ? 373 `, time.Now(), secretHash) 374 375 if err != nil { 376 slog.Warn("Failed to update device last used timestamp", "component", "device_store", "error", err) 377 } 378} 379 380// CleanupExpired removes expired pending authorizations 381func (s *DeviceStore) CleanupExpired() { 382 result, err := s.db.Exec(` 383 DELETE FROM pending_device_auth 384 WHERE expires_at < datetime('now') 385 `) 386 387 if err != nil { 388 slog.Warn("Failed to cleanup expired pending auths", "component", "device_store", "error", err) 389 return 390 } 391 392 deleted, _ := result.RowsAffected() 393 if deleted > 0 { 394 slog.Info("Cleaned up expired pending device auths", "count", deleted) 395 } 396} 397 398// CleanupExpiredContext is a context-aware version for background workers 399func (s *DeviceStore) CleanupExpiredContext(ctx context.Context) error { 400 result, err := s.db.ExecContext(ctx, ` 401 DELETE FROM pending_device_auth 402 WHERE expires_at < datetime('now') 403 `) 404 405 if err != nil { 406 return fmt.Errorf("failed to cleanup expired pending auths: %w", err) 407 } 408 409 deleted, _ := result.RowsAffected() 410 if deleted > 0 { 411 slog.Info("Cleaned up expired pending device auths", "count", deleted) 412 } 413 414 return nil 415} 416 417// generateUserCode creates a short, human-readable code 418// Format: XXXX-XXXX (e.g., "WDJB-MJHT") 419// Character set: A-Z excluding ambiguous chars (0, O, I, 1, L) 420func generateUserCode() string { 421 chars := "ABCDEFGHJKMNPQRSTUVWXYZ23456789" 422 code := make([]byte, 8) 423 if _, err := rand.Read(code); err != nil { 424 // Fallback to timestamp-based generation if crypto rand fails 425 now := time.Now().UnixNano() 426 for i := range code { 427 code[i] = byte(now >> (i * 8)) 428 } 429 } 430 for i := range code { 431 code[i] = chars[int(code[i])%len(chars)] 432 } 433 return string(code[:4]) + "-" + string(code[4:]) 434}