feat: implement patch expiration and encryption features
- Add offline expiration handling for patch artifacts in the database. - Update CreatePatchArtifact and related functions to accept and return offline expiration timestamps. - Enhance patch check responses to include offline expiration metadata. - Introduce device-specific encryption for patches, allowing for secure delivery. - Implement tests for patch check functionality, including scenarios for expired patches and encrypted delivery. - Modify router and configuration to support new patch delivery settings. - Update database schema and migrations to accommodate new fields for offline expiration.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
+28
-8
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
+18
-4
@@ -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") {
|
||||
|
||||
+31
-19
@@ -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.
|
||||
|
||||
+56
-26
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user