diff --git a/.env.example b/.env.example index 945eb55..bb278d1 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,13 @@ STORAGE_LOCAL_DIR=data/storage # STORAGE_S3_RELEASE_BUCKET=shorebird-releases # STORAGE_S3_PATCH_BUCKET=shorebird-patches +# --- Patch delivery encryption (optional) --- +# Default is off for Shorebird updater compatibility. Clients must also send +# accept_encrypted_patch=true in /api/v1/patches/check to receive ciphertext. +# PATCH_DELIVERY_ENCRYPTION=per_device_aes_gcm +# PATCH_DELIVERY_AES_SECRET=replace-with-a-long-random-server-secret +# PATCH_DELIVERY_CACHE_ENCRYPTED=true + # --- Authentication --- JWT_SECRET=change-me-in-production-use-a-long-random-string diff --git a/README.md b/README.md index 3b25b4e..8e6fc7c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Shorebird Self-Hosted Server A self-hosted replacement for the Shorebird CodePush API server (`api.shorebird.dev`). +See `../shorebird/OPEN_SOURCE_REPLACEMENTS.md` for the current audit of public upstream +Shorebird components and the local replacements for hosted Shorebird services. ## Quick Start (Zero Dependencies) @@ -60,6 +62,24 @@ All settings via environment variables. See `.env.example` for the full list. | `SMTP_HOST` | *(empty)* | SMTP host for verification/reset emails; empty logs links | | `SMTP_PORT` | `587` | SMTP port | | `SMTP_FROM` | `shorebird@localhost` | From address for account emails | +| `PATCH_DELIVERY_ENCRYPTION` | `off` | Set to `per_device_aes_gcm` to enable opt-in encrypted patch delivery | +| `PATCH_DELIVERY_AES_SECRET` | *(empty)* | Server secret used to derive per-device AES-GCM keys | +| `PATCH_DELIVERY_CACHE_ENCRYPTED` | `true` | Cache per-device encrypted patch artifacts | + +### Optional encrypted patch delivery + +Encrypted delivery is disabled by default so the server remains compatible +with the public Shorebird updater, which expects plaintext patch bytes. When +`PATCH_DELIVERY_ENCRYPTION=per_device_aes_gcm` is set, devices that send +`accept_encrypted_patch=true` and a `client_id` to `/api/v1/patches/check` +receive a cached AES-GCM ciphertext URL plus encryption metadata. + +The generated updater `client_id` is a random UUID persisted on first startup. +Apps using the cloned `shorebird_code_push` package can call +`ShorebirdUpdater().setDeviceIdOverride(...)` before checking for updates to +bind delivery to their own stable app/account device id. This id is not a +secret; the server derives encryption keys from `PATCH_DELIVERY_AES_SECRET` +and the app/device/patch context. ### Account Security and SSO diff --git a/cmd/server/main.go b/cmd/server/main.go index 7cfaf89..a31efa3 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -78,7 +78,11 @@ func main() { log.Printf("Storage: %s", store.BackendName()) // --- Router --- - router := handlers.NewRouter(authService, database, store, mailer, cfg.Server.BaseURL) + router := handlers.NewRouter(authService, database, store, mailer, cfg.Server.BaseURL, handlers.PatchDeliveryConfig{ + EncryptionMode: cfg.PatchDelivery.EncryptionMode, + AESSecret: []byte(cfg.PatchDelivery.AESSecret), + CacheEncryptedPatches: cfg.PatchDelivery.CacheEncryptedPatches, + }) // --- HTTP Server --- addr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port) diff --git a/internal/api/handlers/device.go b/internal/api/handlers/device.go index 4258624..99ae219 100644 --- a/internal/api/handlers/device.go +++ b/internal/api/handlers/device.go @@ -13,8 +13,9 @@ import ( // DeviceHandler handles device-facing API endpoints (no auth required). type DeviceHandler struct { - DB db.Store - Storage storage.Store + DB db.Store + Storage storage.Store + PatchDelivery PatchDeliveryConfig } // PatchCheck handles POST /api/v1/patches/check @@ -58,6 +59,8 @@ func (h *DeviceHandler) PatchCheck(w http.ResponseWriter, r *http.Request) { if err != nil { rolledBack = []int{} } + currentPatchNumber := requestPatchNumber(req) + potentialRemoval := h.expiredInstalledPatchRemoval(r, release.ID, currentPatchNumber, req.Arch, req.Platform, time.Now().UTC()) // Find the channel (default to "stable") channelName := req.Channel @@ -68,10 +71,7 @@ func (h *DeviceHandler) PatchCheck(w http.ResponseWriter, r *http.Request) { channel, err := h.DB.GetChannelByAppIDAndName(r.Context(), appID, channelName) if err != nil { // Channel not found, no patch available - respondJSON(w, http.StatusOK, models.PatchCheckResponse{ - PatchAvailable: false, - RolledBackPatchNumbers: rolledBack, - }) + respondNoPatch(w, rolledBack, potentialRemoval) return } @@ -79,28 +79,23 @@ func (h *DeviceHandler) PatchCheck(w http.ResponseWriter, r *http.Request) { patch, err := h.DB.GetLatestPromotedPatch(r.Context(), release.ID, channel.ID, req.Arch, req.Platform) if err != nil { // No patch available - respondJSON(w, http.StatusOK, models.PatchCheckResponse{ - PatchAvailable: false, - RolledBackPatchNumbers: rolledBack, - }) + respondNoPatch(w, rolledBack, potentialRemoval) + return + } + if patchExpired(patch, time.Now().UTC()) { + respondNoPatch(w, rolledBack, potentialRemoval) return } // If the device already has this or a newer patch, no update needed if req.CurrentPatchNumber != nil && *req.CurrentPatchNumber >= patch.Number { - respondJSON(w, http.StatusOK, models.PatchCheckResponse{ - PatchAvailable: false, - RolledBackPatchNumbers: rolledBack, - }) + respondNoPatch(w, rolledBack, potentialRemoval) return } // Also check legacy patch_number field if req.PatchNumber != nil && *req.PatchNumber >= patch.Number { - respondJSON(w, http.StatusOK, models.PatchCheckResponse{ - PatchAvailable: false, - RolledBackPatchNumbers: rolledBack, - }) + respondNoPatch(w, rolledBack, potentialRemoval) return } @@ -111,34 +106,84 @@ func (h *DeviceHandler) PatchCheck(w http.ResponseWriter, r *http.Request) { isTargeted, err := h.DB.IsPatchTargetedToDevice(r.Context(), patch.ID, *req.ClientID) if err != nil || !isTargeted { // Patch is restricted but this device is not in the allowlist - respondJSON(w, http.StatusOK, models.PatchCheckResponse{ - PatchAvailable: false, - RolledBackPatchNumbers: rolledBack, - }) + respondNoPatch(w, rolledBack, potentialRemoval) return } } } - // Generate the public download URL for the patch artifact - downloadURL, err := h.Storage.GeneratePublicDownloadURL(r.Context(), patch.StorageKey) + delivery, err := h.maybeEncryptedPatchDelivery(r.Context(), appID, release.ID, req, patch) if err != nil { - respondError(w, http.StatusInternalServerError, "Failed to generate download URL", nil) + respondError(w, http.StatusInternalServerError, "Failed to prepare patch download", nil) return } respondJSON(w, http.StatusOK, models.PatchCheckResponse{ PatchAvailable: true, Patch: &models.PatchCheckMetadata{ - Number: patch.Number, - DownloadURL: downloadURL, - Hash: patch.Hash, - HashSignature: patch.HashSignature, + Number: patch.Number, + DownloadURL: delivery.downloadURL, + Hash: patch.Hash, + HashSignature: patch.HashSignature, + OfflineExpiresAt: patch.OfflineExpiresAt.Ptr(), + Encryption: delivery.encryption, }, RolledBackPatchNumbers: rolledBack, }) } +func respondNoPatch(w http.ResponseWriter, rolledBack []int, removal *models.PatchRemoval) { + if rolledBack == nil { + rolledBack = []int{} + } + if removal != nil { + rolledBack = appendPatchNumberOnce(rolledBack, removal.Number) + } + respondJSON(w, http.StatusOK, models.PatchCheckResponse{ + PatchAvailable: false, + RolledBackPatchNumbers: rolledBack, + RemovePatch: removal, + }) +} + +func requestPatchNumber(req models.PatchCheckRequest) *int { + if req.CurrentPatchNumber != nil { + return req.CurrentPatchNumber + } + return req.PatchNumber +} + +func (h *DeviceHandler) expiredInstalledPatchRemoval(r *http.Request, releaseID int, patchNumber *int, arch, platform string, now time.Time) *models.PatchRemoval { + if patchNumber == nil || *patchNumber <= 0 { + return nil + } + installed, err := h.DB.GetPatchByReleaseNumber(r.Context(), releaseID, *patchNumber, arch, platform) + if err != nil || !patchExpired(installed, now) { + return nil + } + return &models.PatchRemoval{ + Number: *patchNumber, + Reason: "offline_expired", + OfflineExpiresAt: installed.OfflineExpiresAt.Ptr(), + } +} + +func patchExpired(patch *db.PatchWithArtifactRow, now time.Time) bool { + if patch == nil || !patch.OfflineExpiresAt.Valid { + return false + } + return !now.UTC().Before(patch.OfflineExpiresAt.Time.Time.UTC()) +} + +func appendPatchNumberOnce(numbers []int, patchNumber int) []int { + for _, existing := range numbers { + if existing == patchNumber { + return numbers + } + } + return append(numbers, patchNumber) +} + func (h *DeviceHandler) recordPatchCheckActivity(r *http.Request, appID uuid.UUID, req models.PatchCheckRequest) { if req.ClientID == nil || *req.ClientID == "" { return diff --git a/internal/api/handlers/device_delivery.go b/internal/api/handlers/device_delivery.go new file mode 100644 index 0000000..12b01d1 --- /dev/null +++ b/internal/api/handlers/device_delivery.go @@ -0,0 +1,169 @@ +package handlers + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "io" + + "github.com/google/uuid" + "github.com/shorebird-server/internal/db" + "github.com/shorebird-server/internal/models" + "github.com/shorebird-server/internal/storage" +) + +const ( + PatchDeliveryEncryptionOff = "off" + PatchDeliveryEncryptionPerDeviceAESGCM = "per_device_aes_gcm" +) + +type PatchDeliveryConfig struct { + EncryptionMode string + AESSecret []byte + CacheEncryptedPatches bool +} + +type encryptedPatchDelivery struct { + downloadURL string + encryption *models.PatchEncryptionMetadata +} + +func (h *DeviceHandler) maybeEncryptedPatchDelivery(ctx context.Context, appID uuid.UUID, releaseID int, req models.PatchCheckRequest, patch *db.PatchWithArtifactRow) (*encryptedPatchDelivery, error) { + if !h.shouldEncryptPatchDelivery(req) { + downloadURL, err := h.Storage.GeneratePublicDownloadURL(ctx, patch.StorageKey) + if err != nil { + return nil, err + } + return &encryptedPatchDelivery{downloadURL: downloadURL}, nil + } + if len(h.PatchDelivery.AESSecret) == 0 { + return nil, fmt.Errorf("patch delivery encryption is enabled but PATCH_DELIVERY_AES_SECRET is empty") + } + + clientID := *req.ClientID + deviceIDHash := sha256Hex([]byte(clientID)) + aad := patchDeliveryAAD(appID, releaseID, req, patch, deviceIDHash) + key := derivePatchDeliveryKey(h.PatchDelivery.AESSecret, aad) + nonce := derivePatchDeliveryNonce(h.PatchDelivery.AESSecret, aad) + keyID := base64.RawURLEncoding.EncodeToString(hmacDigest(h.PatchDelivery.AESSecret, []byte("key-id\x00"+aad))[:12]) + + plaintext, err := h.readPatchObject(ctx, patch.StorageKey) + if err != nil { + return nil, fmt.Errorf("read plaintext patch: %w", err) + } + ciphertext, err := encryptPatchPayload(key, nonce, []byte(aad), plaintext) + if err != nil { + return nil, err + } + + cacheKey := storage.GenerateDeviceEncryptedPatchStorageKey( + appID.String(), + releaseID, + patch.Number, + req.Platform, + req.Arch, + patch.Hash, + deviceIDHash, + keyID, + ) + cachedCiphertext := ciphertext + if h.PatchDelivery.CacheEncryptedPatches { + if existing, err := h.readPatchObject(ctx, cacheKey); err == nil && len(existing) > 0 { + cachedCiphertext = existing + } else if err := h.Storage.UploadObject(ctx, cacheKey, bytes.NewReader(ciphertext), int64(len(ciphertext)), "application/octet-stream", true); err != nil { + return nil, fmt.Errorf("cache encrypted patch: %w", err) + } + } else if err := h.Storage.UploadObject(ctx, cacheKey, bytes.NewReader(ciphertext), int64(len(ciphertext)), "application/octet-stream", true); err != nil { + return nil, fmt.Errorf("write encrypted patch: %w", err) + } + + downloadURL, err := h.Storage.GeneratePublicDownloadURL(ctx, cacheKey) + if err != nil { + return nil, err + } + + return &encryptedPatchDelivery{ + downloadURL: downloadURL, + encryption: &models.PatchEncryptionMetadata{ + Algorithm: "AES-256-GCM", + KDF: "HMAC-SHA256", + KeyID: keyID, + Nonce: base64.RawURLEncoding.EncodeToString(nonce), + AAD: base64.RawURLEncoding.EncodeToString([]byte(aad)), + AADHash: sha256Hex([]byte(aad)), + EncryptedHash: sha256Hex(cachedCiphertext), + DeviceIDHash: deviceIDHash, + CacheKey: cacheKey, + }, + }, nil +} + +func (h *DeviceHandler) shouldEncryptPatchDelivery(req models.PatchCheckRequest) bool { + if h.PatchDelivery.EncryptionMode != PatchDeliveryEncryptionPerDeviceAESGCM { + return false + } + if !req.AcceptEncryptedPatch { + return false + } + return req.ClientID != nil && *req.ClientID != "" +} + +func (h *DeviceHandler) readPatchObject(ctx context.Context, objectKey string) ([]byte, error) { + reader, err := h.Storage.GetObject(ctx, objectKey, true) + if err != nil { + return nil, err + } + defer reader.Close() + return io.ReadAll(reader) +} + +func patchDeliveryAAD(appID uuid.UUID, releaseID int, req models.PatchCheckRequest, patch *db.PatchWithArtifactRow, deviceIDHash string) string { + return fmt.Sprintf( + "open-aot-patch-delivery-v1\napp_id=%s\nrelease_id=%d\npatch_number=%d\npatch_hash=%s\nplatform=%s\narch=%s\ndevice_id_hash=%s", + appID.String(), + releaseID, + patch.Number, + patch.Hash, + req.Platform, + req.Arch, + deviceIDHash, + ) +} + +func derivePatchDeliveryKey(secret []byte, aad string) []byte { + return hmacDigest(secret, []byte("key\x00"+aad)) +} + +func derivePatchDeliveryNonce(secret []byte, aad string) []byte { + sum := hmacDigest(secret, []byte("nonce\x00"+aad)) + return sum[:12] +} + +func encryptPatchPayload(key, nonce, aad, plaintext []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + return gcm.Seal(nil, nonce, plaintext, aad), nil +} + +func hmacDigest(secret, message []byte) []byte { + mac := hmac.New(sha256.New, secret) + mac.Write(message) + return mac.Sum(nil) +} + +func sha256Hex(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/api/handlers/device_test.go b/internal/api/handlers/device_test.go new file mode 100644 index 0000000..031543c --- /dev/null +++ b/internal/api/handlers/device_test.go @@ -0,0 +1,371 @@ +package handlers + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/shorebird-server/internal/db" + "github.com/shorebird-server/internal/models" + "github.com/shorebird-server/internal/storage" +) + +const ( + testReleaseVersion = "1.0.0+1" + testArch = "x64" + testPlatform = "windows" +) + +func TestPatchCheckRemovesExpiredInstalledPatchWhenNoNewPatchExists(t *testing.T) { + fixture := newPatchCheckFixture(t) + fixture.createPatch(t, 1, time.Now().Add(-time.Hour)) + + response := fixture.patchCheck(t, &models.PatchCheckRequest{ + ReleaseVersion: testReleaseVersion, + Platform: testPlatform, + Arch: testArch, + AppID: fixture.appID.String(), + Channel: "stable", + CurrentPatchNumber: intPtr(1), + }) + + if response.PatchAvailable { + t.Fatalf("expected no patch, got %+v", response.Patch) + } + if response.RemovePatch == nil { + t.Fatal("expected remove_patch metadata") + } + if response.RemovePatch.Number != 1 || response.RemovePatch.Reason != "offline_expired" { + t.Fatalf("unexpected remove_patch: %+v", response.RemovePatch) + } + if !containsInt(response.RolledBackPatchNumbers, 1) { + t.Fatalf("expected rolled_back_patch_numbers to contain 1, got %+v", response.RolledBackPatchNumbers) + } +} + +func TestPatchCheckServesNewPatchInsteadOfRemovingExpiredInstalledPatch(t *testing.T) { + fixture := newPatchCheckFixture(t) + fixture.createPatch(t, 1, time.Now().Add(-time.Hour)) + fixture.createPatch(t, 2, time.Now().Add(24*time.Hour)) + + response := fixture.patchCheck(t, &models.PatchCheckRequest{ + ReleaseVersion: testReleaseVersion, + Platform: testPlatform, + Arch: testArch, + AppID: fixture.appID.String(), + Channel: "stable", + CurrentPatchNumber: intPtr(1), + }) + + if !response.PatchAvailable { + t.Fatalf("expected patch 2 to be available, got remove=%+v", response.RemovePatch) + } + if response.Patch == nil || response.Patch.Number != 2 { + t.Fatalf("expected patch number 2, got %+v", response.Patch) + } + if response.RemovePatch != nil { + t.Fatalf("did not expect removal while a newer valid patch exists: %+v", response.RemovePatch) + } + if response.Patch.OfflineExpiresAt == nil { + t.Fatal("expected offline_expires_at on patch metadata") + } +} + +func TestPatchCheckDoesNotServeExpiredPatchToUnpatchedDevice(t *testing.T) { + fixture := newPatchCheckFixture(t) + fixture.createPatch(t, 1, time.Now().Add(-time.Hour)) + + response := fixture.patchCheck(t, &models.PatchCheckRequest{ + ReleaseVersion: testReleaseVersion, + Platform: testPlatform, + Arch: testArch, + AppID: fixture.appID.String(), + Channel: "stable", + }) + + if response.PatchAvailable { + t.Fatalf("expected expired patch to be withheld, got %+v", response.Patch) + } + if response.RemovePatch != nil { + t.Fatalf("unpatched devices should not receive removal metadata: %+v", response.RemovePatch) + } + if len(response.RolledBackPatchNumbers) != 0 { + t.Fatalf("expected no rolled back numbers, got %+v", response.RolledBackPatchNumbers) + } +} + +type patchCheckFixture struct { + handler *DeviceHandler + db db.Store + storage storage.Store + appID uuid.UUID + releaseID int + channelID int +} + +func newPatchCheckFixture(t *testing.T) *patchCheckFixture { + t.Helper() + database, err := db.NewSqliteStore(t.TempDir() + "/server.db") + if err != nil { + t.Fatal(err) + } + t.Cleanup(database.Close) + ctx := context.Background() + orgID, err := database.CreateOrganization(ctx, "test org", "team") + if err != nil { + t.Fatal(err) + } + app, err := database.CreateApp(ctx, orgID, "license test") + if err != nil { + t.Fatal(err) + } + channel, err := database.CreateChannel(ctx, app.ID, "stable") + if err != nil { + t.Fatal(err) + } + release, err := database.CreateRelease(ctx, app.ID, testReleaseVersion, "flutter-rev", nil, nil) + if err != nil { + t.Fatal(err) + } + localStorage, err := storage.NewLocalStore(t.TempDir(), "http://updates.test", "") + if err != nil { + t.Fatal(err) + } + return &patchCheckFixture{ + handler: &DeviceHandler{ + DB: database, + Storage: localStorage, + }, + db: database, + storage: localStorage, + appID: app.ID, + releaseID: release.ID, + channelID: channel.ID, + } +} + +func (f *patchCheckFixture) createPatch(t *testing.T, number int, offlineExpiresAt time.Time) { + t.Helper() + ctx := context.Background() + patch, err := f.db.CreatePatch(ctx, f.releaseID, number, nil) + if err != nil { + t.Fatal(err) + } + storageKey := storage.GeneratePatchStorageKey(f.appID.String(), f.releaseID, number, testPlatform, testArch, "dlc.vmcode") + patchBytes := []byte(fmt.Sprintf("patch-%d-plaintext", number)) + hash := sha256Hex(patchBytes) + if err := f.storage.UploadObject(ctx, storageKey, bytes.NewReader(patchBytes), int64(len(patchBytes)), "application/octet-stream", true); err != nil { + t.Fatal(err) + } + expires := offlineExpiresAt.UTC() + if _, err := f.db.CreatePatchArtifact(ctx, patch.ID, testArch, testPlatform, hash, storageKey, 128, nil, nil, &expires); err != nil { + t.Fatal(err) + } + if err := f.db.PromotePatch(ctx, patch.ID, f.channelID); err != nil { + t.Fatal(err) + } +} + +func TestPatchCheckReturnsPlaintextPatchWhenEncryptedDeliveryNotAccepted(t *testing.T) { + fixture := newPatchCheckFixture(t) + fixture.handler.PatchDelivery = PatchDeliveryConfig{ + EncryptionMode: PatchDeliveryEncryptionPerDeviceAESGCM, + AESSecret: []byte("server-secret"), + CacheEncryptedPatches: true, + } + fixture.createPatch(t, 1, time.Now().Add(time.Hour)) + + clientID := "device-1" + response := fixture.patchCheck(t, &models.PatchCheckRequest{ + ReleaseVersion: testReleaseVersion, + Platform: testPlatform, + Arch: testArch, + AppID: fixture.appID.String(), + Channel: "stable", + ClientID: &clientID, + }) + + if !response.PatchAvailable || response.Patch == nil { + t.Fatalf("expected patch, got %+v", response) + } + if response.Patch.Encryption != nil { + t.Fatalf("did not expect encryption metadata without opt-in: %+v", response.Patch.Encryption) + } + if strings.Contains(response.Patch.DownloadURL, "/encrypted/") { + t.Fatalf("expected plaintext URL, got %s", response.Patch.DownloadURL) + } +} + +func TestPatchCheckEncryptsPatchPerDeviceAndCaches(t *testing.T) { + fixture := newPatchCheckFixture(t) + secret := []byte("server-secret") + fixture.handler.PatchDelivery = PatchDeliveryConfig{ + EncryptionMode: PatchDeliveryEncryptionPerDeviceAESGCM, + AESSecret: secret, + CacheEncryptedPatches: true, + } + fixture.createPatch(t, 1, time.Now().Add(time.Hour)) + + clientID := "device-1" + request := &models.PatchCheckRequest{ + ReleaseVersion: testReleaseVersion, + Platform: testPlatform, + Arch: testArch, + AppID: fixture.appID.String(), + Channel: "stable", + ClientID: &clientID, + AcceptEncryptedPatch: true, + } + + first := fixture.patchCheck(t, request) + second := fixture.patchCheck(t, request) + + if !first.PatchAvailable || first.Patch == nil || first.Patch.Encryption == nil { + t.Fatalf("expected encrypted patch, got %+v", first) + } + if first.Patch.Encryption.Algorithm != "AES-256-GCM" { + t.Fatalf("unexpected algorithm: %+v", first.Patch.Encryption) + } + if first.Patch.Encryption.CacheKey != second.Patch.Encryption.CacheKey { + t.Fatalf("expected cached object reuse, got %q then %q", first.Patch.Encryption.CacheKey, second.Patch.Encryption.CacheKey) + } + if first.Patch.Encryption.EncryptedHash != second.Patch.Encryption.EncryptedHash { + t.Fatalf("expected stable encrypted hash, got %q then %q", first.Patch.Encryption.EncryptedHash, second.Patch.Encryption.EncryptedHash) + } + if !strings.Contains(first.Patch.DownloadURL, "/encrypted/") { + t.Fatalf("expected encrypted download URL, got %s", first.Patch.DownloadURL) + } + + ciphertext := fixture.readObject(t, first.Patch.Encryption.CacheKey) + if string(ciphertext) == "patch-1-plaintext" { + t.Fatal("encrypted object should not equal plaintext patch bytes") + } + plaintext := decryptPatchForTest(t, secret, first.Patch.Encryption, ciphertext) + if string(plaintext) != "patch-1-plaintext" { + t.Fatalf("unexpected decrypted patch: %q", plaintext) + } + if _, err := decryptPatchForTestWithError([]byte("wrong-secret"), first.Patch.Encryption, ciphertext); err == nil { + t.Fatal("expected wrong key to fail decrypting encrypted patch") + } +} + +func TestPatchCheckDerivesDifferentEncryptedPatchForDifferentDevices(t *testing.T) { + fixture := newPatchCheckFixture(t) + fixture.handler.PatchDelivery = PatchDeliveryConfig{ + EncryptionMode: PatchDeliveryEncryptionPerDeviceAESGCM, + AESSecret: []byte("server-secret"), + CacheEncryptedPatches: true, + } + fixture.createPatch(t, 1, time.Now().Add(time.Hour)) + + clientA := "device-a" + clientB := "device-b" + responseA := fixture.patchCheck(t, &models.PatchCheckRequest{ + ReleaseVersion: testReleaseVersion, + Platform: testPlatform, + Arch: testArch, + AppID: fixture.appID.String(), + Channel: "stable", + ClientID: &clientA, + AcceptEncryptedPatch: true, + }) + responseB := fixture.patchCheck(t, &models.PatchCheckRequest{ + ReleaseVersion: testReleaseVersion, + Platform: testPlatform, + Arch: testArch, + AppID: fixture.appID.String(), + Channel: "stable", + ClientID: &clientB, + AcceptEncryptedPatch: true, + }) + + if responseA.Patch.Encryption.CacheKey == responseB.Patch.Encryption.CacheKey { + t.Fatalf("expected different cache keys, got %s", responseA.Patch.Encryption.CacheKey) + } + if responseA.Patch.Encryption.EncryptedHash == responseB.Patch.Encryption.EncryptedHash { + t.Fatalf("expected different encrypted hashes, got %s", responseA.Patch.Encryption.EncryptedHash) + } +} + +func (f *patchCheckFixture) patchCheck(t *testing.T, request *models.PatchCheckRequest) models.PatchCheckResponse { + t.Helper() + body, err := json.Marshal(request) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/patches/check", bytes.NewReader(body)) + recorder := httptest.NewRecorder() + f.handler.PatchCheck(recorder, req) + if recorder.Code != http.StatusOK { + t.Fatalf("unexpected status %d: %s", recorder.Code, recorder.Body.String()) + } + var response models.PatchCheckResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + return response +} + +func containsInt(values []int, target int) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func (f *patchCheckFixture) readObject(t *testing.T, objectKey string) []byte { + t.Helper() + reader, err := f.storage.GetObject(context.Background(), objectKey, true) + if err != nil { + t.Fatal(err) + } + defer reader.Close() + bytes, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + return bytes +} + +func decryptPatchForTest(t *testing.T, secret []byte, metadata *models.PatchEncryptionMetadata, ciphertext []byte) []byte { + t.Helper() + plaintext, err := decryptPatchForTestWithError(secret, metadata, ciphertext) + if err != nil { + t.Fatal(err) + } + return plaintext +} + +func decryptPatchForTestWithError(secret []byte, metadata *models.PatchEncryptionMetadata, ciphertext []byte) ([]byte, error) { + aadBytes, err := base64.RawURLEncoding.DecodeString(metadata.AAD) + if err != nil { + return nil, err + } + nonce, err := base64.RawURLEncoding.DecodeString(metadata.Nonce) + if err != nil { + return nil, err + } + key := derivePatchDeliveryKey(secret, string(aadBytes)) + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + return gcm.Open(nil, nonce, ciphertext, aadBytes) +} diff --git a/internal/api/handlers/patches.go b/internal/api/handlers/patches.go index 54e3faf..0c3c7ee 100644 --- a/internal/api/handlers/patches.go +++ b/internal/api/handlers/patches.go @@ -97,8 +97,19 @@ func (h *PatchHandler) CreatePatchArtifact(w http.ResponseWriter, r *http.Reques sizeStr := r.FormValue("size") hashSignature := r.FormValue("hash_signature") podfileLockHash := r.FormValue("podfile_lock_hash") + offlineExpiresAtRaw := r.FormValue("offline_expires_at") size, _ := strconv.ParseInt(sizeStr, 10, 64) + var offlineExpiresAt *time.Time + if offlineExpiresAtRaw != "" { + parsed, err := time.Parse(time.RFC3339, offlineExpiresAtRaw) + if err != nil { + respondError(w, http.StatusBadRequest, "Invalid offline_expires_at", strPtr(err.Error())) + return + } + utc := parsed.UTC() + offlineExpiresAt = &utc + } // Get the patch to verify it exists and get release info patch, err := h.DB.GetPatchByID(r.Context(), patchID) @@ -138,20 +149,25 @@ func (h *PatchHandler) CreatePatchArtifact(w http.ResponseWriter, r *http.Reques phl = &podfileLockHash } - artifact, err := h.DB.CreatePatchArtifact(r.Context(), patchID, arch, platform, hash, storageKey, size, hs, phl) + artifact, err := h.DB.CreatePatchArtifact(r.Context(), patchID, arch, platform, hash, storageKey, size, hs, phl, offlineExpiresAt) if err != nil { respondError(w, http.StatusInternalServerError, "Failed to create artifact record", strPtr(err.Error())) return } + var responseOfflineExpiresAt *time.Time + if artifact.OfflineExpiresAt.Valid { + responseOfflineExpiresAt = artifact.OfflineExpiresAt.Ptr() + } respondJSON(w, http.StatusCreated, models.CreatePatchArtifactResponse{ - ID: artifact.ID, - PatchID: artifact.PatchID, - Arch: artifact.Arch, - Platform: artifact.Platform, - Hash: artifact.Hash, - Size: artifact.Size, - URL: uploadURL, + ID: artifact.ID, + PatchID: artifact.PatchID, + Arch: artifact.Arch, + Platform: artifact.Platform, + Hash: artifact.Hash, + Size: artifact.Size, + URL: uploadURL, + OfflineExpiresAt: responseOfflineExpiresAt, }) } diff --git a/internal/api/handlers/router.go b/internal/api/handlers/router.go index 267fe36..9d76f03 100644 --- a/internal/api/handlers/router.go +++ b/internal/api/handlers/router.go @@ -16,7 +16,7 @@ import ( ) // NewRouter creates the HTTP router with all API routes. -func NewRouter(authService *authpkg.Service, database db.Store, store storage.Store, mailer *email.Mailer, baseURL string) *chi.Mux { +func NewRouter(authService *authpkg.Service, database db.Store, store storage.Store, mailer *email.Mailer, baseURL string, patchDelivery PatchDeliveryConfig) *chi.Mux { r := chi.NewRouter() r.Use(chimw.Logger) r.Use(chimw.Recoverer) @@ -40,7 +40,7 @@ func NewRouter(authService *authpkg.Service, database db.Store, store storage.St releaseHandler := &ReleaseHandler{DB: database, Storage: store, BaseURL: baseURL} patchHandler := &PatchHandler{DB: database, Storage: store} metricsHandler := &MetricsHandler{DB: database} - deviceHandler := &DeviceHandler{DB: database, Storage: store} + deviceHandler := &DeviceHandler{DB: database, Storage: store, PatchDelivery: patchDelivery} diagHandler := &DiagnosticsHandler{} adminHandler := &AdminHandler{DB: database} storageHandler := NewStorageHandler(store) diff --git a/internal/config/config.go b/internal/config/config.go index 9e29edc..0aa3781 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,13 +7,14 @@ import ( // Config holds all configuration for the Shorebird server. type Config struct { - Server ServerConfig - DB DBConfig - Storage StorageConfig - Auth AuthConfig - Email EmailConfig - Admin AdminConfig - Redis RedisConfig + Server ServerConfig + DB DBConfig + Storage StorageConfig + Auth AuthConfig + Email EmailConfig + Admin AdminConfig + Redis RedisConfig + PatchDelivery PatchDeliveryConfig } // ServerConfig holds HTTP server configuration. @@ -81,6 +82,16 @@ type RedisConfig struct { DB int } +// PatchDeliveryConfig controls optional device-facing patch encryption. +type PatchDeliveryConfig struct { + // EncryptionMode is "off" or "per_device_aes_gcm". + EncryptionMode string + // AESSecret is a server-side secret used to derive per-device AES keys. + AESSecret string + // CacheEncryptedPatches stores derived ciphertext objects for reuse. + CacheEncryptedPatches bool +} + // Load reads configuration from environment variables with sensible defaults. func Load() *Config { port := envOrDefault("SERVER_PORT", "8080") @@ -130,6 +141,11 @@ func Load() *Config { Password: envOrDefault("REDIS_PASSWORD", ""), DB: 0, }, + PatchDelivery: PatchDeliveryConfig{ + EncryptionMode: envOrDefault("PATCH_DELIVERY_ENCRYPTION", "off"), + AESSecret: envOrDefault("PATCH_DELIVERY_AES_SECRET", ""), + CacheEncryptedPatches: envBoolOrDefault("PATCH_DELIVERY_CACHE_ENCRYPTED", true), + }, } } @@ -139,3 +155,11 @@ func envOrDefault(key, defaultVal string) string { } return defaultVal } + +func envBoolOrDefault(key string, defaultVal bool) bool { + v := os.Getenv(key) + if v == "" { + return defaultVal + } + return v == "true" || v == "1" || v == "yes" +} diff --git a/internal/db/db.go b/internal/db/db.go index 88a91bd..d0bd632 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -599,7 +599,7 @@ func (db *PgStore) GetLatestPromotedPatch(ctx context.Context, releaseID, channe row := &PatchWithArtifactRow{} err := db.queryRow(ctx, `SELECT p.id, p.release_id, p.number, p.notes, p.created_at, - pa.hash, pa.storage_key, pa.hash_signature + pa.hash, pa.storage_key, pa.hash_signature, pa.offline_expires_at FROM patches p JOIN patch_channels pc ON p.id = pc.patch_id JOIN patch_artifacts pa ON p.id = pa.patch_id @@ -608,7 +608,27 @@ func (db *PgStore) GetLatestPromotedPatch(ctx context.Context, releaseID, channe ORDER BY p.number DESC LIMIT 1`, releaseID, channelID, arch, platform, ).Scan(&row.ID, &row.ReleaseID, &row.Number, &row.Notes, &row.CreatedAt, - &row.Hash, &row.StorageKey, &row.HashSignature) + &row.Hash, &row.StorageKey, &row.HashSignature, &row.OfflineExpiresAt) + if err != nil { + return nil, err + } + return row, nil +} + +// GetPatchByReleaseNumber returns one patch artifact by release patch number. +func (db *PgStore) GetPatchByReleaseNumber(ctx context.Context, releaseID, patchNumber int, arch, platform string) (*PatchWithArtifactRow, error) { + row := &PatchWithArtifactRow{} + err := db.queryRow(ctx, + `SELECT p.id, p.release_id, p.number, p.notes, p.created_at, + pa.hash, pa.storage_key, pa.hash_signature, pa.offline_expires_at + FROM patches p + JOIN patch_artifacts pa ON p.id = pa.patch_id + WHERE p.release_id = $1 AND p.number = $2 + AND pa.arch = $3 AND pa.platform = $4 + LIMIT 1`, + releaseID, patchNumber, arch, platform, + ).Scan(&row.ID, &row.ReleaseID, &row.Number, &row.Notes, &row.CreatedAt, + &row.Hash, &row.StorageKey, &row.HashSignature, &row.OfflineExpiresAt) if err != nil { return nil, err } @@ -618,14 +638,14 @@ func (db *PgStore) GetLatestPromotedPatch(ctx context.Context, releaseID, channe // --- Patch artifact queries --- // CreatePatchArtifact creates a patch artifact record. -func (db *PgStore) CreatePatchArtifact(ctx context.Context, patchID int, arch, platform, hash, storageKey string, size int64, hashSignature, podfileLockHash *string) (*PatchArtifactRow, error) { +func (db *PgStore) CreatePatchArtifact(ctx context.Context, patchID int, arch, platform, hash, storageKey string, size int64, hashSignature, podfileLockHash *string, offlineExpiresAt *time.Time) (*PatchArtifactRow, error) { row := &PatchArtifactRow{} err := db.queryRow(ctx, - `INSERT INTO patch_artifacts (patch_id, arch, platform, hash, size, storage_key, hash_signature, podfile_lock_hash) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id, patch_id, arch, platform, hash, size, storage_key, hash_signature, podfile_lock_hash, created_at`, - patchID, arch, platform, hash, size, storageKey, hashSignature, podfileLockHash, - ).Scan(&row.ID, &row.PatchID, &row.Arch, &row.Platform, &row.Hash, &row.Size, &row.StorageKey, &row.HashSignature, &row.PodfileLockHash, &row.CreatedAt) + `INSERT INTO patch_artifacts (patch_id, arch, platform, hash, size, storage_key, hash_signature, podfile_lock_hash, offline_expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id, patch_id, arch, platform, hash, size, storage_key, hash_signature, podfile_lock_hash, offline_expires_at, created_at`, + patchID, arch, platform, hash, size, storageKey, hashSignature, podfileLockHash, offlineExpiresAt, + ).Scan(&row.ID, &row.PatchID, &row.Arch, &row.Platform, &row.Hash, &row.Size, &row.StorageKey, &row.HashSignature, &row.PodfileLockHash, &row.OfflineExpiresAt, &row.CreatedAt) if err != nil { return nil, err } diff --git a/internal/db/migrations/001_initial_schema.sql b/internal/db/migrations/001_initial_schema.sql index f33e8b2..180f735 100644 --- a/internal/db/migrations/001_initial_schema.sql +++ b/internal/db/migrations/001_initial_schema.sql @@ -137,6 +137,7 @@ CREATE TABLE patch_artifacts ( storage_key TEXT NOT NULL, hash_signature TEXT, podfile_lock_hash TEXT, + offline_expires_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); diff --git a/internal/db/migrations/002_patch_offline_expiry.sql b/internal/db/migrations/002_patch_offline_expiry.sql new file mode 100644 index 0000000..12accb9 --- /dev/null +++ b/internal/db/migrations/002_patch_offline_expiry.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE patch_artifacts ADD COLUMN IF NOT EXISTS offline_expires_at TIMESTAMPTZ; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE patch_artifacts DROP COLUMN IF EXISTS offline_expires_at; +-- +goose StatementEnd diff --git a/internal/db/sqlite.go b/internal/db/sqlite.go index 66ad973..1d6d529 100644 --- a/internal/db/sqlite.go +++ b/internal/db/sqlite.go @@ -507,7 +507,16 @@ func (s *SqliteStore) PromotePatch(ctx context.Context, patchID, channelID int) } func (s *SqliteStore) GetLatestPromotedPatch(ctx context.Context, releaseID, channelID int, arch, platform string) (*PatchWithArtifactRow, error) { r := &PatchWithArtifactRow{} - err := s.db.QueryRowContext(ctx, `SELECT p.id, p.release_id, p.number, p.notes, p.created_at, pa.hash, pa.storage_key, pa.hash_signature FROM patches p JOIN patch_channels pc ON p.id = pc.patch_id JOIN patch_artifacts pa ON p.id = pa.patch_id WHERE p.release_id = ? AND pc.channel_id = ? AND pa.arch = ? AND pa.platform = ? ORDER BY p.number DESC LIMIT 1`, releaseID, channelID, arch, platform).Scan(&r.ID, &r.ReleaseID, &r.Number, &r.Notes, &r.CreatedAt, &r.Hash, &r.StorageKey, &r.HashSignature) + err := s.db.QueryRowContext(ctx, `SELECT p.id, p.release_id, p.number, p.notes, p.created_at, pa.hash, pa.storage_key, pa.hash_signature, pa.offline_expires_at FROM patches p JOIN patch_channels pc ON p.id = pc.patch_id JOIN patch_artifacts pa ON p.id = pa.patch_id WHERE p.release_id = ? AND pc.channel_id = ? AND pa.arch = ? AND pa.platform = ? ORDER BY p.number DESC LIMIT 1`, releaseID, channelID, arch, platform).Scan(&r.ID, &r.ReleaseID, &r.Number, &r.Notes, &r.CreatedAt, &r.Hash, &r.StorageKey, &r.HashSignature, &r.OfflineExpiresAt) + if err != nil { + return nil, err + } + return r, nil +} + +func (s *SqliteStore) GetPatchByReleaseNumber(ctx context.Context, releaseID, patchNumber int, arch, platform string) (*PatchWithArtifactRow, error) { + r := &PatchWithArtifactRow{} + err := s.db.QueryRowContext(ctx, `SELECT p.id, p.release_id, p.number, p.notes, p.created_at, pa.hash, pa.storage_key, pa.hash_signature, pa.offline_expires_at FROM patches p JOIN patch_artifacts pa ON p.id = pa.patch_id WHERE p.release_id = ? AND p.number = ? AND pa.arch = ? AND pa.platform = ? LIMIT 1`, releaseID, patchNumber, arch, platform).Scan(&r.ID, &r.ReleaseID, &r.Number, &r.Notes, &r.CreatedAt, &r.Hash, &r.StorageKey, &r.HashSignature, &r.OfflineExpiresAt) if err != nil { return nil, err } @@ -515,9 +524,13 @@ func (s *SqliteStore) GetLatestPromotedPatch(ctx context.Context, releaseID, cha } // --- Patch Artifacts --- -func (s *SqliteStore) CreatePatchArtifact(ctx context.Context, patchID int, arch, platform, hash, storageKey string, size int64, hashSignature, podfileLockHash *string) (*PatchArtifactRow, error) { +func (s *SqliteStore) CreatePatchArtifact(ctx context.Context, patchID int, arch, platform, hash, storageKey string, size int64, hashSignature, podfileLockHash *string, offlineExpiresAt *time.Time) (*PatchArtifactRow, error) { r := &PatchArtifactRow{PatchID: patchID, Arch: arch, Platform: platform, Hash: hash, Size: size, StorageKey: storageKey, HashSignature: hashSignature, PodfileLockHash: podfileLockHash} - err := s.db.QueryRowContext(ctx, `INSERT INTO patch_artifacts (patch_id, arch, platform, hash, size, storage_key, hash_signature, podfile_lock_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, created_at`, patchID, arch, platform, hash, size, storageKey, hashSignature, podfileLockHash).Scan(&r.ID, &r.CreatedAt) + var expires interface{} + if offlineExpiresAt != nil { + expires = offlineExpiresAt.UTC().Format(time.RFC3339Nano) + } + err := s.db.QueryRowContext(ctx, `INSERT INTO patch_artifacts (patch_id, arch, platform, hash, size, storage_key, hash_signature, podfile_lock_hash, offline_expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, created_at, offline_expires_at`, patchID, arch, platform, hash, size, storageKey, hashSignature, podfileLockHash, expires).Scan(&r.ID, &r.CreatedAt, &r.OfflineExpiresAt) if err != nil { return nil, err } @@ -614,7 +627,7 @@ func (s *SqliteStore) migrate(ctx context.Context) error { CREATE TABLE IF NOT EXISTS release_platform_statuses (id INTEGER PRIMARY KEY AUTOINCREMENT, release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE, platform TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'draft', metadata TEXT DEFAULT '{}', created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), UNIQUE(release_id, platform)); CREATE TABLE IF NOT EXISTS release_artifacts (id INTEGER PRIMARY KEY AUTOINCREMENT, release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE, arch TEXT NOT NULL, platform TEXT NOT NULL, hash TEXT NOT NULL, size INTEGER NOT NULL DEFAULT 0, storage_key TEXT NOT NULL, can_sideload INTEGER NOT NULL DEFAULT 0, podfile_lock_hash TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))); CREATE TABLE IF NOT EXISTS patches (id INTEGER PRIMARY KEY AUTOINCREMENT, release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE, number INTEGER NOT NULL, notes TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), UNIQUE(release_id, number)); - CREATE TABLE IF NOT EXISTS patch_artifacts (id INTEGER PRIMARY KEY AUTOINCREMENT, patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE, arch TEXT NOT NULL, platform TEXT NOT NULL, hash TEXT NOT NULL, size INTEGER NOT NULL DEFAULT 0, storage_key TEXT NOT NULL, hash_signature TEXT, podfile_lock_hash TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))); + CREATE TABLE IF NOT EXISTS patch_artifacts (id INTEGER PRIMARY KEY AUTOINCREMENT, patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE, arch TEXT NOT NULL, platform TEXT NOT NULL, hash TEXT NOT NULL, size INTEGER NOT NULL DEFAULT 0, storage_key TEXT NOT NULL, hash_signature TEXT, podfile_lock_hash TEXT, offline_expires_at TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))); CREATE TABLE IF NOT EXISTS patch_channels (id INTEGER PRIMARY KEY AUTOINCREMENT, patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, promoted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), UNIQUE(patch_id, channel_id)); CREATE TABLE IF NOT EXISTS patch_events (id INTEGER PRIMARY KEY AUTOINCREMENT, app_id TEXT NOT NULL, client_id TEXT NOT NULL, arch TEXT NOT NULL, patch_number INTEGER NOT NULL, platform TEXT NOT NULL, release_version TEXT NOT NULL, event_type TEXT NOT NULL, timestamp INTEGER NOT NULL, message TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))); CREATE TABLE IF NOT EXISTS rolled_back_patches (id INTEGER PRIMARY KEY AUTOINCREMENT, release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE, patch_number INTEGER NOT NULL, rolled_back_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), UNIQUE(release_id, patch_number)); @@ -639,6 +652,7 @@ func (s *SqliteStore) migrate(ctx context.Context) error { `ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'password'`, + `ALTER TABLE patch_artifacts ADD COLUMN offline_expires_at TEXT`, } for _, stmt := range alter { if _, err := s.db.ExecContext(ctx, stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { diff --git a/internal/db/store.go b/internal/db/store.go index ea72ffd..c3b923e 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -65,6 +65,15 @@ func (nst *NullShorebirdTime) Scan(v interface{}) error { return nst.Time.Scan(v) } +// Ptr returns a UTC time pointer when valid, otherwise nil. +func (nst NullShorebirdTime) Ptr() *time.Time { + if !nst.Valid { + return nil + } + t := nst.Time.Time.UTC() + return &t +} + // Ensure unused import warnings are suppressed. var _ sql.Scanner = (*ShorebirdTime)(nil) @@ -133,9 +142,10 @@ type Store interface { GetPatchesByReleaseID(ctx context.Context, releaseID int) ([]PatchWithChannelRow, error) PromotePatch(ctx context.Context, patchID, channelID int) error GetLatestPromotedPatch(ctx context.Context, releaseID, channelID int, arch, platform string) (*PatchWithArtifactRow, error) + GetPatchByReleaseNumber(ctx context.Context, releaseID, patchNumber int, arch, platform string) (*PatchWithArtifactRow, error) // --- Patch Artifacts --- - CreatePatchArtifact(ctx context.Context, patchID int, arch, platform, hash, storageKey string, size int64, hashSignature, podfileLockHash *string) (*PatchArtifactRow, error) + CreatePatchArtifact(ctx context.Context, patchID int, arch, platform, hash, storageKey string, size int64, hashSignature, podfileLockHash *string, offlineExpiresAt *time.Time) (*PatchArtifactRow, error) // --- Patch Events --- InsertPatchEvent(ctx context.Context, appID uuid.UUID, clientID, arch, platform, releaseVersion, eventType string, patchNumber int, timestamp int64, message *string) error @@ -253,28 +263,30 @@ type PatchWithChannelRow struct { // PatchWithArtifactRow includes artifact info for patch check. type PatchWithArtifactRow struct { - ID int - ReleaseID int - Number int - Notes *string - CreatedAt ShorebirdTime - Hash string - StorageKey string - HashSignature *string + ID int + ReleaseID int + Number int + Notes *string + CreatedAt ShorebirdTime + Hash string + StorageKey string + HashSignature *string + OfflineExpiresAt NullShorebirdTime } // PatchArtifactRow represents a patch artifact row. type PatchArtifactRow struct { - ID int - PatchID int - Arch string - Platform string - Hash string - Size int64 - StorageKey string - HashSignature *string - PodfileLockHash *string - CreatedAt ShorebirdTime + ID int + PatchID int + Arch string + Platform string + Hash string + Size int64 + StorageKey string + HashSignature *string + PodfileLockHash *string + OfflineExpiresAt NullShorebirdTime + CreatedAt ShorebirdTime } // UserListRow is used by the admin user listing endpoint. diff --git a/internal/models/models.go b/internal/models/models.go index cf8fe84..2ce53b7 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -123,15 +123,16 @@ type PatchArtifact struct { // PatchCheckRequest is the POST /patches/check request body. type PatchCheckRequest struct { - ReleaseVersion string `json:"release_version"` - PatchNumber *int `json:"patch_number"` - PatchHash *string `json:"patch_hash"` - Platform string `json:"platform"` - Arch string `json:"arch"` - AppID string `json:"app_id"` - Channel string `json:"channel"` - ClientID *string `json:"client_id"` - CurrentPatchNumber *int `json:"current_patch_number"` + ReleaseVersion string `json:"release_version"` + PatchNumber *int `json:"patch_number"` + PatchHash *string `json:"patch_hash"` + Platform string `json:"platform"` + Arch string `json:"arch"` + AppID string `json:"app_id"` + Channel string `json:"channel"` + ClientID *string `json:"client_id"` + CurrentPatchNumber *int `json:"current_patch_number"` + AcceptEncryptedPatch bool `json:"accept_encrypted_patch,omitempty"` } // PatchCheckResponse is the POST /patches/check response body. @@ -139,14 +140,41 @@ type PatchCheckResponse struct { PatchAvailable bool `json:"patch_available"` Patch *PatchCheckMetadata `json:"patch"` RolledBackPatchNumbers []int `json:"rolled_back_patch_numbers"` + RemovePatch *PatchRemoval `json:"remove_patch,omitempty"` } // PatchCheckMetadata is the patch metadata returned in patch check. type PatchCheckMetadata struct { - Number int `json:"number"` - DownloadURL string `json:"download_url"` - Hash string `json:"hash"` - HashSignature *string `json:"hash_signature"` + Number int `json:"number"` + DownloadURL string `json:"download_url"` + Hash string `json:"hash"` + HashSignature *string `json:"hash_signature"` + OfflineExpiresAt *time.Time `json:"offline_expires_at,omitempty"` + Encryption *PatchEncryptionMetadata `json:"encryption,omitempty"` +} + +// PatchEncryptionMetadata describes an encrypted patch download. The patch +// hash above remains the plaintext patch hash; EncryptedHash is the hash of +// the downloaded ciphertext. +type PatchEncryptionMetadata struct { + Algorithm string `json:"algorithm"` + KDF string `json:"kdf"` + KeyID string `json:"key_id"` + Nonce string `json:"nonce"` + AAD string `json:"aad"` + AADHash string `json:"aad_hash"` + EncryptedHash string `json:"encrypted_hash"` + DeviceIDHash string `json:"device_id_hash"` + CacheKey string `json:"cache_key"` +} + +// PatchRemoval tells open updaters to remove an installed patch at startup. +// Shorebird-compatible clients can use RolledBackPatchNumbers for the same +// removal decision. +type PatchRemoval struct { + Number int `json:"number"` + Reason string `json:"reason"` + OfflineExpiresAt *time.Time `json:"offline_expires_at,omitempty"` } // CreatePatchEventRequest is sent by devices to report install events. @@ -263,23 +291,25 @@ type CreatePatchResponse struct { // CreatePatchArtifactRequest is the request for uploading a patch artifact. type CreatePatchArtifactRequest struct { - Arch string `json:"arch"` - Platform string `json:"platform"` - Hash string `json:"hash"` - Size int64 `json:"size"` - HashSignature *string `json:"hash_signature"` - PodfileLockHash *string `json:"podfile_lock_hash"` + Arch string `json:"arch"` + Platform string `json:"platform"` + Hash string `json:"hash"` + Size int64 `json:"size"` + HashSignature *string `json:"hash_signature"` + PodfileLockHash *string `json:"podfile_lock_hash"` + OfflineExpiresAt *time.Time `json:"offline_expires_at"` } // CreatePatchArtifactResponse returns the patch artifact metadata. type CreatePatchArtifactResponse struct { - ID int `json:"id"` - PatchID int `json:"patch_id"` - Arch string `json:"arch"` - Platform string `json:"platform"` - Hash string `json:"hash"` - Size int64 `json:"size"` - URL string `json:"url"` + ID int `json:"id"` + PatchID int `json:"patch_id"` + Arch string `json:"arch"` + Platform string `json:"platform"` + Hash string `json:"hash"` + Size int64 `json:"size"` + URL string `json:"url"` + OfflineExpiresAt *time.Time `json:"offline_expires_at,omitempty"` } // UpdateReleaseRequest is the PATCH /releases/{id} request body. diff --git a/internal/storage/store.go b/internal/storage/store.go index 679c43b..21d1b1a 100644 --- a/internal/storage/store.go +++ b/internal/storage/store.go @@ -25,3 +25,10 @@ func GenerateReleaseStorageKey(appID, version, platform, arch, filename string) func GeneratePatchStorageKey(appID string, releaseID int, patchNumber int, platform, arch, filename string) string { return fmt.Sprintf("apps/%s/releases/%d/patches/%d/%s/%s/%s", appID, releaseID, patchNumber, platform, arch, filename) } + +// GenerateDeviceEncryptedPatchStorageKey creates a cache key for a patch +// encrypted specifically for a device. deviceIDHash must be a non-reversible +// hash of the updater device/client ID, not the raw ID. +func GenerateDeviceEncryptedPatchStorageKey(appID string, releaseID int, patchNumber int, platform, arch, patchHash, deviceIDHash, keyID string) string { + return fmt.Sprintf("apps/%s/releases/%d/patches/%d/%s/%s/encrypted/%s/%s/%s.aesgcm", appID, releaseID, patchNumber, platform, arch, deviceIDHash, keyID, patchHash) +}