A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
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}