Repository: kopia/kopia
Pull request #4719 (2025-07-15 05:42:39)

feat(general): check for clock skew on write session creation
Check for clock skew between the local clock and the storage clock when the session blob is written (on session creation). Tests included.

The check is currently disabled by default.


Patch:
diff --git a/repo/content/sessions.go b/repo/content/sessions.go
index 3f00a902..e4cf5877 100644
--- a/repo/content/sessions.go
+++ b/repo/content/sessions.go
@@ -5,6 +5,7 @@
 	cryptorand "crypto/rand"
 	"encoding/json"
 	"fmt"
+	"os"
 	"strings"
 	"time"
 
@@ -21,6 +22,8 @@
 
 const sessionIDLength = 8
 
+const maxClockSkew = 5 * time.Minute
+
 // SessionID represents identifier of a session.
 type SessionID string
 
@@ -39,6 +42,21 @@ type SessionInfo struct {
 	sessionIDEpochGranularity = 30 * 24 * time.Hour
 )
 
+// checkClockSkewBounds checks that the lastModified time of the blob is within acceptable bounds of local clock.
+func checkClockSkewBounds(localTime, modTime time.Time) error {
+	clockSkew := modTime.Sub(localTime)
+
+	if clockSkew < 0 {
+		clockSkew = -clockSkew
+	}
+
+	if clockSkew > maxClockSkew {
+		return errors.Errorf("clock skew detected: local clock is out of sync with modTime by more than allowed %v (local: %v modTime: %v skew: %s)", maxClockSkew, localTime, modTime, clockSkew)
+	}
+
+	return nil
+}
+
 // generateSessionID generates a random session identifier.
 func generateSessionID(now time.Time) (SessionID, error) {
 	// generate session ID as {random-64-bit}{epoch-number}
@@ -119,10 +137,19 @@ func (bm *WriteManager) writeSessionMarkerLocked(ctx context.Context) error {
 
 	bm.onUpload(int64(encrypted.Length()))
 
-	if err := bm.st.PutBlob(ctx, sessionBlobID, encrypted.Bytes(), blob.PutOptions{}); err != nil {
+	var modTime time.Time
+	if err := bm.st.PutBlob(ctx, sessionBlobID, encrypted.Bytes(), blob.PutOptions{GetModTime: &modTime}); err != nil {
 		return errors.Wrapf(err, "unable to write session marker: %v", string(sessionBlobID))
 	}
 
+	if v, found := os.LookupEnv("KOPIA_ENABLE_CLOCK_SKEW_CHECK"); found {
+		if !strings.EqualFold(v, "false") && v != "0" {
+			if skewError := checkClockSkewBounds(bm.timeNow(), modTime); skewError != nil {
+				return errors.Wrap(skewError, "while writing session marker")
+			}
+		}
+	}
+
 	bm.sessionMarkerBlobIDs = append(bm.sessionMarkerBlobIDs, sessionBlobID)
 
 	return nil


Test patch:
diff --git a/repo/content/sessions_test.go b/repo/content/sessions_test.go
index 16c1da95..73d3e57a 100644
--- a/repo/content/sessions_test.go
+++ b/repo/content/sessions_test.go
@@ -2,11 +2,18 @@
 
 import (
 	"testing"
+	"time"
 
 	"github.com/stretchr/testify/require"
 
+	"github.com/kopia/kopia/internal/blobtesting"
 	"github.com/kopia/kopia/internal/clock"
+	"github.com/kopia/kopia/internal/epoch"
+	"github.com/kopia/kopia/internal/faketime"
+	"github.com/kopia/kopia/internal/testlogging"
 	"github.com/kopia/kopia/repo/blob"
+	"github.com/kopia/kopia/repo/content/index"
+	"github.com/kopia/kopia/repo/format"
 )
 
 func TestGenerateSessionID(t *testing.T) {
@@ -51,3 +58,83 @@ func TestSessionIDFromBlobID(t *testing.T) {
 		}
 	}
 }
+
+func TestCheckClockSkewBounds_Positive(t *testing.T) {
+	now := clock.Now()
+	modTime := now.Add(maxClockSkew) // within maxClockSkew
+
+	err := checkClockSkewBounds(now, modTime)
+	require.NoError(t, err)
+}
+
+func TestCheckClockSkewBounds_Negative(t *testing.T) {
+	now := clock.Now()
+	modTime := now.Add(maxClockSkew + time.Nanosecond) // exceeds maxClockSkew
+
+	err := checkClockSkewBounds(now, modTime)
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "clock skew detected")
+}
+
+func TestWriteSessionMarkerLockedWithoutClockSkew(t *testing.T) {
+	t.Setenv("KOPIA_ENABLE_CLOCK_SKEW_CHECK", "1")
+
+	ctx := testlogging.Context(t)
+	data := blobtesting.DataMap{}
+	keyTime := map[blob.ID]time.Time{}
+
+	// Use TimeAdvance to control the timeNow() in the map storage.
+	ta := faketime.NewTimeAdvance(time.Date(2018, time.January, 1, 0, 0, 0, 0, time.UTC))
+	st := blobtesting.NewMapStorage(data, keyTime, ta.NowFunc())
+
+	bm, err := NewManagerForTesting(testlogging.Context(t), st, mustCreateFormatProvider(t, &format.ContentFormat{
+		Hash:       "HMAC-SHA256-128",
+		Encryption: "AES256-GCM-HMAC-SHA256",
+		HMACSecret: []byte("foo"),
+		MasterKey:  []byte("0123456789abcdef0123456789abcdef"),
+		MutableParameters: format.MutableParameters{
+			Version:         2,
+			MaxPackSize:     maxPackSize,
+			IndexVersion:    index.Version2,
+			EpochParameters: epoch.DefaultParameters(),
+		},
+	}), nil, &ManagerOptions{TimeNow: ta.NowFunc()}) // Use the same time advance for the manager's timeNow().
+	require.NoError(t, err, "can't create bm")
+
+	t.Cleanup(func() { bm.CloseShared(ctx) })
+
+	err = bm.writeSessionMarkerLocked(ctx)
+	require.NoError(t, err)
+}
+
+func TestWriteSessionMarkerLockedWithClockSkew(t *testing.T) {
+	t.Setenv("KOPIA_ENABLE_CLOCK_SKEW_CHECK", "1")
+
+	ctx := testlogging.Context(t)
+	data := blobtesting.DataMap{}
+	keyTime := map[blob.ID]time.Time{}
+
+	bmTime := faketime.NewTimeAdvance(time.Date(2018, time.January, 1, 0, 0, 0, 0, time.UTC))
+	stTime := faketime.NewTimeAdvance(time.Date(2018, time.January, 1, 0, int(maxClockSkew), 0, 0, time.UTC))
+	st := blobtesting.NewMapStorage(data, keyTime, stTime.NowFunc())
+
+	bm, err := NewManagerForTesting(testlogging.Context(t), st, mustCreateFormatProvider(t, &format.ContentFormat{
+		Hash:       "HMAC-SHA256-128",
+		Encryption: "AES256-GCM-HMAC-SHA256",
+		HMACSecret: []byte("foo"),
+		MasterKey:  []byte("0123456789abcdef0123456789abcdef"),
+		MutableParameters: format.MutableParameters{
+			Version:         2,
+			MaxPackSize:     maxPackSize,
+			IndexVersion:    index.Version2,
+			EpochParameters: epoch.DefaultParameters(),
+		},
+	}), nil, &ManagerOptions{TimeNow: bmTime.NowFunc()})
+	require.NoError(t, err, "can't create bm")
+
+	t.Cleanup(func() { bm.CloseShared(ctx) })
+
+	err = bm.writeSessionMarkerLocked(ctx)
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "clock skew detected")
+}