From 739543a46ccb6f8a7e4823b00ab9d13ef99ab669 Mon Sep 17 00:00:00 2001 From: tian Date: Tue, 14 Jul 2026 11:25:37 +0800 Subject: [PATCH 01/13] feat: add end-to-end encrypted device sync --- .github/workflows/release.yml | 19 +- .github/workflows/sync-worker-ci.yml | 48 + .../sync-worker-deploy-production.yml | 68 + .../workflows/sync-worker-deploy-staging.yml | 59 + .github/workflows/windows-sync-tests.yml | 35 + .../KeyStats.Sync.Tests/AssemblyAttributes.cs | 3 + .../KeyStats.Sync.Tests.csproj | 28 + .../RemoteShardCacheTests.cs | 97 + .../SyncBatchPlannerTests.cs | 177 + .../SyncCoordinatorFailureTests.cs | 627 + .../SyncCoordinatorHistoryResumeTests.cs | 184 + .../SyncCredentialStoreTests.cs | 112 + .../SyncCryptoFixtureTests.cs | 144 + .../SyncSchedulingTests.cs | 97 + .../SyncStateStoreTests.cs | 101 + .../KeyStats.Sync.Tests/TestDirectory.cs | 32 + .../KeyStats.Sync.Tests/TestPlatform.cs | 15 + KeyStats.Windows/KeyStats.sln | 7 +- KeyStats.Windows/KeyStats/App.xaml.cs | 35 + KeyStats.Windows/KeyStats/KeyStats.csproj | 11 + .../KeyStats/Models/SyncModels.cs | 362 + .../KeyStats/Properties/Strings.cs | 51 + .../KeyStats/Properties/Strings.resx | 52 +- .../KeyStats/Properties/Strings.zh-Hans.resx | 52 +- .../KeyStats/Properties/Strings.zh-Hant.resx | 52 +- .../Services/DisplayStatsAggregator.cs | 92 + .../KeyStats/Services/RemoteShardCache.cs | 313 + .../KeyStats/Services/StatsManager.cs | 75 +- .../KeyStats/Services/SyncBatchPlanner.cs | 111 + .../KeyStats/Services/SyncCoordinator.cs | 3004 ++++ .../KeyStats/Services/SyncCredentialStore.cs | 161 + .../KeyStats/Services/SyncCrypto.cs | 481 + .../KeyStats/Services/SyncKeyCanonicalizer.cs | 89 + .../Services/SyncPendingSecretsStore.cs | 155 + .../Services/SyncServiceConfiguration.cs | 58 + .../KeyStats/Services/SyncStateStore.cs | 209 + .../KeyStats/Services/SyncTransport.cs | 411 + .../KeyStats/Views/SettingsWindow.xaml | 19 +- .../KeyStats/Views/SettingsWindow.xaml.cs | 53 + .../KeyStats/Views/SyncSettingsWindow.xaml | 162 + .../KeyStats/Views/SyncSettingsWindow.xaml.cs | 436 + KeyStats.Windows/build.ps1 | 15 +- KeyStats.xcodeproj/project.pbxproj | 39 + KeyStats/AllTimeStatsViewController.swift | 10 +- KeyStats/AppDelegate.swift | 1 + KeyStats/Info.plist | 2 + KeyStats/SettingsViewController.swift | 70 +- KeyStats/StatsManager.swift | 114 +- KeyStats/StatsModels.swift | 40 +- KeyStats/Sync/DisplayStatsAggregator.swift | 75 + KeyStats/Sync/SyncCoordinator.swift | 1621 ++ KeyStats/Sync/SyncCrypto.swift | 640 + KeyStats/Sync/SyncModels.swift | 543 + .../Sync/SyncSettingsWindowController.swift | 597 + KeyStats/Sync/SyncStorage.swift | 262 + KeyStats/Sync/SyncTransport.swift | 362 + KeyStats/en.lproj/Localizable.strings | 95 + KeyStats/zh-Hans.lproj/Localizable.strings | 95 + KeyStats/zh-Hant.lproj/Localizable.strings | 333 + KeyStatsTests/StatsModelsTests.swift | 1 + KeyStatsTests/SyncCoreTests.swift | 552 + Package.swift | 22 +- contracts/sync/v1/README.md | 105 + contracts/sync/v1/api.schema.json | 159 + .../sync/v1/core-day-snapshot.schema.json | 64 + .../sync/v1/encrypted-record.schema.json | 53 + .../sync/v1/fixtures/crypto-vectors.json | 86 + .../v1/fixtures/key-canonicalization.json | 40 + contracts/sync/v1/openapi.yaml | 559 + .../superpowers/plans/2026-07-13-e2ee-sync.md | 79 + .../specs/2026-07-13-e2ee-sync-design.md | 187 + scripts/build_dmg.sh | 9 +- services/sync-worker/.gitignore | 8 + services/sync-worker/README.md | 121 + .../sync-worker/migrations/0001_initial.sql | 101 + .../migrations/0002_active_device_limit.sql | 10 + .../0003_history_revision_monotonic.sql | 12 + .../0004_exempt_sync_page_limit.sql | 3 + .../migrations/0005_maintenance_state.sql | 5 + .../0006_recovery_and_pairing_replay.sql | 16 + .../0007_vault_deletion_receipts.sql | 9 + services/sync-worker/package-lock.json | 3293 ++++ services/sync-worker/package.json | 26 + .../scripts/validate-contracts.mjs | 304 + .../scripts/write-deploy-config.mjs | 19 + services/sync-worker/src/auth.ts | 125 + services/sync-worker/src/cleanup.ts | 271 + services/sync-worker/src/crypto.ts | 184 + services/sync-worker/src/env.ts | 29 + services/sync-worker/src/history.ts | 231 + services/sync-worker/src/http.ts | 155 + services/sync-worker/src/index.ts | 41 + services/sync-worker/src/routes.ts | 919 + services/sync-worker/src/sync.ts | 427 + services/sync-worker/src/types.ts | 98 + services/sync-worker/src/validation.ts | 94 + services/sync-worker/test/apply-migrations.ts | 24 + services/sync-worker/test/env.d.ts | 11 + services/sync-worker/test/worker.test.ts | 1207 ++ services/sync-worker/tsconfig.json | 16 + services/sync-worker/vitest.config.ts | 21 + .../sync-worker/worker-configuration.d.ts | 14731 ++++++++++++++++ services/sync-worker/wrangler.jsonc | 79 + 103 files changed, 37631 insertions(+), 86 deletions(-) create mode 100644 .github/workflows/sync-worker-ci.yml create mode 100644 .github/workflows/sync-worker-deploy-production.yml create mode 100644 .github/workflows/sync-worker-deploy-staging.yml create mode 100644 .github/workflows/windows-sync-tests.yml create mode 100644 KeyStats.Windows/KeyStats.Sync.Tests/AssemblyAttributes.cs create mode 100644 KeyStats.Windows/KeyStats.Sync.Tests/KeyStats.Sync.Tests.csproj create mode 100644 KeyStats.Windows/KeyStats.Sync.Tests/RemoteShardCacheTests.cs create mode 100644 KeyStats.Windows/KeyStats.Sync.Tests/SyncBatchPlannerTests.cs create mode 100644 KeyStats.Windows/KeyStats.Sync.Tests/SyncCoordinatorFailureTests.cs create mode 100644 KeyStats.Windows/KeyStats.Sync.Tests/SyncCoordinatorHistoryResumeTests.cs create mode 100644 KeyStats.Windows/KeyStats.Sync.Tests/SyncCredentialStoreTests.cs create mode 100644 KeyStats.Windows/KeyStats.Sync.Tests/SyncCryptoFixtureTests.cs create mode 100644 KeyStats.Windows/KeyStats.Sync.Tests/SyncSchedulingTests.cs create mode 100644 KeyStats.Windows/KeyStats.Sync.Tests/SyncStateStoreTests.cs create mode 100644 KeyStats.Windows/KeyStats.Sync.Tests/TestDirectory.cs create mode 100644 KeyStats.Windows/KeyStats.Sync.Tests/TestPlatform.cs create mode 100644 KeyStats.Windows/KeyStats/Models/SyncModels.cs create mode 100644 KeyStats.Windows/KeyStats/Services/DisplayStatsAggregator.cs create mode 100644 KeyStats.Windows/KeyStats/Services/RemoteShardCache.cs create mode 100644 KeyStats.Windows/KeyStats/Services/SyncBatchPlanner.cs create mode 100644 KeyStats.Windows/KeyStats/Services/SyncCoordinator.cs create mode 100644 KeyStats.Windows/KeyStats/Services/SyncCredentialStore.cs create mode 100644 KeyStats.Windows/KeyStats/Services/SyncCrypto.cs create mode 100644 KeyStats.Windows/KeyStats/Services/SyncKeyCanonicalizer.cs create mode 100644 KeyStats.Windows/KeyStats/Services/SyncPendingSecretsStore.cs create mode 100644 KeyStats.Windows/KeyStats/Services/SyncServiceConfiguration.cs create mode 100644 KeyStats.Windows/KeyStats/Services/SyncStateStore.cs create mode 100644 KeyStats.Windows/KeyStats/Services/SyncTransport.cs create mode 100644 KeyStats.Windows/KeyStats/Views/SyncSettingsWindow.xaml create mode 100644 KeyStats.Windows/KeyStats/Views/SyncSettingsWindow.xaml.cs create mode 100644 KeyStats/Sync/DisplayStatsAggregator.swift create mode 100644 KeyStats/Sync/SyncCoordinator.swift create mode 100644 KeyStats/Sync/SyncCrypto.swift create mode 100644 KeyStats/Sync/SyncModels.swift create mode 100644 KeyStats/Sync/SyncSettingsWindowController.swift create mode 100644 KeyStats/Sync/SyncStorage.swift create mode 100644 KeyStats/Sync/SyncTransport.swift create mode 100644 KeyStats/zh-Hant.lproj/Localizable.strings create mode 100644 KeyStatsTests/SyncCoreTests.swift create mode 100644 contracts/sync/v1/README.md create mode 100644 contracts/sync/v1/api.schema.json create mode 100644 contracts/sync/v1/core-day-snapshot.schema.json create mode 100644 contracts/sync/v1/encrypted-record.schema.json create mode 100644 contracts/sync/v1/fixtures/crypto-vectors.json create mode 100644 contracts/sync/v1/fixtures/key-canonicalization.json create mode 100644 contracts/sync/v1/openapi.yaml create mode 100644 docs/superpowers/plans/2026-07-13-e2ee-sync.md create mode 100644 docs/superpowers/specs/2026-07-13-e2ee-sync-design.md create mode 100644 services/sync-worker/.gitignore create mode 100644 services/sync-worker/README.md create mode 100644 services/sync-worker/migrations/0001_initial.sql create mode 100644 services/sync-worker/migrations/0002_active_device_limit.sql create mode 100644 services/sync-worker/migrations/0003_history_revision_monotonic.sql create mode 100644 services/sync-worker/migrations/0004_exempt_sync_page_limit.sql create mode 100644 services/sync-worker/migrations/0005_maintenance_state.sql create mode 100644 services/sync-worker/migrations/0006_recovery_and_pairing_replay.sql create mode 100644 services/sync-worker/migrations/0007_vault_deletion_receipts.sql create mode 100644 services/sync-worker/package-lock.json create mode 100644 services/sync-worker/package.json create mode 100644 services/sync-worker/scripts/validate-contracts.mjs create mode 100644 services/sync-worker/scripts/write-deploy-config.mjs create mode 100644 services/sync-worker/src/auth.ts create mode 100644 services/sync-worker/src/cleanup.ts create mode 100644 services/sync-worker/src/crypto.ts create mode 100644 services/sync-worker/src/env.ts create mode 100644 services/sync-worker/src/history.ts create mode 100644 services/sync-worker/src/http.ts create mode 100644 services/sync-worker/src/index.ts create mode 100644 services/sync-worker/src/routes.ts create mode 100644 services/sync-worker/src/sync.ts create mode 100644 services/sync-worker/src/types.ts create mode 100644 services/sync-worker/src/validation.ts create mode 100644 services/sync-worker/test/apply-migrations.ts create mode 100644 services/sync-worker/test/env.d.ts create mode 100644 services/sync-worker/test/worker.test.ts create mode 100644 services/sync-worker/tsconfig.json create mode 100644 services/sync-worker/vitest.config.ts create mode 100644 services/sync-worker/worker-configuration.d.ts create mode 100644 services/sync-worker/wrangler.jsonc diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab3a712..7be528f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,8 @@ jobs: # ============================================================ build-macos: runs-on: macos-26 + env: + KEYSTATS_SYNC_SERVICE_URL: ${{ vars.KEYSTATS_SYNC_PRODUCTION_URL }} outputs: version: ${{ steps.version.outputs.VERSION }} sha256: ${{ steps.sha256.outputs.SHA256 }} @@ -46,7 +48,12 @@ jobs: run: ./scripts/check_vendored_helper.sh - name: Build DMG - run: ./scripts/build_dmg.sh + run: | + if [[ ! "$KEYSTATS_SYNC_SERVICE_URL" =~ ^https://[A-Za-z0-9.-]+\.workers\.dev/?$ ]]; then + echo "KEYSTATS_SYNC_PRODUCTION_URL must be a production workers.dev URL." + exit 1 + fi + ./scripts/build_dmg.sh - name: Validate Sparkle private key env: @@ -93,7 +100,8 @@ jobs: archive \ CODE_SIGN_IDENTITY="-" \ ARCHS="arm64 x86_64" \ - ONLY_ACTIVE_ARCH=NO + ONLY_ACTIVE_ARCH=NO \ + KEYSTATS_SYNC_SERVICE_URL="$KEYSTATS_SYNC_SERVICE_URL" APP_PATH="$ARCHIVE_PATH/Products/Applications/$APP_NAME.app" cp -R "$APP_PATH" "$STAGING_DIR/" @@ -139,6 +147,8 @@ jobs: # ============================================================ build-windows: runs-on: windows-latest + env: + KEYSTATS_SYNC_SERVICE_URL: ${{ vars.KEYSTATS_SYNC_PRODUCTION_URL }} outputs: version: ${{ steps.version.outputs.VERSION }} @@ -168,7 +178,10 @@ jobs: shell: pwsh working-directory: KeyStats.Windows run: | - .\build.ps1 -Configuration Release + if ($env:KEYSTATS_SYNC_SERVICE_URL -notmatch '^https://[A-Za-z0-9.-]+\.workers\.dev/?$') { + throw 'KEYSTATS_SYNC_PRODUCTION_URL must be a production workers.dev URL.' + } + .\build.ps1 -Configuration Release -SyncServiceBaseUrl $env:KEYSTATS_SYNC_SERVICE_URL - name: Upload Windows artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/sync-worker-ci.yml b/.github/workflows/sync-worker-ci.yml new file mode 100644 index 0000000..1055e7a --- /dev/null +++ b/.github/workflows/sync-worker-ci.yml @@ -0,0 +1,48 @@ +name: Sync Worker CI + +on: + pull_request: + paths: + - 'contracts/sync/v1/**' + - 'services/sync-worker/**' + - '.github/workflows/sync-worker-*.yml' + push: + branches-ignore: [main] + paths: + - 'contracts/sync/v1/**' + - 'services/sync-worker/**' + - '.github/workflows/sync-worker-*.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: sync-worker-ci-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + working-directory: services/sync-worker + +jobs: + validate: + name: Contract, migration, type and Worker tests + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: services/sync-worker/package-lock.json + - run: npm ci + - name: Validate schemas, OpenAPI and golden vectors + run: npm run contracts:check + - name: Apply D1 migrations to an isolated local database + run: npx wrangler d1 migrations apply DB --local + - name: Type-check Worker + run: npm run types:check && npm run typecheck + - name: Run Miniflare and unit tests + run: npm test diff --git a/.github/workflows/sync-worker-deploy-production.yml b/.github/workflows/sync-worker-deploy-production.yml new file mode 100644 index 0000000..7085efb --- /dev/null +++ b/.github/workflows/sync-worker-deploy-production.yml @@ -0,0 +1,68 @@ +name: Deploy Sync Worker Production + +on: + workflow_dispatch: + inputs: + confirmation: + description: 'Type deploy-production after staging verification' + required: true + type: string + +permissions: + contents: read + +concurrency: + group: sync-worker-production + cancel-in-progress: false + +defaults: + run: + working-directory: services/sync-worker + +jobs: + authorize: + name: Validate explicit confirmation + runs-on: ubuntu-latest + steps: + - name: Require exact production confirmation + if: ${{ inputs.confirmation != 'deploy-production' }} + run: exit 1 + + deploy: + name: Validate and deploy production + needs: authorize + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: sync-production + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_D1_DATABASE_ID: ${{ vars.CLOUDFLARE_D1_DATABASE_ID }} + TOKEN_HASH_KEY: ${{ secrets.TOKEN_HASH_KEY }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: services/sync-worker/package-lock.json + - run: npm ci + - run: npm run check + - name: Validate deployment secrets + run: | + if [ -z "$CLOUDFLARE_API_TOKEN" ] || [ -z "$CLOUDFLARE_ACCOUNT_ID" ]; then + echo "Cloudflare deployment credentials are not configured." + exit 1 + fi + if [ "${#TOKEN_HASH_KEY}" -lt 32 ]; then + echo "TOKEN_HASH_KEY must contain at least 32 characters." + exit 1 + fi + - name: Prepare bound production configuration + run: node scripts/write-deploy-config.mjs production wrangler.deploy.json + - name: Apply production D1 migrations + run: npx wrangler d1 migrations apply DB --env production --remote --config wrangler.deploy.json + - name: Configure token hashing secret + run: printf '%s' "$TOKEN_HASH_KEY" | npx wrangler secret put TOKEN_HASH_KEY --env production --config wrangler.deploy.json + - name: Deploy production Worker + run: npx wrangler deploy --env production --config wrangler.deploy.json diff --git a/.github/workflows/sync-worker-deploy-staging.yml b/.github/workflows/sync-worker-deploy-staging.yml new file mode 100644 index 0000000..4598567 --- /dev/null +++ b/.github/workflows/sync-worker-deploy-staging.yml @@ -0,0 +1,59 @@ +name: Deploy Sync Worker Staging + +on: + push: + branches: [main] + paths: + - 'contracts/sync/v1/**' + - 'services/sync-worker/**' + - '.github/workflows/sync-worker-*.yml' + +permissions: + contents: read + +concurrency: + group: sync-worker-staging + cancel-in-progress: false + +defaults: + run: + working-directory: services/sync-worker + +jobs: + deploy: + name: Validate and deploy staging + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: sync-staging + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_D1_DATABASE_ID: ${{ vars.CLOUDFLARE_D1_DATABASE_ID }} + TOKEN_HASH_KEY: ${{ secrets.TOKEN_HASH_KEY }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: services/sync-worker/package-lock.json + - run: npm ci + - run: npm run check + - name: Validate deployment secrets + run: | + if [ -z "$CLOUDFLARE_API_TOKEN" ] || [ -z "$CLOUDFLARE_ACCOUNT_ID" ]; then + echo "Cloudflare deployment credentials are not configured." + exit 1 + fi + if [ "${#TOKEN_HASH_KEY}" -lt 32 ]; then + echo "TOKEN_HASH_KEY must contain at least 32 characters." + exit 1 + fi + - name: Prepare bound staging configuration + run: node scripts/write-deploy-config.mjs staging wrangler.deploy.json + - name: Apply staging D1 migrations + run: npx wrangler d1 migrations apply DB --env staging --remote --config wrangler.deploy.json + - name: Configure token hashing secret + run: printf '%s' "$TOKEN_HASH_KEY" | npx wrangler secret put TOKEN_HASH_KEY --env staging --config wrangler.deploy.json + - name: Deploy staging Worker + run: npx wrangler deploy --env staging --config wrangler.deploy.json diff --git a/.github/workflows/windows-sync-tests.yml b/.github/workflows/windows-sync-tests.yml new file mode 100644 index 0000000..ab7a4ea --- /dev/null +++ b/.github/workflows/windows-sync-tests.yml @@ -0,0 +1,35 @@ +name: Windows Sync Tests + +on: + pull_request: + paths: + - 'KeyStats.Windows/**' + - 'contracts/sync/v1/**' + - '.github/workflows/windows-sync-tests.yml' + push: + branches: [main] + paths: + - 'KeyStats.Windows/**' + - 'contracts/sync/v1/**' + - '.github/workflows/windows-sync-tests.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: windows-sync-tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: .NET Framework sync tests + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + - name: Run Windows sync tests + run: dotnet test KeyStats.Windows/KeyStats.Sync.Tests/KeyStats.Sync.Tests.csproj --configuration Release --verbosity normal diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/AssemblyAttributes.cs b/KeyStats.Windows/KeyStats.Sync.Tests/AssemblyAttributes.cs new file mode 100644 index 0000000..3119b2d --- /dev/null +++ b/KeyStats.Windows/KeyStats.Sync.Tests/AssemblyAttributes.cs @@ -0,0 +1,3 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[assembly: DoNotParallelize] diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/KeyStats.Sync.Tests.csproj b/KeyStats.Windows/KeyStats.Sync.Tests/KeyStats.Sync.Tests.csproj new file mode 100644 index 0000000..99fc3e6 --- /dev/null +++ b/KeyStats.Windows/KeyStats.Sync.Tests/KeyStats.Sync.Tests.csproj @@ -0,0 +1,28 @@ + + + + net48 + 10.0 + enable + disable + false + true + + + + + + + + + + + + + + + + + diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/RemoteShardCacheTests.cs b/KeyStats.Windows/KeyStats.Sync.Tests/RemoteShardCacheTests.cs new file mode 100644 index 0000000..95e1366 --- /dev/null +++ b/KeyStats.Windows/KeyStats.Sync.Tests/RemoteShardCacheTests.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using KeyStats.Models; +using KeyStats.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace KeyStats.Sync.Tests; + +[TestClass] +public sealed class RemoteShardCacheTests +{ + [TestMethod] + public void SameRevision_IsIdempotentAndNewRevisionReplacesAggregate() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + var cache = new RemoteShardCache(directory.Path); + var aggregator = new DisplayStatsAggregator(cache, () => "local-device"); + var first = CreateRecord(revision: 1, ciphertextHash: "hash-1", keyPresses: 5); + + Assert.IsTrue(cache.Apply(first)); + Assert.IsFalse(cache.Apply(CreateRecord(revision: 1, ciphertextHash: "hash-1", keyPresses: 5))); + + var local = new DailyStats(new DateTime(2026, 7, 13)) + { + KeyPresses = 2, + LeftClicks = 1, + KeyPressCounts = new Dictionary(StringComparer.Ordinal) { ["A"] = 2 } + }; + var firstAggregate = aggregator.Aggregate(local.Date, local); + Assert.AreEqual(7, firstAggregate.KeyPresses); + Assert.AreEqual(4, firstAggregate.LeftClicks); + Assert.AreEqual(7, firstAggregate.KeyPressCounts["A"]); + + Assert.IsTrue(cache.Apply(CreateRecord(revision: 2, ciphertextHash: "hash-2", keyPresses: 8))); + Assert.IsFalse(cache.Apply(CreateRecord(revision: 2, ciphertextHash: "hash-2", keyPresses: 8))); + var secondAggregate = aggregator.Aggregate(local.Date, local); + Assert.AreEqual(10, secondAggregate.KeyPresses); + Assert.AreEqual(7, secondAggregate.LeftClicks); + Assert.AreEqual(10, secondAggregate.KeyPressCounts["A"]); + Assert.AreEqual(1, cache.GetAll().Count); + + Assert.ThrowsExactly(() => + cache.Apply(CreateRecord(revision: 2, ciphertextHash: "conflict", keyPresses: 8))); + } + + [TestMethod] + public void Save_IsDurableAndReloadsLatestProtectedCache() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + var path = Path.Combine(directory.Path, "sync_cache.json"); + var cache = new RemoteShardCache(directory.Path); + + Assert.IsTrue(cache.Apply(CreateRecord(revision: 1, ciphertextHash: "hash-1", keyPresses: 5))); + Assert.IsTrue(cache.Apply(CreateRecord(revision: 2, ciphertextHash: "hash-2", keyPresses: 8))); + + Assert.IsTrue(File.Exists(path)); + Assert.IsTrue(File.Exists(path + ".bak")); + Assert.IsFalse(File.Exists(path + ".tmp")); + + var reloaded = new RemoteShardCache(directory.Path); + Assert.IsFalse(reloaded.NeedsRepair); + var record = reloaded.GetAll().Single(); + Assert.AreEqual(2L, record.Revision); + Assert.AreEqual("hash-2", record.CiphertextHash); + Assert.AreEqual(8L, record.Plaintext.KeyPresses); + + File.WriteAllText(path, "damaged"); + var recovered = new RemoteShardCache(directory.Path); + Assert.IsFalse(recovered.NeedsRepair); + Assert.AreEqual(1L, recovered.GetAll().Single().Revision); + } + + private static CachedRemoteRecord CreateRecord(long revision, string ciphertextHash, long keyPresses) + { + return new CachedRemoteRecord + { + RecordId = "record-1", + DeviceId = "remote-device", + Revision = revision, + CiphertextHash = ciphertextHash, + IsCurrent = true, + Plaintext = new CoreDaySnapshotV1 + { + DeviceId = "remote-device", + LocalDay = "2026-07-13", + Revision = revision, + KeyPresses = keyPresses, + KeyPressCounts = new Dictionary(StringComparer.Ordinal) { ["A"] = keyPresses }, + Clicks = new CoreClickSnapshotV1 { Left = keyPresses - 2 } + } + }; + } +} diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/SyncBatchPlannerTests.cs b/KeyStats.Windows/KeyStats.Sync.Tests/SyncBatchPlannerTests.cs new file mode 100644 index 0000000..ce265d9 --- /dev/null +++ b/KeyStats.Windows/KeyStats.Sync.Tests/SyncBatchPlannerTests.cs @@ -0,0 +1,177 @@ +using System; +using System.Linq; +using System.Reflection; +using KeyStats.Models; +using KeyStats.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace KeyStats.Sync.Tests; + +[TestClass] +public sealed class SyncBatchPlannerTests +{ + [DataTestMethod] + [DataRow(0, 1, "0")] + [DataRow(16, 1, "16")] + [DataRow(17, 2, "16,1")] + [DataRow(35, 3, "16,16,3")] + public void Bootstrap_SplitsArchivesAndOnlyMarksFinalBatchComplete( + int archiveCount, + int expectedBatchCount, + string expectedSizes) + { + var request = CreateRequest("bootstrap", archiveCount); + + var batches = SyncBatchPlanner.CreateBatches(request); + + Assert.AreEqual(expectedBatchCount, batches.Count); + Assert.AreEqual(expectedSizes, string.Join(",", batches.Select(batch => batch.Archives.Count))); + for (var index = 0; index < batches.Count - 1; index++) + { + Assert.IsFalse(batches[index].BootstrapComplete); + Assert.IsNull(batches[index].CurrentSnapshot); + Assert.IsNull(batches[index].EncryptedDeviceProfile); + } + Assert.IsTrue(batches[batches.Count - 1].BootstrapComplete); + Assert.AreSame(request.CurrentSnapshot, batches[batches.Count - 1].CurrentSnapshot); + Assert.AreSame(request.EncryptedDeviceProfile, batches[batches.Count - 1].EncryptedDeviceProfile); + CollectionAssert.AreEqual( + request.Archives.Select(record => record.RecordId).ToArray(), + batches.SelectMany(batch => batch.Archives).Select(record => record.RecordId).ToArray()); + } + + [TestMethod] + public void Bootstrap_AllowsProtocolMaximumAndRejectsOverflowBeforeSending() + { + var maximum = CreateRequest("recovery", SyncProtocol.MaximumBootstrapArchives); + var overflow = CreateRequest("pairing", SyncProtocol.MaximumBootstrapArchives + 1); + + var batches = SyncBatchPlanner.CreateBatches(maximum); + + Assert.AreEqual(SyncProtocol.MaximumBootstrapRequests, batches.Count); + Assert.IsTrue(batches[batches.Count - 1].BootstrapComplete); + Assert.ThrowsExactly(() => SyncBatchPlanner.CreateBatches(overflow)); + } + + [DataTestMethod] + [DataRow("manual")] + [DataRow("automatic")] + public void OrdinarySync_UsesOneCompleteOldestFirstBatch(string reason) + { + var request = CreateRequest(reason, 35); + request.BootstrapComplete = false; + + var batches = SyncBatchPlanner.CreateBatches(request); + + Assert.AreEqual(1, batches.Count); + Assert.IsTrue(batches[0].BootstrapComplete); + Assert.AreEqual(SyncProtocol.MaximumArchivesPerRequest, batches[0].Archives.Count); + CollectionAssert.AreEqual( + Enumerable.Range(0, SyncProtocol.MaximumArchivesPerRequest) + .Select(index => "record-" + index) + .ToArray(), + batches[0].Archives.Select(record => record.RecordId).ToArray()); + Assert.AreSame(request.CurrentSnapshot, batches[0].CurrentSnapshot); + Assert.AreSame(request.EncryptedDeviceProfile, batches[0].EncryptedDeviceProfile); + } + + [TestMethod] + public void OrdinarySync_CrossDayBacklogStartsWithExactAcknowledgedCurrentArchive() + { + var request = CreateRequest("manual", 35); + request.CurrentSnapshot = CreateRecord("today-current"); + var acknowledgedCurrent = CreateRecord("record-34"); + acknowledgedCurrent.Revision = 1; + acknowledgedCurrent.CiphertextHash = "acknowledged-current-hash"; + request.Archives[34].Revision = 2; + request.Archives[34].CiphertextHash = "newer-local-history-hash"; + + var batches = SyncBatchPlanner.CreateBatches(request, acknowledgedCurrent); + + Assert.AreEqual(1, batches.Count); + Assert.AreEqual(SyncProtocol.MaximumArchivesPerRequest, batches[0].Archives.Count); + Assert.AreSame(acknowledgedCurrent, batches[0].Archives[0]); + Assert.AreEqual("acknowledged-current-hash", batches[0].Archives[0].CiphertextHash); + CollectionAssert.AreEqual( + Enumerable.Range(0, SyncProtocol.MaximumArchivesPerRequest - 1) + .Select(index => "record-" + index) + .ToArray(), + batches[0].Archives.Skip(1).Select(record => record.RecordId).ToArray()); + Assert.IsFalse(batches[0].Archives.Any(record => + string.Equals(record.CiphertextHash, "newer-local-history-hash", StringComparison.Ordinal))); + Assert.AreSame(request.CurrentSnapshot, batches[0].CurrentSnapshot); + } + + [TestMethod] + public void SyncRequest_DefaultsToCompletedBootstrap() + { + Assert.IsTrue(new SyncRequest().BootstrapComplete); + } + + [TestMethod] + public void Transport_RejectsOversizedOrIncompleteOrdinaryRequestsBeforeNetwork() + { + using var transport = new CloudflareSyncTransport(new Uri("https://unit-tests.workers.dev/")); + var oversized = CreateRequest("bootstrap", SyncProtocol.MaximumArchivesPerRequest + 1); + var incompleteManual = CreateRequest("manual", 0); + incompleteManual.BootstrapComplete = false; + + Assert.ThrowsExactly(() => + transport.SyncAsync(oversized, "token", "key", default)); + Assert.ThrowsExactly(() => + transport.SyncAsync(incompleteManual, "token", "key", default)); + } + + [TestMethod] + public void IdempotencyFingerprint_IncludesBootstrapCompletionFlag() + { + var serializer = typeof(SyncCoordinator) + .GetMethods(BindingFlags.Static | BindingFlags.NonPublic) + .Single(method => + method.Name == "SerializeCanonical" && + method.GetParameters().Length == 1 && + method.GetParameters()[0].ParameterType == typeof(SyncRequest)); + var request = CreateRequest("bootstrap", 1); + request.BootstrapComplete = true; + var completedBytes = (byte[])serializer.Invoke(null, new object[] { request })!; + request.BootstrapComplete = false; + var incompleteBytes = (byte[])serializer.Invoke(null, new object[] { request })!; + + Assert.AreNotEqual( + SyncCrypto.Sha256Base64Url(completedBytes), + SyncCrypto.Sha256Base64Url(incompleteBytes)); + } + + private static SyncRequest CreateRequest(string reason, int archiveCount) + { + return new SyncRequest + { + Reason = reason, + HistoryCursor = 7, + CurrentSnapshot = CreateRecord("current"), + Archives = Enumerable.Range(0, archiveCount) + .Select(index => CreateRecord("record-" + index)) + .ToList(), + EncryptedDeviceProfile = new PairingEncryptedPayload + { + Nonce = "profile-nonce", + Ciphertext = "profile-ciphertext", + Tag = "profile-tag" + } + }; + } + + private static EncryptedSyncRecord CreateRecord(string recordId) + { + return new EncryptedSyncRecord + { + RecordId = recordId, + DeviceId = "device-id", + Revision = 1, + Nonce = "nonce", + Ciphertext = "ciphertext", + Tag = "tag", + CiphertextHash = "hash" + }; + } +} diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/SyncCoordinatorFailureTests.cs b/KeyStats.Windows/KeyStats.Sync.Tests/SyncCoordinatorFailureTests.cs new file mode 100644 index 0000000..09cdfcc --- /dev/null +++ b/KeyStats.Windows/KeyStats.Sync.Tests/SyncCoordinatorFailureTests.cs @@ -0,0 +1,627 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Runtime.Serialization; +using System.Threading; +using System.Threading.Tasks; +using KeyStats.Models; +using KeyStats.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace KeyStats.Sync.Tests; + +[TestClass] +public sealed class SyncCoordinatorFailureTests +{ + [TestMethod] + public async Task SingleDeviceConflict_PersistsGateAndStopsOrdinaryScheduling() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + SaveEnabledState(directory.Path); + SaveCredentials(directory.Path); + var failure = new SyncTransportException( + HttpStatusCode.Conflict, + "Single-device sync is disabled.", + null, + errorCode: "single_device_sync_disabled", + activeDeviceCount: 1); + using var transport = new FailureTransport(failure); + using var coordinator = CreateCoordinator(directory.Path, transport); + coordinator.Start(); + + await coordinator.SyncNowAsync().ConfigureAwait(false); + + var persisted = new SyncStateStore(directory.Path).Load(); + var status = coordinator.GetStatus(); + Assert.AreEqual(1, transport.SyncCallCount); + Assert.AreEqual(1, persisted.ActiveDeviceCount); + Assert.IsNull(persisted.NextAutomaticSyncAtUtc); + Assert.AreEqual(1, status.ActiveDeviceCount); + Assert.IsFalse(status.CanSync); + Assert.IsFalse(status.CanManualSync); + Assert.IsNull(status.LastError); + } + + [TestMethod] + public async Task SingleDeviceCodeWithoutAuthoritativeCount_RemainsAConflict() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + SaveEnabledState(directory.Path); + SaveCredentials(directory.Path); + using var transport = new FailureTransport(new SyncTransportException( + HttpStatusCode.Conflict, + "Untrusted single-device detail.", + null, + errorCode: "single_device_sync_disabled")); + using var coordinator = CreateCoordinator(directory.Path, transport); + + await Assert.ThrowsExceptionAsync( + () => coordinator.SyncNowAsync()).ConfigureAwait(false); + + Assert.AreEqual(2, new SyncStateStore(directory.Path).Load().ActiveDeviceCount); + Assert.IsFalse(coordinator.GetStatus().CanManualSync); + Assert.IsNotNull(coordinator.GetStatus().LastError); + } + + [TestMethod] + public async Task ManualTransportFailure_AddsSixtySecondRetryDelay() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + SaveEnabledState(directory.Path); + SaveCredentials(directory.Path); + using var transport = new FailureTransport(new SyncTransportException( + HttpStatusCode.ServiceUnavailable, + "Service unavailable.", + null, + errorCode: "temporarily_unavailable")); + using var coordinator = CreateCoordinator(directory.Path, transport); + var before = DateTime.UtcNow; + + await Assert.ThrowsExceptionAsync( + () => coordinator.SyncNowAsync()).ConfigureAwait(false); + + var after = DateTime.UtcNow; + var retryAt = new SyncStateStore(directory.Path).Load().NextAllowedSyncAtUtc; + Assert.IsTrue(retryAt.HasValue); + Assert.IsTrue(retryAt.Value >= before.AddSeconds(55)); + Assert.IsTrue(retryAt.Value <= after.AddSeconds(65)); + Assert.IsFalse(coordinator.GetStatus().CanManualSync); + } + + [TestMethod] + public void AutomaticFailures_RetryAfterOneHourThenSixHoursAndStopForUtcDay() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + SaveEnabledState(directory.Path); + SaveCredentials(directory.Path); + using var coordinator = new SyncCoordinator( + CreateStatsManager(), + directory.Path, + null, + "test"); + var method = typeof(SyncCoordinator).GetMethod( + "RecordAutomaticFailureLocked", + BindingFlags.Instance | BindingFlags.NonPublic); + var stateField = typeof(SyncCoordinator).GetField( + "_state", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(method); + Assert.IsNotNull(stateField); + + var firstStart = new DateTime(2026, 7, 14, 12, 0, 0, DateTimeKind.Utc); + method!.Invoke(coordinator, new object?[] { firstStart }); + var state = (SyncState)stateField!.GetValue(coordinator)!; + Assert.AreEqual(1, state.AutomaticFailureCount); + Assert.IsTrue(state.NextAutomaticSyncAtUtc.HasValue); + Assert.IsTrue(state.NextAutomaticSyncAtUtc.Value >= firstStart.AddMinutes(59)); + Assert.IsTrue(state.NextAutomaticSyncAtUtc.Value <= firstStart.AddMinutes(61)); + + var secondStart = firstStart.AddSeconds(1); + method.Invoke(coordinator, new object?[] { secondStart }); + Assert.AreEqual(2, state.AutomaticFailureCount); + Assert.IsTrue(state.NextAutomaticSyncAtUtc.HasValue); + Assert.IsTrue(state.NextAutomaticSyncAtUtc.Value >= secondStart.AddHours(6).AddMinutes(-1)); + Assert.IsTrue(state.NextAutomaticSyncAtUtc.Value <= secondStart.AddHours(6).AddMinutes(1)); + + var thirdStart = secondStart.AddSeconds(1); + var nextUtcMidnight = thirdStart.Date.AddDays(1); + method.Invoke(coordinator, new object?[] { thirdStart }); + Assert.AreEqual(SyncProtocol.MaximumAutomaticFailuresPerUtcDay, state.AutomaticFailureCount); + Assert.IsTrue(state.NextAutomaticSyncAtUtc.HasValue); + Assert.IsTrue(state.NextAutomaticSyncAtUtc.Value >= nextUtcMidnight); + Assert.IsTrue(state.NextAutomaticSyncAtUtc.Value <= nextUtcMidnight.AddHours(1)); + } + + [TestMethod] + public async Task Transport_PreservesValidatedSingleDeviceConflictDetails() + { + using var transport = new CloudflareSyncTransport( + new Uri("https://unit-tests.workers.dev/"), + new StaticResponseHandler(new HttpResponseMessage(HttpStatusCode.Conflict) + { + Content = new StringContent( + "{\"code\":\"single_device_sync_disabled\",\"activeDeviceCount\":1," + + "\"requestId\":\"not-exposed\"}") + })); + + var exception = await Assert.ThrowsExceptionAsync(() => + transport.SyncAsync( + new SyncRequest { Reason = "manual" }, + "device.test-token", + "idempotency-key", + CancellationToken.None)).ConfigureAwait(false); + + Assert.AreEqual(HttpStatusCode.Conflict, exception.StatusCode); + Assert.AreEqual("single_device_sync_disabled", exception.ErrorCode); + Assert.AreEqual(1, exception.ActiveDeviceCount!.Value); + Assert.AreEqual(-1, exception.Message.IndexOf("not-exposed", StringComparison.Ordinal)); + } + + [TestMethod] + public async Task Transport_PreservesEncryptedMaximumDeviceRecoveryOptions() + { + var vaultId = "11111111-1111-4111-8111-111111111111"; + var deviceIds = new[] + { + "22222222-2222-4222-8222-222222222222", + "33333333-3333-4333-8333-333333333333", + "44444444-4444-4444-8444-444444444444", + "55555555-5555-4555-8555-555555555555", + "66666666-6666-4666-8666-666666666666" + }; + var body = "{\"code\":\"maximum_devices\",\"vaultId\":\"" + vaultId + + "\",\"devices\":[" + string.Join(",", deviceIds.Select(id => + "{\"deviceId\":\"" + id + + "\",\"encryptedDeviceProfile\":null,\"revoked\":false}")) + "]}"; + using var transport = new CloudflareSyncTransport( + new Uri("https://unit-tests.workers.dev/"), + new StaticResponseHandler(new HttpResponseMessage(HttpStatusCode.Conflict) + { + Content = new StringContent(body) + })); + + var exception = await Assert.ThrowsExceptionAsync(() => + transport.RecoverVaultAsync( + new RecoverVaultRequest(), + CancellationToken.None)).ConfigureAwait(false); + + Assert.AreEqual("maximum_devices", exception.ErrorCode); + Assert.AreEqual(vaultId, exception.VaultId); + Assert.AreEqual(SyncProtocol.MaximumDevices, exception.Devices.Count); + CollectionAssert.AreEqual( + deviceIds, + exception.Devices.Select(device => device.DeviceId).ToArray()); + } + + [TestMethod] + public async Task StartupWithCredentialBackupForAnotherDevice_BlocksVaultDeletion() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + SaveEnabledState(directory.Path); + var credentialStore = new SyncCredentialStore(directory.Path); + credentialStore.Save(new SyncCredentials + { + VaultSeed = new byte[16], + DeviceToken = "old-device.old-token" + }); + credentialStore.Save(new SyncCredentials + { + VaultSeed = new byte[16], + DeviceToken = "device-id.current-token" + }); + File.WriteAllBytes( + Path.Combine(directory.Path, "sync_credentials.bin"), + new byte[] { 1, 2, 3 }); + using var transport = new FailureTransport(new InvalidOperationException("Not used.")); + using var coordinator = CreateCoordinator(directory.Path, transport); + + Assert.IsTrue(coordinator.GetStatus().NeedsRepair); + await Assert.ThrowsExceptionAsync( + () => coordinator.DeleteVaultAsync()).ConfigureAwait(false); + Assert.AreEqual(0, transport.DeleteVaultCallCount); + } + + [TestMethod] + public async Task CredentialSwapAfterStartup_BlocksVaultDeletionAndPersistsRepairState() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + SaveEnabledState(directory.Path); + SaveCredentials(directory.Path); + using var transport = new FailureTransport(new InvalidOperationException("Not used.")); + using var coordinator = CreateCoordinator(directory.Path, transport); + new SyncCredentialStore(directory.Path).Save(new SyncCredentials + { + VaultSeed = new byte[16], + DeviceToken = "other-device.other-token" + }); + + await Assert.ThrowsExceptionAsync( + () => coordinator.DeleteVaultAsync()).ConfigureAwait(false); + + Assert.AreEqual(0, transport.DeleteVaultCallCount); + Assert.IsTrue(coordinator.GetStatus().NeedsRepair); + Assert.IsTrue(new SyncStateStore(directory.Path).Load().NeedsRepair); + } + + [TestMethod] + public async Task SingleDeviceStateRefresh_UsesReadOnlyEndpointAndNeverPostsSync() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + var now = DateTime.UtcNow; + new SyncStateStore(directory.Path).Save(new SyncState + { + IsEnabled = true, + VaultId = "vault-id", + DeviceId = "device-id", + DeviceName = "Test device", + ActiveDeviceCount = 1, + RemainingDailySyncs = 8, + LastSuccessfulSyncAtUtc = now, + LastStateRefreshAtUtc = now.AddHours(-25) + }); + SaveCredentials(directory.Path); + using var transport = new FailureTransport(new InvalidOperationException("Not used.")) + { + StateResponse = new SyncStateResponse + { + ServerTime = now, + ActiveDeviceCount = 1, + Devices = new List + { + new() { DeviceId = "device-id" } + } + } + }; + using var coordinator = CreateCoordinator(directory.Path, transport); + var refresh = typeof(SyncCoordinator).GetMethod( + "RefreshStateAsync", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(refresh); + + var task = (Task)refresh!.Invoke( + coordinator, + new object?[] { false, CancellationToken.None })!; + await task.ConfigureAwait(false); + + Assert.AreEqual(1, transport.StateCallCount); + Assert.AreEqual(0, transport.SyncCallCount); + Assert.AreEqual(1, coordinator.GetStatus().ActiveDeviceCount); + Assert.IsFalse(coordinator.GetStatus().CanManualSync); + Assert.IsTrue(new SyncStateStore(directory.Path).Load().LastStateRefreshAtUtc.HasValue); + } + + [TestMethod] + public async Task FailedVaultDeletion_PersistsIntentAndReplaysSameBoundToken() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + SaveEnabledState(directory.Path); + SaveCredentials(directory.Path); + using (var failingTransport = new FailureTransport(new SyncTransportException( + HttpStatusCode.ServiceUnavailable, + "Service unavailable.", + null, + errorCode: "temporarily_unavailable")) + { DeleteFailuresRemaining = 1 }) + using (var coordinator = CreateCoordinator(directory.Path, failingTransport)) + { + await Assert.ThrowsExceptionAsync( + () => coordinator.DeleteVaultAsync()).ConfigureAwait(false); + Assert.AreEqual(1, failingTransport.DeleteVaultCallCount); + Assert.AreEqual("device-id.test-token", failingTransport.LastDeleteToken); + Assert.IsTrue(new SyncStateStore(directory.Path).Load().PendingVaultDeletion); + Assert.AreEqual(0, failingTransport.SyncCallCount); + } + + using var replayTransport = new FailureTransport(new InvalidOperationException("Not used.")); + using var restarted = CreateCoordinator(directory.Path, replayTransport); + Assert.IsTrue(restarted.GetStatus().CanRetryBootstrap); + await restarted.RetryBootstrapAsync().ConfigureAwait(false); + + Assert.AreEqual(1, replayTransport.DeleteVaultCallCount); + Assert.AreEqual("device-id.test-token", replayTransport.LastDeleteToken); + Assert.AreEqual(0, replayTransport.SyncCallCount); + var cleared = new SyncStateStore(directory.Path).Load(); + Assert.IsFalse(cleared.IsEnabled); + Assert.IsFalse(cleared.PendingVaultDeletion); + Assert.AreNotEqual("device-id", cleared.InstallationDeviceId); + } + + [TestMethod] + public async Task UnauthorizedRecovery_CanClearOnlyLocalSetupAndRetainsTakeoverIdentity() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + var installationId = "77777777-7777-4777-8777-777777777777"; + new SyncStateStore(directory.Path).Save(new SyncState + { + InstallationDeviceId = installationId + }); + using var transport = new FailureTransport(new InvalidOperationException("Not used.")) + { + RecoverFailure = new SyncTransportException( + HttpStatusCode.Unauthorized, + "Unauthorized.", + null, + errorCode: "unauthorized") + }; + using var coordinator = CreateCoordinator(directory.Path, transport); + var recoveryCode = new SyncCrypto().EncodeRecoveryCode(new byte[16]); + + await Assert.ThrowsExceptionAsync(() => + coordinator.RecoverVaultAsync(recoveryCode, "Test device")).ConfigureAwait(false); + + var pending = new SyncStateStore(directory.Path).Load(); + Assert.AreEqual("recover", pending.PendingProvisioningKind); + Assert.IsTrue(coordinator.BlocksImport); + await coordinator.ClearLocalSyncConfigurationAsync().ConfigureAwait(false); + + var cleared = new SyncStateStore(directory.Path).Load(); + Assert.IsFalse(cleared.IsEnabled); + Assert.IsNull(cleared.PendingProvisioningKind); + Assert.AreNotEqual(installationId, cleared.InstallationDeviceId); + Assert.IsNull(cleared.ReplacementCandidateDeviceId); + Assert.IsFalse(coordinator.BlocksImport); + } + + [TestMethod] + public async Task ClearingRepair_RotatesInstallationAndRetainsOldDeviceAsTakeoverCandidate() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + var oldDeviceId = "88888888-8888-4888-8888-888888888888"; + new SyncStateStore(directory.Path).Save(new SyncState + { + IsEnabled = true, + NeedsRepair = true, + VaultId = "vault-id", + DeviceId = oldDeviceId, + InstallationDeviceId = oldDeviceId, + DeviceName = "Broken device", + ActiveDeviceCount = 2 + }); + using var transport = new FailureTransport(new InvalidOperationException("Not used.")); + using var coordinator = CreateCoordinator(directory.Path, transport); + + await coordinator.ClearLocalSyncConfigurationAsync().ConfigureAwait(false); + + var cleared = new SyncStateStore(directory.Path).Load(); + Assert.AreNotEqual(oldDeviceId, cleared.InstallationDeviceId); + Assert.AreEqual(oldDeviceId, cleared.ReplacementCandidateDeviceId); + Assert.IsFalse(coordinator.BlocksImport); + + transport.RecoverFailure = new SyncTransportException( + HttpStatusCode.Unauthorized, + "Unauthorized.", + null, + errorCode: "recovery_failed"); + var recoveryCode = new SyncCrypto().EncodeRecoveryCode(new byte[16]); + await Assert.ThrowsExceptionAsync(() => + coordinator.RecoverVaultAsync(recoveryCode, "Recovered device")).ConfigureAwait(false); + Assert.IsNotNull(transport.LastRecoverRequest); + Assert.AreEqual(oldDeviceId, transport.LastRecoverRequest!.DeviceId); + Assert.AreEqual(oldDeviceId, transport.LastRecoverRequest.ReplaceDeviceId); + } + + [TestMethod] + public async Task MissingRecoveryReplacement_FallsBackOnceToFreshDeviceIdentity() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + var oldDeviceId = "99999999-9999-4999-8999-999999999999"; + new SyncStateStore(directory.Path).Save(new SyncState + { + InstallationDeviceId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + ReplacementCandidateDeviceId = oldDeviceId, + LocalRecords = new Dictionary + { + ["2026-07-14"] = new() { LocalDay = "2026-07-14", Revision = 7 } + } + }); + using var transport = new FailureTransport(new InvalidOperationException("Not used.")); + transport.RecoverFailures.Enqueue(new SyncTransportException( + HttpStatusCode.Conflict, + "Replacement is not in this vault.", + null, + errorCode: "replace_device_not_found")); + transport.RecoverFailures.Enqueue(new SyncTransportException( + HttpStatusCode.ServiceUnavailable, + "Service unavailable.", + null, + errorCode: "temporarily_unavailable")); + using var coordinator = CreateCoordinator(directory.Path, transport); + var recoveryCode = new SyncCrypto().EncodeRecoveryCode(new byte[16]); + + await Assert.ThrowsExceptionAsync(() => + coordinator.RecoverVaultAsync(recoveryCode, "Recovered device")).ConfigureAwait(false); + + Assert.AreEqual(2, transport.RecoverRequests.Count); + Assert.AreEqual(oldDeviceId, transport.RecoverRequests[0].DeviceId); + Assert.AreEqual(oldDeviceId, transport.RecoverRequests[0].ReplaceDeviceId); + Assert.AreNotEqual(oldDeviceId, transport.RecoverRequests[1].DeviceId); + Assert.IsNull(transport.RecoverRequests[1].ReplaceDeviceId); + Assert.IsTrue(transport.RecoverRequests[1].DeviceToken.StartsWith( + transport.RecoverRequests[1].DeviceId + ".", + StringComparison.Ordinal)); + var pendingState = new SyncStateStore(directory.Path).Load(); + Assert.IsNull(pendingState.ReplacementCandidateDeviceId); + Assert.IsNull(pendingState.PendingProvisioningReplaceDeviceId); + Assert.AreEqual(0, pendingState.LocalRecords.Count); + } + + private static SyncCoordinator CreateCoordinator(string dataFolder, ISyncTransport transport) + => new(CreateStatsManager(), dataFolder, null, "test", transport); + + private static StatsManager CreateStatsManager() + { + var manager = (StatsManager)FormatterServices.GetUninitializedObject(typeof(StatsManager)); + SetField(manager, "_lock", new object()); + SetField(manager, "k__BackingField", new DailyStats(DateTime.Today)); + SetField( + manager, + "k__BackingField", + new Dictionary(StringComparer.Ordinal)); + return manager; + } + + private static void SetField(object target, string name, object value) + { + var field = target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(field, "Missing test setup field: " + name); + field!.SetValue(target, value); + } + + private static void SaveEnabledState(string dataFolder) + { + var now = DateTime.UtcNow; + new SyncStateStore(dataFolder).Save(new SyncState + { + IsEnabled = true, + VaultId = "vault-id", + DeviceId = "device-id", + DeviceName = "Test device", + ActiveDeviceCount = 2, + RemainingDailySyncs = 8, + QuotaUtcDay = now.ToString("yyyy-MM-dd"), + LastSuccessfulSyncAtUtc = now.AddHours(-2) + }); + } + + private static void SaveCredentials(string dataFolder) + { + new SyncCredentialStore(dataFolder).Save(new SyncCredentials + { + VaultSeed = new byte[16], + DeviceToken = "device-id.test-token" + }); + } + + private sealed class FailureTransport : ISyncTransport + { + private readonly Exception _failure; + + public int SyncCallCount { get; private set; } + public int StateCallCount { get; private set; } + public int DeleteVaultCallCount { get; private set; } + public int DeleteFailuresRemaining { get; set; } + public string? LastDeleteToken { get; private set; } + public SyncStateResponse? StateResponse { get; set; } + public Exception? RecoverFailure { get; set; } + public RecoverVaultRequest? LastRecoverRequest { get; private set; } + public Queue RecoverFailures { get; } = new(); + public List RecoverRequests { get; } = new(); + + public FailureTransport(Exception failure) => _failure = failure; + + public Task SyncAsync( + SyncRequest request, + string deviceToken, + string idempotencyKey, + CancellationToken cancellationToken) + { + SyncCallCount++; + return Task.FromException(_failure); + } + + public Task CreateVaultAsync( + CreateVaultRequest request, + CancellationToken cancellationToken) => throw Unexpected(); + + public Task RecoverVaultAsync( + RecoverVaultRequest request, + CancellationToken cancellationToken) + { + LastRecoverRequest = request; + RecoverRequests.Add(request); + if (RecoverFailures.Count > 0) + { + return Task.FromException(RecoverFailures.Dequeue()); + } + return RecoverFailure != null + ? Task.FromException(RecoverFailure!) + : throw Unexpected(); + } + + public Task CreatePairingSessionAsync( + CreatePairingSessionRequest request, + CancellationToken cancellationToken) => throw Unexpected(); + + public Task JoinPairingSessionAsync( + string code, + JoinPairingSessionRequest request, + string deviceToken, + CancellationToken cancellationToken) => throw Unexpected(); + + public Task ApprovePairingSessionAsync( + string sessionId, + ApprovePairingSessionRequest request, + string deviceToken, + CancellationToken cancellationToken) => throw Unexpected(); + + public Task CompletePairingSessionAsync( + string sessionId, + CompletePairingSessionRequest request, + CancellationToken cancellationToken) => throw Unexpected(); + + public Task GetHistoryAsync( + long cursor, + string deviceToken, + CancellationToken cancellationToken) => throw Unexpected(); + + public Task GetStateAsync( + string deviceToken, + CancellationToken cancellationToken) + { + StateCallCount++; + return StateResponse != null + ? Task.FromResult(StateResponse) + : Task.FromException(Unexpected()); + } + + public Task RevokeDeviceAsync( + string deviceId, + string deviceToken, + CancellationToken cancellationToken) => throw Unexpected(); + + public Task DeleteVaultAsync(string deviceToken, CancellationToken cancellationToken) + { + DeleteVaultCallCount++; + LastDeleteToken = deviceToken; + if (DeleteFailuresRemaining > 0) + { + DeleteFailuresRemaining--; + return Task.FromException(_failure); + } + return Task.CompletedTask; + } + + public void Dispose() + { + } + + private static InvalidOperationException Unexpected() + => new("Unexpected transport call in sync failure test."); + } + + private sealed class StaticResponseHandler : HttpMessageHandler + { + private readonly HttpResponseMessage _response; + + public StaticResponseHandler(HttpResponseMessage response) => _response = response; + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) => Task.FromResult(_response); + } +} diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/SyncCoordinatorHistoryResumeTests.cs b/KeyStats.Windows/KeyStats.Sync.Tests/SyncCoordinatorHistoryResumeTests.cs new file mode 100644 index 0000000..cfce334 --- /dev/null +++ b/KeyStats.Windows/KeyStats.Sync.Tests/SyncCoordinatorHistoryResumeTests.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Runtime.Serialization; +using System.Threading; +using System.Threading.Tasks; +using KeyStats.Models; +using KeyStats.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace KeyStats.Sync.Tests; + +[TestClass] +public sealed class SyncCoordinatorHistoryResumeTests +{ + [TestMethod] + public async Task HistoryFailure_PersistsPageCursorAndRestartNeverPostsSync() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + SaveHistoryOnlyState(directory.Path, cursor: 7); + SaveCredentials(directory.Path); + + var firstTransport = new RecordingTransport( + new HistoryResponse { Cursor = 11, HasMore = true }, + new SyncTransportException( + HttpStatusCode.ServiceUnavailable, + "History is temporarily unavailable.", + null)); + using (var firstCoordinator = CreateCoordinator(directory.Path, firstTransport)) + { + try + { + await firstCoordinator.RetryBootstrapAsync().ConfigureAwait(false); + Assert.Fail("The second history page should fail."); + } + catch (SyncTransportException ex) + { + Assert.AreEqual(HttpStatusCode.ServiceUnavailable, ex.StatusCode); + } + } + + var interrupted = new SyncStateStore(directory.Path).Load(); + Assert.IsFalse(interrupted.NeedsBootstrap); + Assert.IsTrue(interrupted.NeedsHistoryBootstrap); + Assert.AreEqual(11L, interrupted.HistoryCursor); + Assert.AreEqual(0, firstTransport.SyncCallCount); + CollectionAssert.AreEqual(new long[] { 7, 11 }, firstTransport.HistoryCursors.ToArray()); + + var resumedTransport = new RecordingTransport( + new HistoryResponse { Cursor = 15, HasMore = false }); + using (var resumedCoordinator = CreateCoordinator(directory.Path, resumedTransport)) + { + await resumedCoordinator.RetryBootstrapAsync().ConfigureAwait(false); + } + + var completed = new SyncStateStore(directory.Path).Load(); + Assert.IsFalse(completed.NeedsBootstrap); + Assert.IsFalse(completed.NeedsHistoryBootstrap); + Assert.AreEqual(15L, completed.HistoryCursor); + Assert.AreEqual(0, resumedTransport.SyncCallCount); + CollectionAssert.AreEqual(new long[] { 11 }, resumedTransport.HistoryCursors.ToArray()); + } + + private static SyncCoordinator CreateCoordinator(string dataFolder, ISyncTransport transport) + { + var statsManager = (StatsManager)FormatterServices.GetUninitializedObject(typeof(StatsManager)); + return new SyncCoordinator(statsManager, dataFolder, null, "test", transport); + } + + private static void SaveHistoryOnlyState(string dataFolder, long cursor) + { + new SyncStateStore(dataFolder).Save(new SyncState + { + IsEnabled = true, + NeedsBootstrap = false, + NeedsHistoryBootstrap = true, + VaultId = "vault-id", + DeviceId = "device-id", + DeviceName = "Test device", + ActiveDeviceCount = 2, + RemainingDailySyncs = 8, + LastSuccessfulSyncAtUtc = DateTime.UtcNow, + HistoryCursor = cursor + }); + } + + private static void SaveCredentials(string dataFolder) + { + new SyncCredentialStore(dataFolder).Save(new SyncCredentials + { + VaultSeed = new byte[16], + DeviceToken = "device-id.test-token" + }); + } + + private sealed class RecordingTransport : ISyncTransport + { + private readonly Queue _historyResults; + + public int SyncCallCount { get; private set; } + public List HistoryCursors { get; } = new(); + + public RecordingTransport(params object[] historyResults) + { + _historyResults = new Queue(historyResults); + } + + public Task SyncAsync( + SyncRequest request, + string deviceToken, + string idempotencyKey, + CancellationToken cancellationToken) + { + SyncCallCount++; + throw new AssertFailedException("History-only recovery must not POST /sync."); + } + + public Task GetHistoryAsync( + long cursor, + string deviceToken, + CancellationToken cancellationToken) + { + HistoryCursors.Add(cursor); + if (_historyResults.Count == 0) + { + throw new AssertFailedException("The test did not configure another history response."); + } + var result = _historyResults.Dequeue(); + if (result is Exception exception) return Task.FromException(exception); + return Task.FromResult((HistoryResponse)result); + } + + public Task GetStateAsync( + string deviceToken, + CancellationToken cancellationToken) => throw Unexpected(); + + public Task CreateVaultAsync( + CreateVaultRequest request, + CancellationToken cancellationToken) => throw Unexpected(); + + public Task RecoverVaultAsync( + RecoverVaultRequest request, + CancellationToken cancellationToken) => throw Unexpected(); + + public Task CreatePairingSessionAsync( + CreatePairingSessionRequest request, + CancellationToken cancellationToken) => throw Unexpected(); + + public Task JoinPairingSessionAsync( + string code, + JoinPairingSessionRequest request, + string deviceToken, + CancellationToken cancellationToken) => throw Unexpected(); + + public Task ApprovePairingSessionAsync( + string sessionId, + ApprovePairingSessionRequest request, + string deviceToken, + CancellationToken cancellationToken) => throw Unexpected(); + + public Task CompletePairingSessionAsync( + string sessionId, + CompletePairingSessionRequest request, + CancellationToken cancellationToken) => throw Unexpected(); + + public Task RevokeDeviceAsync( + string deviceId, + string deviceToken, + CancellationToken cancellationToken) => throw Unexpected(); + + public Task DeleteVaultAsync( + string deviceToken, + CancellationToken cancellationToken) => throw Unexpected(); + + public void Dispose() + { + } + + private static InvalidOperationException Unexpected() + => new("Unexpected transport call in history resume test."); + } +} diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/SyncCredentialStoreTests.cs b/KeyStats.Windows/KeyStats.Sync.Tests/SyncCredentialStoreTests.cs new file mode 100644 index 0000000..a36fe0e --- /dev/null +++ b/KeyStats.Windows/KeyStats.Sync.Tests/SyncCredentialStoreTests.cs @@ -0,0 +1,112 @@ +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using KeyStats.Models; +using KeyStats.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace KeyStats.Sync.Tests; + +[TestClass] +public sealed class SyncCredentialStoreTests +{ + [TestMethod] + public void SaveAndLoad_RoundTripsWithCurrentUserDpapi() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + var store = new SyncCredentialStore(directory.Path); + var expected = new SyncCredentials + { + VaultId = "vault-id", + DeviceId = "device-id", + VaultSeed = Enumerable.Range(0, 16).Select(value => (byte)value).ToArray(), + DeviceToken = "device-id.test-token" + }; + + store.Save(expected); + var actual = store.Load("vault-id", "device-id"); + + Assert.AreEqual(expected.VaultId, actual.VaultId); + Assert.AreEqual(expected.DeviceId, actual.DeviceId); + CollectionAssert.AreEqual(expected.VaultSeed, actual.VaultSeed); + Assert.AreEqual(expected.DeviceToken, actual.DeviceToken); + } + + [TestMethod] + public void Load_WithDamagedPrimary_RestoresDpapiBackup() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + var store = new SyncCredentialStore(directory.Path); + var first = new SyncCredentials + { + VaultSeed = Enumerable.Range(0, 16).Select(value => (byte)value).ToArray(), + DeviceToken = "device-id.first-token" + }; + store.Save(first); + store.Save(new SyncCredentials + { + VaultSeed = Enumerable.Repeat((byte)7, 16).ToArray(), + DeviceToken = "device-id.second-token" + }); + File.WriteAllBytes(Path.Combine(directory.Path, "sync_credentials.bin"), new byte[] { 1, 2, 3 }); + + var recovered = store.Load(); + + CollectionAssert.AreEqual(first.VaultSeed, recovered.VaultSeed); + Assert.AreEqual(first.DeviceToken, recovered.DeviceToken); + } + + [TestMethod] + public void Load_WithBackupForDifferentDevice_RejectsDeviceBinding() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + var store = new SyncCredentialStore(directory.Path); + store.Save(new SyncCredentials + { + VaultSeed = Enumerable.Range(0, 16).Select(value => (byte)value).ToArray(), + DeviceToken = "old-device.old-token" + }); + store.Save(new SyncCredentials + { + VaultSeed = Enumerable.Repeat((byte)7, 16).ToArray(), + DeviceToken = "current-device.current-token" + }); + File.WriteAllBytes(Path.Combine(directory.Path, "sync_credentials.bin"), new byte[] { 1, 2, 3 }); + + Assert.ThrowsExactly(() => store.Load("current-device")); + } + + [TestMethod] + public void PendingPairingCompletion_RoundTripsSecretsWithCurrentUserDpapi() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + var store = new SyncPendingSecretsStore(directory.Path); + var expected = new PendingSyncSecrets + { + Kind = "pairing-final", + VaultId = "vault-id", + DeviceId = "device-id", + VaultSeed = Enumerable.Range(0, 16).Select(value => (byte)value).ToArray(), + DeviceToken = "device-id.test-token", + PairingPrivateKey = Enumerable.Repeat((byte)3, 32).ToArray(), + PairingPublicKey = Enumerable.Repeat((byte)7, 32).ToArray(), + PairingSessionId = "session-id", + PairingCompletionToken = "completion-token" + }; + + store.Save(expected); + var actual = store.Load(); + + Assert.AreEqual(expected.Kind, actual.Kind); + Assert.AreEqual(expected.VaultId, actual.VaultId); + Assert.AreEqual(expected.DeviceId, actual.DeviceId); + Assert.AreEqual(expected.DeviceToken, actual.DeviceToken); + CollectionAssert.AreEqual(expected.VaultSeed, actual.VaultSeed); + CollectionAssert.AreEqual(expected.PairingPrivateKey, actual.PairingPrivateKey); + CollectionAssert.AreEqual(expected.PairingPublicKey, actual.PairingPublicKey); + } +} diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/SyncCryptoFixtureTests.cs b/KeyStats.Windows/KeyStats.Sync.Tests/SyncCryptoFixtureTests.cs new file mode 100644 index 0000000..7e59f29 --- /dev/null +++ b/KeyStats.Windows/KeyStats.Sync.Tests/SyncCryptoFixtureTests.cs @@ -0,0 +1,144 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using KeyStats.Models; +using KeyStats.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace KeyStats.Sync.Tests; + +[TestClass] +public sealed class SyncCryptoFixtureTests +{ + [TestMethod] + public void RecoveryAndDerivedKeys_MatchSharedFixture() + { + var root = LoadFixture(); + var recovery = root.GetProperty("recovery"); + var derivedKeys = root.GetProperty("derivedKeys"); + var seed = HexToBytes(RequiredString(recovery, "seedHex")); + var crypto = new SyncCrypto(); + + Assert.AreEqual(RequiredString(recovery, "formattedCode"), crypto.EncodeRecoveryCode(seed)); + Assert.IsTrue(crypto.TryDecodeRecoveryCode( + RequiredString(recovery, "formattedCode").ToLowerInvariant(), + out var decodedSeed)); + CollectionAssert.AreEqual(seed, decodedSeed); + Assert.AreEqual( + RequiredString(derivedKeys, "encryptionKeyHex"), + ToHex(crypto.DeriveDataKey(seed))); + Assert.AreEqual( + RequiredString(derivedKeys, "recordIndexKeyHex"), + ToHex(crypto.DeriveIndexKey(seed))); + Assert.AreEqual( + RequiredString(derivedKeys, "recoveryAuthKeyHex"), + ToHex(crypto.DeriveRecoveryAuth(seed))); + Assert.AreEqual( + RequiredString(derivedKeys, "recoveryCredentialBase64Url"), + SyncCrypto.Base64UrlEncode(crypto.DeriveRecoveryAuth(seed))); + } + + [TestMethod] + public void RecordIdAndAesGcmDecryption_MatchSharedFixture() + { + var root = LoadFixture(); + var derivedKeys = root.GetProperty("derivedKeys"); + var vector = root.GetProperty("record"); + var crypto = new SyncCrypto(); + var indexKey = HexToBytes(RequiredString(derivedKeys, "recordIndexKeyHex")); + var dataKey = HexToBytes(RequiredString(derivedKeys, "encryptionKeyHex")); + var deviceId = RequiredString(vector, "deviceId"); + var recordId = RequiredString(vector, "recordId"); + + Assert.AreEqual( + recordId, + crypto.CreateRecordId(indexKey, deviceId, RequiredString(vector, "localDay"))); + + var encryptedRecord = CreateEncryptedRecord(vector); + var plaintext = crypto.DecryptRecord( + dataKey, + RequiredString(vector, "vaultId"), + encryptedRecord); + Assert.AreEqual(RequiredString(vector, "plaintextUtf8"), Encoding.UTF8.GetString(plaintext)); + + encryptedRecord.Revision++; + Assert.ThrowsExactly(() => crypto.DecryptRecord( + dataKey, + RequiredString(vector, "vaultId"), + encryptedRecord)); + } + + [TestMethod] + public void PairingDerivationSafetyCodeAndAesGcm_MatchSharedFixture() + { + var pairing = LoadFixture().GetProperty("pairing"); + var crypto = new SyncCrypto(); + var joiningPrivateKey = HexToBytes(RequiredString(pairing, "joiningPrivateKeyHex")); + var joiningPublicKey = Convert.FromBase64String(RequiredString(pairing, "joiningPublicKeyBase64")); + var approvingPublicKey = Convert.FromBase64String(RequiredString(pairing, "approvingPublicKeyBase64")); + var wrapKey = crypto.DerivePairingWrapKey(joiningPrivateKey, approvingPublicKey); + + Assert.AreEqual(RequiredString(pairing, "wrapKeyHex"), ToHex(wrapKey)); + Assert.AreEqual( + RequiredString(pairing, "safetyCode"), + crypto.CreatePairingSafetyCode(joiningPublicKey, approvingPublicKey, joiningPrivateKey)); + + var payload = new PairingEncryptedPayload + { + Nonce = RequiredString(pairing, "nonceBase64"), + Ciphertext = RequiredString(pairing, "ciphertextBase64"), + Tag = RequiredString(pairing, "tagBase64") + }; + var plaintext = crypto.DecryptPairingPayload( + wrapKey, + RequiredString(pairing, "sessionId"), + payload); + Assert.AreEqual(RequiredString(pairing, "plaintextUtf8"), Encoding.UTF8.GetString(plaintext)); + } + + private static EncryptedSyncRecord CreateEncryptedRecord(JsonElement vector) + { + return new EncryptedSyncRecord + { + SchemaVersion = 1, + RecordId = RequiredString(vector, "recordId"), + DeviceId = RequiredString(vector, "deviceId"), + Revision = vector.GetProperty("revision").GetInt64(), + Nonce = RequiredString(vector, "nonceBase64"), + Ciphertext = RequiredString(vector, "ciphertextBase64"), + Tag = RequiredString(vector, "tagBase64"), + CiphertextHash = RequiredString(vector, "ciphertextHashBase64Url") + }; + } + + private static JsonElement LoadFixture() + { + var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "crypto-vectors.json"); + using var document = JsonDocument.Parse(File.ReadAllText(path, Encoding.UTF8)); + return document.RootElement.Clone(); + } + + private static string RequiredString(JsonElement parent, string propertyName) + { + return parent.GetProperty(propertyName).GetString() + ?? throw new InvalidDataException($"Fixture property {propertyName} is missing."); + } + + private static byte[] HexToBytes(string value) + { + if (value.Length % 2 != 0) throw new FormatException("Hex input must have an even length."); + var result = new byte[value.Length / 2]; + for (var index = 0; index < result.Length; index++) + { + result[index] = Convert.ToByte(value.Substring(index * 2, 2), 16); + } + return result; + } + + private static string ToHex(byte[] value) + { + return BitConverter.ToString(value).Replace("-", string.Empty).ToLowerInvariant(); + } +} diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/SyncSchedulingTests.cs b/KeyStats.Windows/KeyStats.Sync.Tests/SyncSchedulingTests.cs new file mode 100644 index 0000000..ac30d4f --- /dev/null +++ b/KeyStats.Windows/KeyStats.Sync.Tests/SyncSchedulingTests.cs @@ -0,0 +1,97 @@ +using System; +using System.IO; +using System.Reflection; +using System.Runtime.Serialization; +using KeyStats.Models; +using KeyStats.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace KeyStats.Sync.Tests; + +[TestClass] +public sealed class SyncSchedulingTests +{ + [TestMethod] + public void DamagedRemoteCache_RequiresExplicitRepairWithoutBootstrapReplay() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + new SyncStateStore(directory.Path).Save(new SyncState + { + IsEnabled = true, + NeedsBootstrap = false, + VaultId = "vault-id", + DeviceId = "device-id", + DeviceName = "Test device", + ActiveDeviceCount = 2, + RemainingDailySyncs = 8 + }); + new SyncCredentialStore(directory.Path).Save(new SyncCredentials + { + VaultSeed = new byte[16], + DeviceToken = "device-id.test-token" + }); + File.WriteAllText(Path.Combine(directory.Path, "sync_cache.json"), "damaged"); + + var statsManager = (StatsManager)FormatterServices.GetUninitializedObject(typeof(StatsManager)); + using var coordinator = new SyncCoordinator( + statsManager, + directory.Path, + new Uri("https://unit-tests.workers.dev/"), + "test"); + + var status = coordinator.GetStatus(); + var persisted = new SyncStateStore(directory.Path).Load(); + Assert.IsTrue(status.NeedsRepair); + Assert.IsFalse(status.CanRetryBootstrap); + Assert.IsTrue(persisted.NeedsRepair); + Assert.IsFalse(persisted.NeedsBootstrap); + Assert.IsFalse(persisted.NeedsHistoryBootstrap); + } + + [TestMethod] + public void Start_WithOneDevice_DoesNotScheduleOrEnableManualSync() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + var stateStore = new SyncStateStore(directory.Path); + stateStore.Save(new SyncState + { + IsEnabled = true, + VaultId = "vault-id", + DeviceId = "only-device", + DeviceName = "Only device", + ActiveDeviceCount = 1, + RemainingDailySyncs = 8 + }); + new SyncCredentialStore(directory.Path).Save(new SyncCredentials + { + VaultSeed = new byte[16], + DeviceToken = "only-device.test-token" + }); + + // Avoid opening or modifying the real user's statistics directory. The coordinator only + // attaches events and a display projection during construction; neither needs loaded stats. + var statsManager = (StatsManager)FormatterServices.GetUninitializedObject(typeof(StatsManager)); + using var coordinator = new SyncCoordinator( + statsManager, + directory.Path, + new Uri("https://unit-tests.workers.dev/"), + "test"); + coordinator.Start(); + + var status = coordinator.GetStatus(); + Assert.IsTrue(status.IsServiceConfigured); + Assert.IsTrue(status.IsEnabled); + Assert.AreEqual(1, status.ActiveDeviceCount); + Assert.IsFalse(status.CanSync); + Assert.IsFalse(status.CanManualSync); + Assert.IsNull(stateStore.Load().NextAutomaticSyncAtUtc); + + var timerField = typeof(SyncCoordinator).GetField( + "_automaticTimer", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(timerField); + Assert.IsNull(timerField!.GetValue(coordinator)); + } +} diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/SyncStateStoreTests.cs b/KeyStats.Windows/KeyStats.Sync.Tests/SyncStateStoreTests.cs new file mode 100644 index 0000000..7d32018 --- /dev/null +++ b/KeyStats.Windows/KeyStats.Sync.Tests/SyncStateStoreTests.cs @@ -0,0 +1,101 @@ +using System.IO; +using KeyStats.Models; +using KeyStats.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace KeyStats.Sync.Tests; + +[TestClass] +public sealed class SyncStateStoreTests +{ + [TestMethod] + public void Load_WithDamagedPrimary_RestoresValidatedBackup() + { + using var directory = new TestDirectory(); + var path = Path.Combine(directory.Path, "sync_state.json"); + var store = new SyncStateStore(directory.Path); + store.Save(CreateState(activeDeviceCount: 1)); + store.Save(CreateState(activeDeviceCount: 2)); + File.WriteAllText(path, "damaged"); + + var reloadedStore = new SyncStateStore(directory.Path); + var recovered = reloadedStore.Load(); + + Assert.IsFalse(reloadedStore.NeedsRepair); + Assert.IsTrue(reloadedStore.RecoveredFromBackup); + Assert.AreEqual(1, recovered.ActiveDeviceCount); + Assert.AreEqual("device-id", recovered.DeviceId); + } + + [TestMethod] + public void Load_WithNoValidCopy_RequiresRepairWithoutOverwritingPrimary() + { + using var directory = new TestDirectory(); + var path = Path.Combine(directory.Path, "sync_state.json"); + File.WriteAllText(path, "damaged"); + + var store = new SyncStateStore(directory.Path); + var state = store.Load(); + + Assert.IsTrue(store.NeedsRepair); + Assert.IsTrue(state.NeedsRepair); + Assert.AreEqual("damaged", File.ReadAllText(path)); + } + + [TestMethod] + public void Save_HistoryOnlyBootstrapPhaseSurvivesRestart() + { + using var directory = new TestDirectory(); + var store = new SyncStateStore(directory.Path); + var state = CreateState(activeDeviceCount: 2); + state.NeedsBootstrap = false; + state.NeedsHistoryBootstrap = true; + state.HistoryCursor = 42; + + store.Save(state); + var restored = new SyncStateStore(directory.Path).Load(); + + Assert.IsFalse(restored.NeedsBootstrap); + Assert.IsTrue(restored.NeedsHistoryBootstrap); + Assert.AreEqual(42L, restored.HistoryCursor); + } + + [TestMethod] + public void Save_AcknowledgedCurrentEnvelopeSurvivesCrossDayRetry() + { + using var directory = new TestDirectory(); + var store = new SyncStateStore(directory.Path); + var state = CreateState(activeDeviceCount: 2); + state.LastAcknowledgedCurrentSnapshot = new EncryptedSyncRecord + { + RecordId = "prior-current", + DeviceId = "device-id", + Revision = 7, + Nonce = "nonce", + Ciphertext = "ciphertext", + Tag = "tag", + CiphertextHash = "hash" + }; + + store.Save(state); + var restored = new SyncStateStore(directory.Path).Load(); + + Assert.IsNotNull(restored.LastAcknowledgedCurrentSnapshot); + Assert.AreEqual("prior-current", restored.LastAcknowledgedCurrentSnapshot!.RecordId); + Assert.AreEqual(7L, restored.LastAcknowledgedCurrentSnapshot.Revision); + Assert.AreEqual("hash", restored.LastAcknowledgedCurrentSnapshot.CiphertextHash); + } + + private static SyncState CreateState(int activeDeviceCount) + { + return new SyncState + { + IsEnabled = true, + VaultId = "vault-id", + DeviceId = "device-id", + DeviceName = "Test device", + ActiveDeviceCount = activeDeviceCount, + RemainingDailySyncs = 8 + }; + } +} diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/TestDirectory.cs b/KeyStats.Windows/KeyStats.Sync.Tests/TestDirectory.cs new file mode 100644 index 0000000..8291b83 --- /dev/null +++ b/KeyStats.Windows/KeyStats.Sync.Tests/TestDirectory.cs @@ -0,0 +1,32 @@ +using System; +using System.IO; + +namespace KeyStats.Sync.Tests; + +internal sealed class TestDirectory : IDisposable +{ + public string Path { get; } + + public TestDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "KeyStats.Sync.Tests", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public void Dispose() + { + try + { + if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } +} diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/TestPlatform.cs b/KeyStats.Windows/KeyStats.Sync.Tests/TestPlatform.cs new file mode 100644 index 0000000..8f88a73 --- /dev/null +++ b/KeyStats.Windows/KeyStats.Sync.Tests/TestPlatform.cs @@ -0,0 +1,15 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace KeyStats.Sync.Tests; + +internal static class TestPlatform +{ + public static void RequireWindows() + { + if (Environment.OSVersion.Platform != PlatformID.Win32NT) + { + Assert.Inconclusive("This test exercises Windows DPAPI and only runs on Windows."); + } + } +} diff --git a/KeyStats.Windows/KeyStats.sln b/KeyStats.Windows/KeyStats.sln index c1628c3..e71ebdb 100644 --- a/KeyStats.Windows/KeyStats.sln +++ b/KeyStats.Windows/KeyStats.sln @@ -1,10 +1,11 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KeyStats", "KeyStats\KeyStats.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KeyStats.Sync.Tests", "KeyStats.Sync.Tests\KeyStats.Sync.Tests.csproj", "{C73EAAC0-0DA4-4AC2-8192-A5F7E92463D3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,5 +16,9 @@ Global {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {C73EAAC0-0DA4-4AC2-8192-A5F7E92463D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C73EAAC0-0DA4-4AC2-8192-A5F7E92463D3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C73EAAC0-0DA4-4AC2-8192-A5F7E92463D3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C73EAAC0-0DA4-4AC2-8192-A5F7E92463D3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/KeyStats.Windows/KeyStats/App.xaml.cs b/KeyStats.Windows/KeyStats/App.xaml.cs index d93853a..d456dd6 100644 --- a/KeyStats.Windows/KeyStats/App.xaml.cs +++ b/KeyStats.Windows/KeyStats/App.xaml.cs @@ -35,9 +35,11 @@ public partial class App : System.Windows.Application private AppStatsWindow? _appStatsWindow; private KeyboardHeatmapWindow? _keyboardHeatmapWindow; private KeyHistoryWindow? _keyHistoryWindow; + private SyncSettingsWindow? _syncSettingsWindow; private System.Threading.Mutex? _singleInstanceMutex; private string? _appVersion; private IPostHogAnalytics? _postHogClient; + private SyncCoordinator? _syncCoordinator; private long _lastResumeRecoveryTicks; protected override void OnStartup(StartupEventArgs e) @@ -104,6 +106,16 @@ protected override void OnStartup(StartupEventArgs e) var statsManager = StatsManager.Instance; StartupManager.Instance.SyncWithSettings(); _appVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "0.0.0"; + SyncServiceConfiguration.TryCreateBaseUri(out var syncServiceUri); + var dataFolder = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "KeyStats"); + _syncCoordinator = new SyncCoordinator( + statsManager, + dataFolder, + syncServiceUri, + _appVersion); + _syncCoordinator.Start(); InitializeAnalytics(statsManager); InputMonitorService.Instance.StartMonitoring(); @@ -240,6 +252,20 @@ public void ShowSettingsWindow() _settingsWindow.Activate(); } + public void ShowSyncSettingsWindow() + { + if (_syncSettingsWindow != null && _syncSettingsWindow.IsVisible) + { + _syncSettingsWindow.Activate(); + return; + } + + _syncSettingsWindow = new SyncSettingsWindow(); + _syncSettingsWindow.Closed += (_, _) => _syncSettingsWindow = null; + _syncSettingsWindow.Show(); + _syncSettingsWindow.Activate(); + } + public void ShowStatsPanel() { _trayIconViewModel?.ShowStatsCommand.Execute(null); @@ -364,6 +390,11 @@ public void ImportData() try { + if (_syncCoordinator?.BlocksImport == true) + { + throw new InvalidOperationException(KeyStats.Properties.Strings.Error_ImportDisabledWhileSyncing); + } + var dialog = new OpenFileDialog { Title = KeyStats.Properties.Strings.Dialog_ImportTitle, @@ -457,6 +488,8 @@ protected override void OnExit(ExitEventArgs e) _trayIcon = null; } InputMonitorService.Instance.StopMonitoring(); + _syncCoordinator?.Dispose(); + _syncCoordinator = null; StatsManager.Instance.Dispose(); ThemeManager.Instance.Dispose(); _singleInstanceMutex?.ReleaseMutex(); @@ -863,6 +896,7 @@ public void TrackClick(string elementName, Dictionary? extraPro /// 获取 App 实例(用于其他类调用追踪方法) /// public static App? CurrentApp => Current as App; + public SyncCoordinator? SyncCoordinator => _syncCoordinator; private void RegisterSystemEventHandlers() { @@ -962,6 +996,7 @@ private void RecoverAfterResume(string trigger) { Console.WriteLine($"Tray icon resume recovery failed: {ex}"); } + _syncCoordinator?.HandleAppResume(); } private void RecreateTrayIntegration() diff --git a/KeyStats.Windows/KeyStats/KeyStats.csproj b/KeyStats.Windows/KeyStats/KeyStats.csproj index 36181f0..cc0c239 100644 --- a/KeyStats.Windows/KeyStats/KeyStats.csproj +++ b/KeyStats.Windows/KeyStats/KeyStats.csproj @@ -21,6 +21,7 @@ KeyStats Keyboard and mouse statistics tracker for Windows 10.0 + @@ -31,11 +32,21 @@ + + + + + + <_Parameter1>SyncServiceBaseUrl + <_Parameter2>$(SyncServiceBaseUrl) + + + diff --git a/KeyStats.Windows/KeyStats/Models/SyncModels.cs b/KeyStats.Windows/KeyStats/Models/SyncModels.cs new file mode 100644 index 0000000..52f55a4 --- /dev/null +++ b/KeyStats.Windows/KeyStats/Models/SyncModels.cs @@ -0,0 +1,362 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace KeyStats.Models; + +public static class SyncProtocol +{ + public const int SchemaVersion = 1; + public const int MaximumDevices = 5; + public const int MaximumKeyEntries = 512; + public const int MaximumKeyNameBytes = 64; + public const int MaximumSnapshotBytes = 64 * 1024; + public const int MaximumArchivesPerRequest = 16; + public const int MaximumBootstrapRequests = 256; + public const int MaximumBootstrapArchives = MaximumArchivesPerRequest * MaximumBootstrapRequests; + public const int MaximumHistoryPagesPerAttempt = 256; + public const int MaximumAutomaticFailuresPerUtcDay = 3; + public static readonly TimeSpan ManualSyncInterval = TimeSpan.FromHours(1); + public static readonly TimeSpan AutomaticSyncInterval = TimeSpan.FromHours(24); +} + +public sealed class CoreDaySnapshotV1 +{ + public int SchemaVersion { get; set; } = SyncProtocol.SchemaVersion; + public string DeviceId { get; set; } = string.Empty; + public string LocalDay { get; set; } = string.Empty; + public long Revision { get; set; } + public long KeyPresses { get; set; } + public Dictionary KeyPressCounts { get; set; } = new(StringComparer.Ordinal); + public CoreClickSnapshotV1 Clicks { get; set; } = new(); +} + +public sealed class CoreClickSnapshotV1 +{ + public long Left { get; set; } + public long Right { get; set; } + public long Middle { get; set; } + public long SideBack { get; set; } + public long SideForward { get; set; } +} + +public sealed class EncryptedSyncRecord +{ + public int SchemaVersion { get; set; } = SyncProtocol.SchemaVersion; + public string RecordId { get; set; } = string.Empty; + public string DeviceId { get; set; } = string.Empty; + public long Revision { get; set; } + public string Nonce { get; set; } = string.Empty; + public string Ciphertext { get; set; } = string.Empty; + public string Tag { get; set; } = string.Empty; + public string CiphertextHash { get; set; } = string.Empty; +} + +public sealed class SyncRequest +{ + public string Reason { get; set; } = "manual"; + public long HistoryCursor { get; set; } + public EncryptedSyncRecord? CurrentSnapshot { get; set; } + public List Archives { get; set; } = new(); + public PairingEncryptedPayload? EncryptedDeviceProfile { get; set; } + public bool BootstrapComplete { get; set; } = true; +} + +public sealed class SyncResponse +{ + public DateTime ServerTime { get; set; } + public DateTime NextAllowedSyncAt { get; set; } + public int RemainingDailySyncs { get; set; } + public int ActiveDeviceCount { get; set; } + public List CurrentSnapshots { get; set; } = new(); + public List HistoryChanges { get; set; } = new(); + public bool HistoryHasMore { get; set; } + public long Cursor { get; set; } + public List Devices { get; set; } = new(); +} + +public sealed class SyncHistoryChange +{ + public long Cursor { get; set; } + public EncryptedSyncRecord? Record { get; set; } + public string RecordId { get; set; } = string.Empty; + public bool Tombstone { get; set; } +} + +public sealed class HistoryResponse +{ + public List Changes { get; set; } = new(); + public long Cursor { get; set; } + public bool HasMore { get; set; } +} + +public sealed class CreateVaultRequest +{ + public string VaultId { get; set; } = string.Empty; + public string DeviceId { get; set; } = string.Empty; + public string DeviceToken { get; set; } = string.Empty; + public string RecoveryAuthToken { get; set; } = string.Empty; + public PairingEncryptedPayload EncryptedDeviceProfile { get; set; } = new(); +} + +public sealed class CreateVaultResponse +{ + public string VaultId { get; set; } = string.Empty; + public string DeviceId { get; set; } = string.Empty; + public string DeviceToken { get; set; } = string.Empty; + public int ActiveDeviceCount { get; set; } = 1; + public DateTime ServerTime { get; set; } +} + +public sealed class CreatePairingSessionRequest +{ + public string DeviceId { get; set; } = string.Empty; + public string JoiningPublicKey { get; set; } = string.Empty; +} + +public sealed class CreatePairingSessionResponse +{ + public string SessionId { get; set; } = string.Empty; + public string Code { get; set; } = string.Empty; + public string CompletionToken { get; set; } = string.Empty; + public DateTime ExpiresAt { get; set; } +} + +public sealed class JoinPairingSessionRequest +{ + public string ApprovingPublicKey { get; set; } = string.Empty; +} + +public sealed class JoinPairingSessionResponse +{ + public string SessionId { get; set; } = string.Empty; + public string JoiningDeviceId { get; set; } = string.Empty; + public string JoiningPublicKey { get; set; } = string.Empty; + public bool ReplacedExistingDevice { get; set; } + public DateTime? ExpiresAt { get; set; } +} + +public sealed class ApprovePairingSessionRequest +{ + public string ApprovingPublicKey { get; set; } = string.Empty; + public PairingEncryptedPayload EncryptedGrant { get; set; } = new(); + public string NewDeviceToken { get; set; } = string.Empty; +} + +public sealed class PairingEncryptedPayload +{ + public string Nonce { get; set; } = string.Empty; + public string Ciphertext { get; set; } = string.Empty; + public string Tag { get; set; } = string.Empty; +} + +public sealed class CompletePairingSessionResponse +{ + public bool Pending { get; set; } + public bool RequiresProfile { get; set; } + public string? ApprovingPublicKey { get; set; } + public PairingEncryptedPayload? EncryptedGrant { get; set; } + public bool ReplacedExistingDevice { get; set; } + public int? ActiveDeviceCount { get; set; } + public DateTime? ServerTime { get; set; } +} + +public sealed class CompletePairingSessionRequest +{ + public string CompletionToken { get; set; } = string.Empty; + public PairingEncryptedPayload? EncryptedDeviceProfile { get; set; } +} + +public sealed class RecoverVaultRequest +{ + public string RecoveryAuthToken { get; set; } = string.Empty; + public string DeviceId { get; set; } = string.Empty; + public string DeviceToken { get; set; } = string.Empty; + public string? ReplaceDeviceId { get; set; } +} + +public sealed class RecoverVaultResponse +{ + public string VaultId { get; set; } = string.Empty; + public string DeviceId { get; set; } = string.Empty; + public string DeviceToken { get; set; } = string.Empty; + public int ActiveDeviceCount { get; set; } + public DateTime ServerTime { get; set; } + public long Cursor { get; set; } + public EncryptedSyncRecord? CurrentSnapshot { get; set; } +} + +public sealed class SyncStateResponse +{ + public DateTime ServerTime { get; set; } + public int ActiveDeviceCount { get; set; } + public List Devices { get; set; } = new(); + public List CurrentSnapshots { get; set; } = new(); +} + +public sealed class PairingProvisioningPayload +{ + public string VaultId { get; set; } = string.Empty; + public string RecoverySeed { get; set; } = string.Empty; + public string? DeviceToken { get; set; } +} + +public sealed class DeviceProfileV1 +{ + public int SchemaVersion { get; set; } = SyncProtocol.SchemaVersion; + public string DisplayName { get; set; } = string.Empty; + public string Platform { get; set; } = "windows"; +} + +public sealed class DeviceSummary +{ + public string DeviceId { get; set; } = string.Empty; + public PairingEncryptedPayload? EncryptedDeviceProfile { get; set; } + public DateTime? LastSyncAt { get; set; } + public bool Revoked { get; set; } +} + +public sealed class SyncDeviceInfo +{ + public string DeviceId { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string Platform { get; set; } = string.Empty; + public DateTime? LastSyncAt { get; set; } + public bool IsCurrent { get; set; } + public bool IsRevoked { get; set; } +} + +public sealed class RecoveryReplacementOption +{ + public string DeviceId { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string Platform { get; set; } = string.Empty; +} + +public sealed class SyncState +{ + public int Version { get; set; } = 1; + public bool IsEnabled { get; set; } + public bool NeedsRepair { get; set; } + public bool NeedsBootstrap { get; set; } + public bool NeedsHistoryBootstrap { get; set; } + public bool NeedsStateRefreshBeforeBootstrap { get; set; } + public string InstallationDeviceId { get; set; } = string.Empty; + public string? ReplacementCandidateDeviceId { get; set; } + public string? PendingProvisioningKind { get; set; } + public string? PendingProvisioningDisplayName { get; set; } + public string? PendingProvisioningReplaceDeviceId { get; set; } + public string? PendingBootstrapReason { get; set; } + public bool PendingVaultDeletion { get; set; } + public string VaultId { get; set; } = string.Empty; + public string DeviceId { get; set; } = string.Empty; + public string DeviceName { get; set; } = string.Empty; + public string Platform { get; set; } = "windows"; + public DateTime? LastSuccessfulSyncAtUtc { get; set; } + public DateTime? NextAllowedSyncAtUtc { get; set; } + public DateTime? NextAutomaticSyncAtUtc { get; set; } + public DateTime? LastStateRefreshAtUtc { get; set; } + public int RemainingDailySyncs { get; set; } = 8; + public string? QuotaUtcDay { get; set; } + public int ActiveDeviceCount { get; set; } + public long HistoryCursor { get; set; } + public string? AutomaticFailureUtcDay { get; set; } + public int AutomaticFailureCount { get; set; } + public PairingEncryptedPayload? PendingEncryptedDeviceProfile { get; set; } + public EncryptedSyncRecord? LastAcknowledgedCurrentSnapshot { get; set; } + public Dictionary LocalRecords { get; set; } = new(StringComparer.Ordinal); + public List Devices { get; set; } = new(); +} + +public sealed class LocalSyncRecordState +{ + public string LocalDay { get; set; } = string.Empty; + public string ContentHash { get; set; } = string.Empty; + public long Revision { get; set; } + public bool IsPending { get; set; } + public bool UploadedAsArchive { get; set; } + public EncryptedSyncRecord Envelope { get; set; } = new(); +} + +public sealed class CachedRemoteRecord +{ + public string RecordId { get; set; } = string.Empty; + public string DeviceId { get; set; } = string.Empty; + public long Revision { get; set; } + public string CiphertextHash { get; set; } = string.Empty; + public bool IsCurrent { get; set; } + public CoreDaySnapshotV1 Plaintext { get; set; } = new(); +} + +public sealed class PairingSessionContext +{ + [JsonIgnore] + public byte[] PrivateKey { get; set; } = Array.Empty(); + public byte[] PublicKey { get; set; } = Array.Empty(); + public string SessionId { get; set; } = string.Empty; + public string Code { get; set; } = string.Empty; + public string CompletionToken { get; set; } = string.Empty; + public string ProposedDeviceId { get; set; } = string.Empty; + public DateTime ExpiresAt { get; set; } +} + +public sealed class PairingApprovalContext +{ + public byte[] PrivateKey { get; set; } = Array.Empty(); + public byte[] PublicKey { get; set; } = Array.Empty(); + public byte[] PeerPublicKey { get; set; } = Array.Empty(); + public string SessionId { get; set; } = string.Empty; + public string NewDeviceId { get; set; } = string.Empty; + public string SafetyCode { get; set; } = string.Empty; + public DateTime ExpiresAt { get; set; } +} + +public sealed class PairingCompletionPreview +{ + public PairingSessionContext Context { get; set; } = new(); + public CompletePairingSessionResponse Response { get; set; } = new(); + public string SafetyCode { get; set; } = string.Empty; +} + +public sealed class SyncCredentials +{ + public string VaultId { get; set; } = string.Empty; + public string DeviceId { get; set; } = string.Empty; + public byte[] VaultSeed { get; set; } = Array.Empty(); + public string DeviceToken { get; set; } = string.Empty; +} + +public sealed class PendingSyncSecrets +{ + public int Version { get; set; } = 1; + public string Kind { get; set; } = string.Empty; + public string VaultId { get; set; } = string.Empty; + public string DeviceId { get; set; } = string.Empty; + public byte[] VaultSeed { get; set; } = Array.Empty(); + public string DeviceToken { get; set; } = string.Empty; + public string? ReplaceDeviceId { get; set; } + public byte[] PairingPrivateKey { get; set; } = Array.Empty(); + public byte[] PairingPublicKey { get; set; } = Array.Empty(); + public string PairingSessionId { get; set; } = string.Empty; + public string PairingCode { get; set; } = string.Empty; + public string PairingCompletionToken { get; set; } = string.Empty; + public DateTime? PairingExpiresAt { get; set; } +} + +public sealed class SyncStatusSnapshot +{ + public bool IsServiceConfigured { get; set; } + public bool IsEnabled { get; set; } + public bool NeedsRepair { get; set; } + public bool NeedsBootstrap { get; set; } + public bool BlocksImport { get; set; } + public bool IsBusy { get; set; } + public bool CanSync { get; set; } + public bool CanManualSync { get; set; } + public bool CanRetryBootstrap { get; set; } + public int ActiveDeviceCount { get; set; } + public DateTime? LastSuccessfulSyncAtUtc { get; set; } + public DateTime? NextAllowedSyncAtUtc { get; set; } + public int RemainingDailySyncs { get; set; } + public string? LastError { get; set; } +} diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.cs b/KeyStats.Windows/KeyStats/Properties/Strings.cs index 2704a1e..47d4b27 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.cs +++ b/KeyStats.Windows/KeyStats/Properties/Strings.cs @@ -22,6 +22,7 @@ public static class Strings public static string Error_ImportInvalidFormat => Get(nameof(Error_ImportInvalidFormat)); public static string Error_ImportUnsupportedVersion => Get(nameof(Error_ImportUnsupportedVersion)); public static string Error_ImportEmpty => Get(nameof(Error_ImportEmpty)); + public static string Error_ImportDisabledWhileSyncing => Get(nameof(Error_ImportDisabledWhileSyncing)); public static string Notif_ThresholdTitle => Get(nameof(Notif_ThresholdTitle)); public static string Notif_KeyThresholdReachedFormat => Get(nameof(Notif_KeyThresholdReachedFormat)); @@ -68,6 +69,56 @@ public static class Strings public static string Settings_DistanceCalibrationDesc => Get(nameof(Settings_DistanceCalibrationDesc)); public static string Settings_VersionFormat => Get(nameof(Settings_VersionFormat)); public static string Settings_OpenGitHubFailedMessage => Get(nameof(Settings_OpenGitHubFailedMessage)); + public static string Settings_Sync => Get(nameof(Settings_Sync)); + public static string Settings_SyncDesc => Get(nameof(Settings_SyncDesc)); + public static string Settings_SyncUnavailable => Get(nameof(Settings_SyncUnavailable)); + + public static string Sync_WindowTitle => Get(nameof(Sync_WindowTitle)); + public static string Sync_HeaderTitle => Get(nameof(Sync_HeaderTitle)); + public static string Sync_HeaderSubtitle => Get(nameof(Sync_HeaderSubtitle)); + public static string Sync_ServiceUnavailable => Get(nameof(Sync_ServiceUnavailable)); + public static string Sync_CreateTitle => Get(nameof(Sync_CreateTitle)); + public static string Sync_CreateDescription => Get(nameof(Sync_CreateDescription)); + public static string Sync_CreateButton => Get(nameof(Sync_CreateButton)); + public static string Sync_JoinTitle => Get(nameof(Sync_JoinTitle)); + public static string Sync_JoinDescription => Get(nameof(Sync_JoinDescription)); + public static string Sync_BeginPairingButton => Get(nameof(Sync_BeginPairingButton)); + public static string Sync_CompletePairingButton => Get(nameof(Sync_CompletePairingButton)); + public static string Sync_RecoverTitle => Get(nameof(Sync_RecoverTitle)); + public static string Sync_RecoverDescription => Get(nameof(Sync_RecoverDescription)); + public static string Sync_RecoverButton => Get(nameof(Sync_RecoverButton)); + public static string Sync_StatusTitle => Get(nameof(Sync_StatusTitle)); + public static string Sync_NowButton => Get(nameof(Sync_NowButton)); + public static string Sync_BootstrapPendingStatus => Get(nameof(Sync_BootstrapPendingStatus)); + public static string Sync_RetryBootstrapButton => Get(nameof(Sync_RetryBootstrapButton)); + public static string Sync_RepairRequired => Get(nameof(Sync_RepairRequired)); + public static string Sync_ClearRepairButton => Get(nameof(Sync_ClearRepairButton)); + public static string Sync_ClearRepairConfirm => Get(nameof(Sync_ClearRepairConfirm)); + public static string Sync_RecoveryReplaceTitle => Get(nameof(Sync_RecoveryReplaceTitle)); + public static string Sync_RecoveryReplaceMessage => Get(nameof(Sync_RecoveryReplaceMessage)); + public static string Sync_RecoveryReplaceConfirm => Get(nameof(Sync_RecoveryReplaceConfirm)); + public static string Sync_ApproveTitle => Get(nameof(Sync_ApproveTitle)); + public static string Sync_ApproveDescription => Get(nameof(Sync_ApproveDescription)); + public static string Sync_ApproveButton => Get(nameof(Sync_ApproveButton)); + public static string Sync_RecoveryCodeTitle => Get(nameof(Sync_RecoveryCodeTitle)); + public static string Sync_RecoveryCodeWarning => Get(nameof(Sync_RecoveryCodeWarning)); + public static string Sync_ShowRecoveryCodeButton => Get(nameof(Sync_ShowRecoveryCodeButton)); + public static string Sync_LeaveButton => Get(nameof(Sync_LeaveButton)); + public static string Sync_LeaveConfirm => Get(nameof(Sync_LeaveConfirm)); + public static string Sync_DeleteVaultButton => Get(nameof(Sync_DeleteVaultButton)); + public static string Sync_DeleteVaultConfirm => Get(nameof(Sync_DeleteVaultConfirm)); + public static string Sync_SingleDeviceStatus => Get(nameof(Sync_SingleDeviceStatus)); + public static string Sync_DeviceCountFormat => Get(nameof(Sync_DeviceCountFormat)); + public static string Sync_LastSuccessFormat => Get(nameof(Sync_LastSuccessFormat)); + public static string Sync_NeverSynced => Get(nameof(Sync_NeverSynced)); + public static string Sync_DevicesTitle => Get(nameof(Sync_DevicesTitle)); + public static string Sync_ThisDevice => Get(nameof(Sync_ThisDevice)); + public static string Sync_RevokeButton => Get(nameof(Sync_RevokeButton)); + public static string Sync_RevokeConfirmFormat => Get(nameof(Sync_RevokeConfirmFormat)); + public static string Sync_SafetyCodeTitle => Get(nameof(Sync_SafetyCodeTitle)); + public static string Sync_SafetyCodeConfirmFormat => Get(nameof(Sync_SafetyCodeConfirmFormat)); + public static string Sync_ApprovalComplete => Get(nameof(Sync_ApprovalComplete)); + public static string Sync_GenericError => Get(nameof(Sync_GenericError)); public static string Stats_TodayHeader => Get(nameof(Stats_TodayHeader)); public static string Stats_KeyPresses => Get(nameof(Stats_KeyPresses)); diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.resx b/KeyStats.Windows/KeyStats/Properties/Strings.resx index e81a266..f181acb 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.resx +++ b/KeyStats.Windows/KeyStats/Properties/Strings.resx @@ -101,7 +101,7 @@ Manage import/export and notification settings here Open GitHub repository Data Import / Export - Import or export statistics from JSON + Import or export this device's local statistics as JSON Notifications Configure key/click threshold notifications Distance Calibration @@ -224,4 +224,54 @@ Are you sure you want to perform this action? Confirm OK + Import is disabled while device sync is enabled. Leave sync first; export always contains this device's local data only. + Device Sync + End-to-end encrypted sync for your core key and click totals + Sync service is not configured in this build + Device Sync - KeyStats + Device Sync + Core key and click totals are encrypted on this device before upload. App activity, distance, and peak rates stay local. + This build does not have a sync service URL. No network request will be sent. + Start on this device + Create an encrypted vault and upload the initial encrypted history. After setup, ordinary and manual sync stay off until a second device is connected. + Create vault + Add this device + Generate a six-digit code, then approve it on a device already connected to the vault. + Generate code + Finish pairing + Recover with code + Use your 28-character recovery code to add this device when another device is unavailable. + Recover + Sync status + Sync now + The initial encrypted history upload is incomplete. + Retry setup + The local sync configuration could not be opened safely. Creating a new vault is blocked. Recover or pair this device again, or clear only the local sync configuration. Local statistics are unchanged. + Clear local sync configuration + Clear the damaged or incomplete sync configuration and encrypted credentials from this computer? Local statistics will remain. This does not delete cloud data. + Replace a connected device + This vault already has five devices. Choose one device identity to take over. That device's token will be revoked, but its encrypted history remains. + Replace device + Approve another device + Enter the six-digit code shown on the new device, then compare the safety code on both devices. + Review + Recovery code + Anyone with this code can recover your vault. Keep it offline and never send it to support. + Show recovery code + Remove this device from sync + Remove this device from the encrypted vault? Local statistics will remain on this computer. + Delete all cloud sync data + Permanently delete the encrypted vault for every device? Access will be revoked immediately. Local statistics on this computer will remain. + One connected device — statistics are not uploaded and manual sync is unavailable. + {0} connected devices + Last successful sync: {0} + No successful sync yet + Connected devices + This device + Remove + Remove {0} from the encrypted vault? + Compare safety code + Confirm that both devices show this code: {0} + Device approved. Finish pairing on the new device. + Sync could not be completed. Check your connection and try again. diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx index 682f399..531ca7f 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx +++ b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx @@ -101,7 +101,7 @@ 在这里管理导入导出与通知设置 打开 GitHub 仓库 数据导入 / 导出 - 从 JSON 导入或导出统计数据 + 以 JSON 导入或导出这台设备的本机统计 通知设置 设置按键/点击阈值通知 距离校准 @@ -224,4 +224,54 @@ 确定要执行此操作吗? 确认 确定 + 设备同步开启时不能导入数据。请先退出同步;导出始终只包含本机数据。 + 设备同步 + 端到端加密同步核心按键和点击总数 + 此版本尚未配置同步服务 + 设备同步 - KeyStats + 设备同步 + 核心按键和点击总数会先在本机加密再上传。应用统计、距离和峰值仅保留在本机。 + 此版本没有配置同步服务地址,不会发出任何网络请求。 + 从这台设备开始 + 创建加密资料库并首次上传加密历史。设置完成后,连接第二台设备前不会进行普通同步,也不能手动同步。 + 创建资料库 + 添加这台设备 + 生成六位验证码,然后在已连接的设备上批准。 + 生成验证码 + 完成配对 + 使用恢复码 + 其他设备不可用时,可用 28 位恢复码添加这台设备。 + 恢复 + 同步状态 + 立即同步 + 首次加密历史上传尚未完成。 + 重试设置 + 无法安全读取本机同步配置,已禁止创建新的资料库。请使用恢复码或重新配对,也可以只清除本机同步配置。本机统计不会改变。 + 清除本机同步配置 + 要清除这台电脑上损坏或未完成的同步配置和加密凭证吗?本机统计不会删除,云端数据也不会被删除。 + 替换一台已连接设备 + 此资料库已有五台设备。请选择要接管的一台设备身份。该设备的令牌会被撤销,但其加密历史仍会保留。 + 替换设备 + 批准另一台设备 + 输入新设备上的六位验证码,然后核对两台设备的安全码。 + 核对 + 恢复码 + 任何拿到恢复码的人都能恢复资料库。请离线保存,也不要发给客服。 + 显示恢复码 + 从同步中移除这台设备 + 要从加密资料库中移除这台设备吗?本机统计不会被删除。 + 删除全部云端同步数据 + 要永久删除所有设备的加密资料库吗?所有设备会立即失去访问权限,本机统计仍会保留。 + 当前只有一台设备,不上传统计,也不能手动同步。 + 已连接 {0} 台设备 + 上次成功同步:{0} + 尚未成功同步 + 已连接设备 + 这台设备 + 移除 + 要从加密资料库中移除 {0} 吗? + 核对安全码 + 请确认两台设备都显示此安全码:{0} + 设备已批准,请在新设备上完成配对。 + 同步未完成,请检查网络后重试。 diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx index 1333340..9a8a42f 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx +++ b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx @@ -101,7 +101,7 @@ 在這裡管理匯入、匯出與通知設定 開啟 GitHub 儲存庫 資料匯入 / 匯出 - 從 JSON 匯入或匯出統計資料 + 以 JSON 匯入或匯出這台裝置的本機統計 通知設定 設定按鍵/點擊門檻通知 距離校準 @@ -224,4 +224,54 @@ 確定要執行此操作嗎? 確認 確定 + 裝置同步開啟時不能匯入資料。請先離開同步;匯出永遠只包含本機資料。 + 裝置同步 + 端對端加密同步核心按鍵和點擊總數 + 此版本尚未設定同步服務 + 裝置同步 - KeyStats + 裝置同步 + 核心按鍵和點擊總數會先在本機加密再上傳。應用程式統計、距離和峰值僅保留在本機。 + 此版本沒有設定同步服務位址,不會發出任何網路請求。 + 從這台裝置開始 + 建立加密資料庫並首次上傳加密歷史。設定完成後,連接第二台裝置前不會進行一般同步,也不能手動同步。 + 建立資料庫 + 加入這台裝置 + 產生六位驗證碼,然後在已連接的裝置上核准。 + 產生驗證碼 + 完成配對 + 使用恢復碼 + 其他裝置無法使用時,可用 28 位恢復碼加入這台裝置。 + 恢復 + 同步狀態 + 立即同步 + 首次加密歷史上傳尚未完成。 + 重試設定 + 無法安全讀取本機同步設定,已禁止建立新的資料庫。請使用恢復碼或重新配對,也可以只清除本機同步設定。本機統計不會改變。 + 清除本機同步設定 + 要清除這台電腦上損壞或未完成的同步設定和加密憑證嗎?本機統計不會刪除,雲端資料也不會被刪除。 + 取代一台已連接裝置 + 此資料庫已有五台裝置。請選擇要接管的一台裝置身分。該裝置的權杖會被撤銷,但其加密歷史仍會保留。 + 取代裝置 + 核准另一台裝置 + 輸入新裝置上的六位驗證碼,然後核對兩台裝置的安全碼。 + 核對 + 恢復碼 + 任何取得恢復碼的人都能恢復資料庫。請離線保存,也不要傳給客服。 + 顯示恢復碼 + 從同步中移除這台裝置 + 要從加密資料庫中移除這台裝置嗎?本機統計不會被刪除。 + 刪除全部雲端同步資料 + 要永久刪除所有裝置的加密資料庫嗎?所有裝置會立即失去存取權,本機統計仍會保留。 + 目前只有一台裝置,不上傳統計,也不能手動同步。 + 已連接 {0} 台裝置 + 上次成功同步:{0} + 尚未成功同步 + 已連接裝置 + 這台裝置 + 移除 + 要從加密資料庫中移除 {0} 嗎? + 核對安全碼 + 請確認兩台裝置都顯示此安全碼:{0} + 裝置已核准,請在新裝置上完成配對。 + 同步未完成,請檢查網路後再試一次。 diff --git a/KeyStats.Windows/KeyStats/Services/DisplayStatsAggregator.cs b/KeyStats.Windows/KeyStats/Services/DisplayStatsAggregator.cs new file mode 100644 index 0000000..e62bdca --- /dev/null +++ b/KeyStats.Windows/KeyStats/Services/DisplayStatsAggregator.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using KeyStats.Models; + +namespace KeyStats.Services; + +public sealed class DisplayStatsAggregator +{ + private readonly RemoteShardCache _remoteCache; + private readonly Func _localDeviceId; + + public DisplayStatsAggregator(RemoteShardCache remoteCache, Func localDeviceId) + { + _remoteCache = remoteCache; + _localDeviceId = localDeviceId; + } + + public DailyStats Aggregate(DateTime date, DailyStats localStats) + { + var day = date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + var result = Clone(localStats, date.Date); + var localDeviceId = _localDeviceId(); + + foreach (var record in _remoteCache.GetAll().Where(record => + string.Equals(record.Plaintext.LocalDay, day, StringComparison.Ordinal) && + !string.Equals(record.DeviceId, localDeviceId, StringComparison.Ordinal))) + { + var remote = record.Plaintext; + result.KeyPresses = SafeAdd(result.KeyPresses, remote.KeyPresses); + result.LeftClicks = SafeAdd(result.LeftClicks, remote.Clicks.Left); + result.RightClicks = SafeAdd(result.RightClicks, remote.Clicks.Right); + result.MiddleClicks = SafeAdd(result.MiddleClicks, remote.Clicks.Middle); + result.SideBackClicks = SafeAdd(result.SideBackClicks, remote.Clicks.SideBack); + result.SideForwardClicks = SafeAdd(result.SideForwardClicks, remote.Clicks.SideForward); + + foreach (var keyCount in remote.KeyPressCounts) + { + var key = (keyCount.Key ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(key) || keyCount.Value <= 0) continue; + result.KeyPressCounts[key] = SafeAdd( + result.KeyPressCounts.TryGetValue(key, out var existing) ? existing : 0, + keyCount.Value); + } + } + + return result; + } + + public IReadOnlyCollection GetRemoteDays() + { + return _remoteCache.GetAvailableDays() + .Select(day => DateTime.TryParseExact( + day, + "yyyy-MM-dd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var parsed) + ? (DateTime?)parsed.Date + : null) + .Where(value => value.HasValue) + .Select(value => value!.Value) + .ToList(); + } + + private static DailyStats Clone(DailyStats source, DateTime date) + { + return new DailyStats(date) + { + KeyPresses = Math.Max(0, source.KeyPresses), + KeyPressCounts = new Dictionary(source.KeyPressCounts, StringComparer.Ordinal), + LeftClicks = Math.Max(0, source.LeftClicks), + RightClicks = Math.Max(0, source.RightClicks), + MiddleClicks = Math.Max(0, source.MiddleClicks), + SideBackClicks = Math.Max(0, source.SideBackClicks), + SideForwardClicks = Math.Max(0, source.SideForwardClicks), + MouseDistance = Math.Max(0, source.MouseDistance), + ScrollDistance = Math.Max(0, source.ScrollDistance), + PeakKPS = Math.Max(0, source.PeakKPS), + PeakCPS = Math.Max(0, source.PeakCPS), + AppStats = source.AppStats.ToDictionary(pair => pair.Key, pair => new AppStats(pair.Value)) + }; + } + + private static int SafeAdd(int left, long right) + { + if (right <= 0) return Math.Max(0, left); + var total = (long)Math.Max(0, left) + right; + return total >= int.MaxValue ? int.MaxValue : (int)total; + } +} diff --git a/KeyStats.Windows/KeyStats/Services/RemoteShardCache.cs b/KeyStats.Windows/KeyStats/Services/RemoteShardCache.cs new file mode 100644 index 0000000..890f8a8 --- /dev/null +++ b/KeyStats.Windows/KeyStats/Services/RemoteShardCache.cs @@ -0,0 +1,313 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using KeyStats.Models; + +namespace KeyStats.Services; + +public sealed class RemoteShardCache +{ + private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("KeyStats.Sync.RemoteCache.v1"); + private readonly string _path; + private readonly object _lock = new(); + private readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + private CachePayload _payload = new(); + + public bool NeedsRepair { get; private set; } + public event Action? Changed; + + public RemoteShardCache(string dataFolder) + { + Directory.CreateDirectory(dataFolder); + _path = Path.Combine(dataFolder, "sync_cache.json"); + Load(); + } + + public bool Apply(CachedRemoteRecord incoming) + { + ValidateIncoming(incoming); + var key = CreateKey(incoming.DeviceId, incoming.RecordId); + var changed = false; + lock (_lock) + { + if (_payload.Tombstones.TryGetValue(incoming.RecordId, out var tombstoneRevision) && + tombstoneRevision >= incoming.Revision) + { + return false; + } + + if (_payload.Records.TryGetValue(key, out var existing)) + { + if (existing.Revision > incoming.Revision) return false; + if (existing.Revision == incoming.Revision) + { + if (!string.Equals(existing.CiphertextHash, incoming.CiphertextHash, StringComparison.Ordinal)) + { + throw new RemoteShardConflictException(); + } + + if (existing.IsCurrent == incoming.IsCurrent) return false; + existing.IsCurrent = incoming.IsCurrent; + SaveLocked(); + changed = true; + } + } + + if (!changed) + { + if (incoming.IsCurrent) + { + foreach (var current in _payload.Records.Values.Where(record => + record.IsCurrent && + string.Equals(record.DeviceId, incoming.DeviceId, StringComparison.Ordinal))) + { + current.IsCurrent = false; + } + } + + _payload.Records[key] = DeepClone(incoming); + SaveLocked(); + changed = true; + } + } + + if (changed) Changed?.Invoke(); + return changed; + } + + public bool ApplyTombstone(string recordId, long sequence) + { + if (string.IsNullOrWhiteSpace(recordId) || sequence <= 0) + { + return false; + } + + var changed = false; + lock (_lock) + { + if (_payload.Tombstones.TryGetValue(recordId, out var existingRevision) && existingRevision >= sequence) + { + return false; + } + + _payload.Tombstones[recordId] = sequence; + foreach (var key in _payload.Records + .Where(pair => string.Equals(pair.Value.RecordId, recordId, StringComparison.Ordinal)) + .Select(pair => pair.Key) + .ToList()) + { + _payload.Records.Remove(key); + } + SaveLocked(); + changed = true; + } + + if (changed) Changed?.Invoke(); + return changed; + } + + public IReadOnlyList GetAll() + { + lock (_lock) + { + return _payload.Records.Values.Select(DeepClone).ToList(); + } + } + + public IReadOnlyCollection GetAvailableDays() + { + lock (_lock) + { + return _payload.Records.Values + .Select(record => record.Plaintext.LocalDay) + .Where(day => !string.IsNullOrWhiteSpace(day)) + .Distinct(StringComparer.Ordinal) + .ToList(); + } + } + + public void ResetForBootstrap() + { + lock (_lock) + { + _payload = new CachePayload(); + NeedsRepair = false; + SaveLocked(); + } + Changed?.Invoke(); + } + + public void Clear() + { + lock (_lock) + { + _payload = new CachePayload(); + NeedsRepair = false; + TryDelete(_path); + TryDelete(_path + ".tmp"); + TryDelete(_path + ".bak"); + } + Changed?.Invoke(); + } + + private void Load() + { + lock (_lock) + { + NeedsRepair = false; + var backupPath = _path + ".bak"; + if (TryLoad(_path, out var payload, out _)) + { + _payload = payload; + return; + } + + var primaryExists = File.Exists(_path); + if (TryLoad(backupPath, out payload, out var backupBytes)) + { + try + { + SyncStateStore.WriteDurable(_path, backupBytes); + _payload = payload; + return; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Could not restore remote cache backup: {ex.Message}"); + } + } + + _payload = new CachePayload(); + NeedsRepair = primaryExists || File.Exists(backupPath); + } + } + + private bool TryLoad(string path, out CachePayload payload, out byte[] fileBytes) + { + payload = new CachePayload(); + fileBytes = Array.Empty(); + if (!File.Exists(path)) return false; + try + { + fileBytes = File.ReadAllBytes(path); + var wrapper = JsonSerializer.Deserialize(fileBytes, _jsonOptions) + ?? throw new JsonException("Cache wrapper was empty."); + if (wrapper.Version != 1 || string.IsNullOrWhiteSpace(wrapper.ProtectedPayload)) + { + throw new JsonException("Unsupported cache wrapper."); + } + + var protectedBytes = Convert.FromBase64String(wrapper.ProtectedPayload); + var jsonBytes = ProtectedData.Unprotect(protectedBytes, Entropy, DataProtectionScope.CurrentUser); + payload = JsonSerializer.Deserialize(jsonBytes, _jsonOptions) + ?? throw new JsonException("Cache payload was empty."); + payload.Records ??= new Dictionary(StringComparer.Ordinal); + payload.Tombstones ??= new Dictionary(StringComparer.Ordinal); + return true; + } + catch (Exception ex) when ( + ex is IOException || ex is UnauthorizedAccessException || ex is JsonException || + ex is FormatException || ex is CryptographicException || ex is NotSupportedException) + { + System.Diagnostics.Debug.WriteLine($"Could not load remote cache {path}: {ex.Message}"); + payload = new CachePayload(); + fileBytes = Array.Empty(); + return false; + } + } + + private void SaveLocked() + { + var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(_payload, _jsonOptions); + var protectedBytes = ProtectedData.Protect(jsonBytes, Entropy, DataProtectionScope.CurrentUser); + var wrapper = new CacheFileWrapper + { + Version = 1, + ProtectedPayload = Convert.ToBase64String(protectedBytes) + }; + SyncStateStore.WriteDurable(_path, JsonSerializer.SerializeToUtf8Bytes(wrapper, _jsonOptions)); + } + + private static void ValidateIncoming(CachedRemoteRecord incoming) + { + if (incoming.Revision <= 0 || + string.IsNullOrWhiteSpace(incoming.RecordId) || + string.IsNullOrWhiteSpace(incoming.DeviceId) || + string.IsNullOrWhiteSpace(incoming.CiphertextHash) || + string.IsNullOrWhiteSpace(incoming.Plaintext.LocalDay)) + { + throw new ArgumentException("Remote record is incomplete.", nameof(incoming)); + } + } + + private static string CreateKey(string deviceId, string recordId) => deviceId + "\n" + recordId; + + private static CachedRemoteRecord DeepClone(CachedRemoteRecord value) + { + var source = value.Plaintext; + return new CachedRemoteRecord + { + DeviceId = value.DeviceId, + RecordId = value.RecordId, + Revision = value.Revision, + CiphertextHash = value.CiphertextHash, + IsCurrent = value.IsCurrent, + Plaintext = new CoreDaySnapshotV1 + { + SchemaVersion = source.SchemaVersion, + DeviceId = source.DeviceId, + LocalDay = source.LocalDay, + Revision = source.Revision, + KeyPresses = source.KeyPresses, + KeyPressCounts = new Dictionary(source.KeyPressCounts, StringComparer.Ordinal), + Clicks = new CoreClickSnapshotV1 + { + Left = source.Clicks.Left, + Right = source.Clicks.Right, + Middle = source.Clicks.Middle, + SideBack = source.Clicks.SideBack, + SideForward = source.Clicks.SideForward + } + } + }; + } + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) File.Delete(path); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Could not delete {path}: {ex.Message}"); + } + } + + private sealed class CachePayload + { + public Dictionary Records { get; set; } = new(StringComparer.Ordinal); + public Dictionary Tombstones { get; set; } = new(StringComparer.Ordinal); + } + + private sealed class CacheFileWrapper + { + public int Version { get; set; } = 1; + public string ProtectedPayload { get; set; } = string.Empty; + } +} + +public sealed class RemoteShardConflictException : Exception +{ + public RemoteShardConflictException() + : base("Conflicting encrypted sync records were received.") + { + } +} diff --git a/KeyStats.Windows/KeyStats/Services/StatsManager.cs b/KeyStats.Windows/KeyStats/Services/StatsManager.cs index ec5f575..36e4163 100644 --- a/KeyStats.Windows/KeyStats/Services/StatsManager.cs +++ b/KeyStats.Windows/KeyStats/Services/StatsManager.cs @@ -46,6 +46,9 @@ public enum StatsUpdateKind private bool _pendingMouseMoveUpdate; private bool _pendingSettingsSave; private volatile bool _isDisposed; + private DisplayStatsAggregator? _displayStatsAggregator; + private Func? _isSyncEnabledProvider; + private Func? _isImportBlockedProvider; // KPS/CPS peak tracking (1-second sliding window) private readonly Queue _recentKeyTimestamps = new(); @@ -60,6 +63,7 @@ public enum StatsUpdateKind public event Action? StatsUpdateRequested; public event Action? StatsChanged; + public event Action? LocalStatsReset; private StatsManager() { @@ -545,6 +549,22 @@ private void NotifyStatsUpdate(StatsUpdateKind kind = StatsUpdateKind.Full) } } + public void ConfigureSyncDisplay( + DisplayStatsAggregator aggregator, + Func isSyncEnabledProvider, + Func? isImportBlockedProvider = null) + { + _displayStatsAggregator = aggregator; + _isSyncEnabledProvider = isSyncEnabledProvider; + _isImportBlockedProvider = isImportBlockedProvider ?? isSyncEnabledProvider; + NotifyStatsUpdate(); + } + + public void NotifyRemoteStatsChanged() + { + NotifyStatsUpdate(); + } + #region Persistence private void SaveStats() @@ -776,6 +796,7 @@ public byte[] ExportStatsData() payload = new ExportPayload { Version = 1, + Scope = "currentDevice", ExportedAt = DateTime.UtcNow, CurrentStats = currentCopy, History = exportHistory @@ -798,6 +819,11 @@ public void ImportStatsData(byte[] data) public void ImportStatsData(byte[] data, ImportMode mode) { + if (_isImportBlockedProvider?.Invoke() == true) + { + throw new InvalidOperationException(KeyStats.Properties.Strings.Error_ImportDisabledWhileSyncing); + } + if (data == null || data.Length == 0) { throw new InvalidDataException(KeyStats.Properties.Strings.Error_ImportEmpty); @@ -827,6 +853,11 @@ public void ImportStatsData(byte[] data, ImportMode mode) { throw new InvalidDataException(KeyStats.Properties.Strings.Error_ImportUnsupportedVersion); } + if (!string.IsNullOrWhiteSpace(payload.Scope) && + !string.Equals(payload.Scope, "currentDevice", StringComparison.Ordinal)) + { + throw new InvalidDataException(KeyStats.Properties.Strings.Error_ImportInvalidFormat); + } lock (_lock) { @@ -1071,6 +1102,7 @@ private static double SanitizeDistance(double value) private sealed class ExportPayload { public int Version { get; set; } + public string? Scope { get; set; } public DateTime ExportedAt { get; set; } = DateTime.UtcNow; public DailyStats CurrentStats { get; set; } = new(); public Dictionary History { get; set; } = new(); @@ -1141,6 +1173,7 @@ private void ResetStats(DateTime date) UpdateNotificationBaselines(); NotifyStatsUpdate(); SaveStats(); + LocalStatsReset?.Invoke(); } private void SynchronizeCurrentDay(DateTime targetDate, bool notifyStatsUpdate) @@ -1274,6 +1307,17 @@ public string FormatNumber(int number) } } + public Dictionary GetLocalHistorySnapshot() + { + lock (_lock) + { + var snapshot = CloneHistorySnapshot(History); + snapshot[CurrentStats.Date.ToString("yyyy-MM-dd")] = + CloneDailyStats(CurrentStats, CurrentStats.Date.Date); + return snapshot; + } + } + public List GetAppStatsSorted(int limit = 5) { lock (_lock) @@ -1339,6 +1383,17 @@ public List GetAppStatsSummary(AppStatsRange range) } } + if (_displayStatsAggregator != null && _isSyncEnabledProvider?.Invoke() == true) + { + foreach (var candidate in _displayStatsAggregator.GetRemoteDays()) + { + if (candidate <= today && candidate < start) + { + start = candidate; + } + } + } + return (start, today); } } @@ -1399,13 +1454,20 @@ private List GetAppStatsDates(AppStatsRange range) private DailyStats GetDailyStats(DateTime date) { + DailyStats local; if (date.Date == CurrentStats.Date.Date) { - return CurrentStats; + local = CurrentStats; + } + else + { + var key = date.ToString("yyyy-MM-dd"); + local = History.TryGetValue(key, out var stats) ? stats : new DailyStats(date); } - var key = date.ToString("yyyy-MM-dd"); - return History.TryGetValue(key, out var stats) ? stats : new DailyStats(date); + return _displayStatsAggregator != null && _isSyncEnabledProvider?.Invoke() == true + ? _displayStatsAggregator.Aggregate(date.Date, local) + : local; } private static Dictionary AggregateKeyboardHeatmapCounts(Dictionary keyPressCounts) @@ -1682,8 +1744,7 @@ public enum KeyHistoryRange { Today, Week, Month, All } { return dates.Select(date => { - var key = date.ToString("yyyy-MM-dd"); - var stats = History.TryGetValue(key, out var s) ? s : new DailyStats(date); + var stats = GetDailyStats(date); return (date, GetMetricValue(metric, stats)); }).ToList(); } @@ -1758,6 +1819,10 @@ private List GetDatesInKeyHistoryRange(KeyHistoryRange range) .Where(x => x <= today) .ToHashSet(); dates.Add(today); + if (_displayStatsAggregator != null && _isSyncEnabledProvider?.Invoke() == true) + { + dates.UnionWith(_displayStatsAggregator.GetRemoteDays().Where(date => date <= today)); + } return dates.OrderBy(x => x).ToList(); } diff --git a/KeyStats.Windows/KeyStats/Services/SyncBatchPlanner.cs b/KeyStats.Windows/KeyStats/Services/SyncBatchPlanner.cs new file mode 100644 index 0000000..09480b7 --- /dev/null +++ b/KeyStats.Windows/KeyStats/Services/SyncBatchPlanner.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using KeyStats.Models; + +namespace KeyStats.Services; + +public static class SyncBatchPlanner +{ + public const int MaximumArchivesPerRequest = SyncProtocol.MaximumArchivesPerRequest; + + public static IReadOnlyList CreateBatches( + SyncRequest source, + EncryptedSyncRecord? lastAcknowledgedCurrentSnapshot = null) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + + var archives = source.Archives ?? new List(); + if (!IsSupportedReason(source.Reason)) + { + throw new ArgumentException("Unsupported sync reason.", nameof(source)); + } + + if (!IsBootstrapReason(source.Reason)) + { + var orderedArchives = PrioritizePreviousCurrentArchive( + source.CurrentSnapshot, + archives, + lastAcknowledgedCurrentSnapshot); + return new[] + { + CreateBatch( + source, + orderedArchives.Take(MaximumArchivesPerRequest), + bootstrapComplete: true, + includeFinalPayload: true) + }; + } + + if (archives.Count > SyncProtocol.MaximumBootstrapArchives) + { + throw new InvalidOperationException( + $"An initial sync can upload at most {SyncProtocol.MaximumBootstrapArchives} archives."); + } + + var batchCount = Math.Max( + 1, + (archives.Count + MaximumArchivesPerRequest - 1) / MaximumArchivesPerRequest); + var result = new List(batchCount); + for (var index = 0; index < batchCount; index++) + { + var isFinal = index == batchCount - 1; + result.Add(CreateBatch( + source, + archives.Skip(index * MaximumArchivesPerRequest).Take(MaximumArchivesPerRequest), + bootstrapComplete: isFinal, + includeFinalPayload: isFinal)); + } + return result; + } + + public static bool IsBootstrapReason(string? reason) + { + return string.Equals(reason, "bootstrap", StringComparison.Ordinal) || + string.Equals(reason, "recovery", StringComparison.Ordinal) || + string.Equals(reason, "pairing", StringComparison.Ordinal); + } + + private static bool IsSupportedReason(string? reason) + { + return IsBootstrapReason(reason) || + string.Equals(reason, "manual", StringComparison.Ordinal) || + string.Equals(reason, "automatic", StringComparison.Ordinal); + } + + private static IEnumerable PrioritizePreviousCurrentArchive( + EncryptedSyncRecord? nextCurrent, + IEnumerable archives, + EncryptedSyncRecord? previousCurrent) + { + if (nextCurrent == null || previousCurrent == null || + string.Equals(nextCurrent.RecordId, previousCurrent.RecordId, StringComparison.Ordinal)) + { + return archives; + } + + // The server must archive the exact previously acknowledged current + // envelope before changing the current record ID. A newer local + // revision for that day remains pending for the next ordinary sync. + return new[] { previousCurrent }.Concat( + archives.Where(record => + !string.Equals(record.RecordId, previousCurrent.RecordId, StringComparison.Ordinal))); + } + + private static SyncRequest CreateBatch( + SyncRequest source, + IEnumerable archives, + bool bootstrapComplete, + bool includeFinalPayload) + { + return new SyncRequest + { + Reason = source.Reason, + HistoryCursor = source.HistoryCursor, + CurrentSnapshot = includeFinalPayload ? source.CurrentSnapshot : null, + Archives = archives.ToList(), + EncryptedDeviceProfile = includeFinalPayload ? source.EncryptedDeviceProfile : null, + BootstrapComplete = bootstrapComplete + }; + } +} diff --git a/KeyStats.Windows/KeyStats/Services/SyncCoordinator.cs b/KeyStats.Windows/KeyStats/Services/SyncCoordinator.cs new file mode 100644 index 0000000..624e7e7 --- /dev/null +++ b/KeyStats.Windows/KeyStats/Services/SyncCoordinator.cs @@ -0,0 +1,3004 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using KeyStats.Models; + +namespace KeyStats.Services; + +public sealed class SyncCoordinator : IDisposable +{ + private static readonly TimeSpan[] PairingRefreshRetryDelays = + { + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(20), + TimeSpan.FromSeconds(40), + TimeSpan.FromSeconds(60) + }; + private static readonly TimeSpan StateRefreshInterval = TimeSpan.FromHours(24); + + private readonly StatsManager _statsManager; + private readonly SyncStateStore _stateStore; + private readonly SyncCredentialStore _credentialStore; + private readonly SyncPendingSecretsStore _pendingSecretsStore; + private readonly RemoteShardCache _remoteCache; + private readonly DisplayStatsAggregator _displayAggregator; + private readonly SyncCrypto _crypto; + private readonly ISyncTransport? _transport; + private readonly bool _ownsTransport; + private readonly string _clientVersion; + private readonly SemaphoreSlim _operationGate = new(1, 1); + private readonly object _stateLock = new(); + private readonly CancellationTokenSource _lifetimeCancellation = new(); + private readonly JsonSerializerOptions _wireJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + private Timer? _automaticTimer; + private Timer? _stateRefreshTimer; + private SyncState _state; + private bool _isBusy; + private string? _lastError; + private bool _disposed; + + public event Action? StatusChanged; + + public bool IsEnabled + { + get { lock (_stateLock) return _state.IsEnabled && !_state.NeedsRepair; } + } + + public bool BlocksImport + { + get + { + lock (_stateLock) + { + return _state.IsEnabled || _state.NeedsRepair || + !string.IsNullOrWhiteSpace(_state.PendingProvisioningKind); + } + } + } + + public bool IsServiceConfigured => _transport != null; + + public SyncCoordinator( + StatsManager statsManager, + string dataFolder, + Uri? serviceBaseUri, + string clientVersion, + ISyncTransport? transport = null) + { + _statsManager = statsManager; + _stateStore = new SyncStateStore(dataFolder); + _credentialStore = new SyncCredentialStore(dataFolder); + _pendingSecretsStore = new SyncPendingSecretsStore(dataFolder); + _remoteCache = new RemoteShardCache(dataFolder); + _crypto = new SyncCrypto(); + _clientVersion = clientVersion; + _state = _stateStore.Load(); + if (string.IsNullOrWhiteSpace(_state.InstallationDeviceId)) + { + _state.InstallationDeviceId = Guid.TryParse(_state.DeviceId, out _) + ? _state.DeviceId + : Guid.NewGuid().ToString("D"); + if (!_stateStore.NeedsRepair) _stateStore.Save(_state); + } + _transport = transport ?? (serviceBaseUri != null ? new CloudflareSyncTransport(serviceBaseUri) : null); + _ownsTransport = transport == null && _transport != null; + _displayAggregator = new DisplayStatsAggregator(_remoteCache, () => + { + lock (_stateLock) return _state.DeviceId; + }); + + var credentialsNeedRepair = false; + var hasPendingProvisioning = !string.IsNullOrWhiteSpace(_state.PendingProvisioningKind); + if (string.Equals(_state.PendingProvisioningKind, "pairing", StringComparison.Ordinal) && + _pendingSecretsStore.Exists) + { + try + { + var pending = _pendingSecretsStore.Load(); + if (string.Equals(pending.Kind, "pairing-final", StringComparison.Ordinal) && + string.Equals(pending.DeviceId, _state.InstallationDeviceId, StringComparison.Ordinal) && + _state.PendingEncryptedDeviceProfile != null) + { + // The final secret bundle is written before the non-secret state marker. + // Reconcile a crash in that narrow window so startup can replay the exact + // completion request without asking the user to pair again. + _state.PendingProvisioningKind = "pairing-final"; + _stateStore.Save(_state); + } + } + catch (CryptographicException) + { + credentialsNeedRepair = true; + } + } + hasPendingProvisioning = !string.IsNullOrWhiteSpace(_state.PendingProvisioningKind); + if (_state.IsEnabled) + { + if (!_credentialStore.Exists) + { + credentialsNeedRepair = !string.Equals( + _state.PendingProvisioningKind, + "recover", + StringComparison.Ordinal); + } + else + { + try { _credentialStore.Load(_state.VaultId, _state.DeviceId); } + catch (CryptographicException) { credentialsNeedRepair = true; } + } + } + else if (_credentialStore.Exists && !hasPendingProvisioning) + { + credentialsNeedRepair = true; + } + + if (hasPendingProvisioning && !_pendingSecretsStore.Exists) + { + credentialsNeedRepair = true; + } + else if (!hasPendingProvisioning && _pendingSecretsStore.Exists) + { + var stalePendingRemoved = false; + if (_state.IsEnabled && _credentialStore.Exists) + { + try + { + var pending = _pendingSecretsStore.Load(); + var active = _credentialStore.Load(_state.VaultId, _state.DeviceId); + stalePendingRemoved = + string.Equals(pending.VaultId, active.VaultId, StringComparison.Ordinal) && + string.Equals(pending.DeviceId, active.DeviceId, StringComparison.Ordinal) && + string.Equals(pending.DeviceToken, active.DeviceToken, StringComparison.Ordinal); + if (stalePendingRemoved) _pendingSecretsStore.Delete(); + } + catch (CryptographicException) + { + } + } + if (!stalePendingRemoved) credentialsNeedRepair = true; + } + + if (_stateStore.NeedsRepair || _state.NeedsRepair || credentialsNeedRepair) + { + _state.NeedsRepair = true; + _lastError = "The local sync configuration needs repair."; + if (credentialsNeedRepair && !_stateStore.NeedsRepair) + { + _stateStore.Save(_state); + } + } + + if (_remoteCache.NeedsRepair && _state.IsEnabled && !_state.NeedsRepair) + { + _state.NeedsRepair = true; + _state.NeedsBootstrap = false; + _state.NeedsHistoryBootstrap = false; + _lastError = "The local sync cache is damaged. Recover or pair this device again."; + _stateStore.Save(_state); + } + + _remoteCache.Changed += OnRemoteCacheChanged; + _statsManager.LocalStatsReset += OnLocalStatsReset; + _statsManager.ConfigureSyncDisplay(_displayAggregator, () => IsEnabled, () => BlocksImport); + } + + public void Start() + { + var retryBootstrap = false; + var retryProvisioning = false; + var retryDeletion = false; + lock (_stateLock) + { + if (_disposed || _transport == null) return; + retryDeletion = _state.PendingVaultDeletion; + retryProvisioning = IsRetryableProvisioningKind(_state.PendingProvisioningKind); + if (!retryDeletion && !retryProvisioning && + (!_state.IsEnabled || _state.NeedsRepair)) + { + return; + } + if (!retryDeletion && !retryProvisioning) + { + retryBootstrap = _state.NeedsBootstrap || _state.NeedsHistoryBootstrap || + _state.NeedsStateRefreshBeforeBootstrap; + if (!retryBootstrap) + { + if (_state.ActiveDeviceCount < 2) + { + ScheduleStateRefreshLocked(DateTime.UtcNow); + return; + } + EnsureAutomaticAttemptLocked(DateTime.UtcNow); + ScheduleAutomaticTimerLocked(DateTime.UtcNow); + } + } + } + RaiseStatusChanged(); + if (retryDeletion) RunVaultDeletionInBackground(); + else if (retryProvisioning) RunPendingProvisioningInBackground(); + else if (retryBootstrap) RunBootstrapInBackground(); + } + + public void HandleAppResume() + { + var shouldRun = false; + var shouldRefreshState = false; + lock (_stateLock) + { + if (_disposed || _transport == null || !_state.IsEnabled || _state.NeedsRepair || + _state.NeedsBootstrap || _state.NeedsHistoryBootstrap || + _state.NeedsStateRefreshBeforeBootstrap || _state.PendingVaultDeletion) return; + if (_state.ActiveDeviceCount < 2) + { + var refreshBase = _state.LastStateRefreshAtUtc ?? + _state.LastSuccessfulSyncAtUtc ?? + DateTime.UtcNow; + var dueAt = refreshBase + StateRefreshInterval; + shouldRefreshState = dueAt <= DateTime.UtcNow; + if (!shouldRefreshState) ScheduleStateRefreshLocked(DateTime.UtcNow); + } + else + { + EnsureAutomaticAttemptLocked(DateTime.UtcNow); + shouldRun = _state.NextAutomaticSyncAtUtc.HasValue && + _state.NextAutomaticSyncAtUtc.Value <= DateTime.UtcNow; + if (!shouldRun) ScheduleAutomaticTimerLocked(DateTime.UtcNow); + } + } + + if (shouldRefreshState) RunStateRefreshInBackground(); + else if (shouldRun) RunAutomaticSyncInBackground(); + } + + private async Task RefreshStateAsync( + bool rebuildOwnRevisionState, + CancellationToken cancellationToken = default) + { + var transport = RequireTransport(); + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + SetBusy(true); + try + { + lock (_stateLock) EnsureEnabledLocked(); + var credentials = RequireCredentials(); + var response = await transport.GetStateAsync(credentials.DeviceToken, cancellationToken) + .ConfigureAwait(false); + if (response.ServerTime == default || response.ActiveDeviceCount < 1 || + response.ActiveDeviceCount > SyncProtocol.MaximumDevices || + response.Devices == null || response.CurrentSnapshots == null) + { + throw new InvalidDataException("Sync service returned invalid device state."); + } + var dataKey = _crypto.DeriveDataKey(credentials.VaultSeed); + var indexKey = _crypto.DeriveIndexKey(credentials.VaultSeed); + foreach (var record in response.CurrentSnapshots) + { + var decrypted = DecryptAndValidate(record, isCurrent: true, dataKey, indexKey); + if (IsLocalDevice(record.DeviceId)) + { + if (rebuildOwnRevisionState) + { + lock (_stateLock) RestoreOwnDecryptedRecordLocked(record, decrypted, isCurrent: true); + } + } + else + { + _remoteCache.Apply(decrypted); + } + } + RefreshDevices(response.Devices, credentials); + lock (_stateLock) + { + _state.ActiveDeviceCount = response.ActiveDeviceCount; + _state.LastStateRefreshAtUtc = NormalizeUtc(response.ServerTime); + _lastError = null; + if (_state.NeedsStateRefreshBeforeBootstrap || _state.NeedsHistoryBootstrap || + _state.NeedsBootstrap) + { + _automaticTimer?.Change(Timeout.Infinite, Timeout.Infinite); + _stateRefreshTimer?.Change(Timeout.Infinite, Timeout.Infinite); + } + else if (_state.ActiveDeviceCount < 2) + { + _state.NextAutomaticSyncAtUtc = null; + _automaticTimer?.Change(Timeout.Infinite, Timeout.Infinite); + ScheduleStateRefreshLocked(DateTime.UtcNow); + } + else + { + _stateRefreshTimer?.Change(Timeout.Infinite, Timeout.Infinite); + EnsureAutomaticAttemptLocked(DateTime.UtcNow); + ScheduleAutomaticTimerLocked(DateTime.UtcNow); + } + _stateStore.Save(_state); + } + RaiseStatusChanged(); + } + catch (Exception ex) + { + lock (_stateLock) + { + _lastError = SafeErrorMessage(ex); + _state.LastStateRefreshAtUtc = DateTime.UtcNow; + if (_state.ActiveDeviceCount < 2) ScheduleStateRefreshLocked(DateTime.UtcNow); + _stateStore.Save(_state); + } + RaiseStatusChanged(); + throw; + } + finally + { + SetBusy(false); + _operationGate.Release(); + } + } + + public SyncStatusSnapshot GetStatus() + { + lock (_stateLock) + { + var now = DateTime.UtcNow; + RefreshUtcDayWindowsLocked(now); + var allowedAt = GetEffectiveAllowedAtLocked(); + var hasProvisioningWork = IsRetryableProvisioningKind(_state.PendingProvisioningKind); + var hasPendingDeletion = _state.PendingVaultDeletion; + var hasBootstrapWork = _state.NeedsBootstrap || _state.NeedsHistoryBootstrap || + _state.NeedsStateRefreshBeforeBootstrap || hasProvisioningWork || + hasPendingDeletion; + var canSync = _state.IsEnabled && !_state.NeedsRepair && !hasBootstrapWork && + _state.ActiveDeviceCount >= 2; + var canRetryBootstrap = IsServiceConfigured && !_isBusy && + (hasProvisioningWork || hasPendingDeletion || + (_state.IsEnabled && !_state.NeedsRepair && + (_state.NeedsBootstrap || _state.NeedsHistoryBootstrap || + _state.NeedsStateRefreshBeforeBootstrap))); + return new SyncStatusSnapshot + { + IsServiceConfigured = IsServiceConfigured, + IsEnabled = _state.IsEnabled, + NeedsRepair = _state.NeedsRepair, + NeedsBootstrap = hasBootstrapWork, + BlocksImport = _state.IsEnabled || _state.NeedsRepair || + !string.IsNullOrWhiteSpace(_state.PendingProvisioningKind), + IsBusy = _isBusy, + CanSync = canSync, + CanManualSync = IsServiceConfigured && canSync && !_isBusy && + _state.RemainingDailySyncs > 0 && now >= allowedAt, + CanRetryBootstrap = canRetryBootstrap, + ActiveDeviceCount = _state.ActiveDeviceCount, + LastSuccessfulSyncAtUtc = _state.LastSuccessfulSyncAtUtc, + NextAllowedSyncAtUtc = allowedAt, + RemainingDailySyncs = _state.RemainingDailySyncs, + LastError = _lastError + }; + } + } + + public string GetRecoveryCode() + { + lock (_stateLock) + { + if (!_state.IsEnabled && !_state.NeedsRepair) + { + throw new InvalidOperationException("Sync is not configured on this device."); + } + } + return _crypto.EncodeRecoveryCode(RequireCredentials().VaultSeed); + } + + public string GetCurrentDeviceName() + { + lock (_stateLock) return _state.DeviceName; + } + + public IReadOnlyList GetDevices() + { + lock (_stateLock) + { + return _state.Devices.Select(CloneDevice).ToList(); + } + } + + public async Task CreateVaultAsync(string displayName, CancellationToken cancellationToken = default) + { + var transport = RequireTransport(); + string recoveryCode; + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + SetBusy(true); + try + { + PendingSyncSecrets pending; + PairingEncryptedPayload profile; + string effectiveDisplayName; + lock (_stateLock) + { + if (!string.IsNullOrWhiteSpace(_state.PendingProvisioningKind) && + !string.Equals(_state.PendingProvisioningKind, "create", StringComparison.Ordinal)) + { + throw new InvalidOperationException("Another sync setup operation is already pending."); + } + } + if (_pendingSecretsStore.Exists) + { + pending = _pendingSecretsStore.Load(); + lock (_stateLock) + { + if (!string.Equals(_state.PendingProvisioningKind, "create", StringComparison.Ordinal) || + !string.Equals(pending.Kind, "create", StringComparison.Ordinal) || + _state.PendingEncryptedDeviceProfile == null) + { + throw new CryptographicException("Pending vault creation state is inconsistent."); + } + profile = _state.PendingEncryptedDeviceProfile; + effectiveDisplayName = _state.PendingProvisioningDisplayName ?? displayName; + } + } + else + { + EnsureCanCreate(); + var seed = _crypto.GenerateVaultSeed(); + var vaultId = Guid.NewGuid().ToString("D"); + string deviceId; + lock (_stateLock) deviceId = _state.InstallationDeviceId; + var deviceToken = deviceId + "." + _crypto.GenerateTokenSecret(); + profile = CreateEncryptedDeviceProfile(seed, vaultId, deviceId, displayName); + pending = new PendingSyncSecrets + { + Kind = "create", + VaultId = vaultId, + DeviceId = deviceId, + VaultSeed = seed, + DeviceToken = deviceToken + }; + _pendingSecretsStore.Save(pending); + lock (_stateLock) + { + _state.PendingProvisioningKind = "create"; + _state.PendingProvisioningDisplayName = NormalizeProfileValue(displayName, 128); + _state.PendingEncryptedDeviceProfile = profile; + _stateStore.Save(_state); + effectiveDisplayName = _state.PendingProvisioningDisplayName; + } + } + + var response = await transport.CreateVaultAsync(new CreateVaultRequest + { + VaultId = pending.VaultId, + DeviceId = pending.DeviceId, + DeviceToken = pending.DeviceToken, + RecoveryAuthToken = SyncCrypto.Base64UrlEncode(_crypto.DeriveRecoveryAuth(pending.VaultSeed)), + EncryptedDeviceProfile = profile + }, cancellationToken).ConfigureAwait(false); + + if (!string.Equals(response.VaultId, pending.VaultId, StringComparison.Ordinal) || + !string.Equals(response.DeviceId, pending.DeviceId, StringComparison.Ordinal) || + !string.Equals(response.DeviceToken, pending.DeviceToken, StringComparison.Ordinal) || + response.ActiveDeviceCount < 1 || response.ActiveDeviceCount > SyncProtocol.MaximumDevices) + { + throw new InvalidDataException("Sync service returned inconsistent vault credentials."); + } + + _credentialStore.Save(new SyncCredentials + { + VaultId = pending.VaultId, + DeviceId = pending.DeviceId, + VaultSeed = pending.VaultSeed, + DeviceToken = pending.DeviceToken + }); + lock (_stateLock) + { + _state = NewEnabledState( + pending.VaultId, + pending.DeviceId, + effectiveDisplayName, + response.ActiveDeviceCount); + _lastError = null; + _stateStore.Save(_state); + } + _pendingSecretsStore.Delete(); + _remoteCache.Clear(); + RaiseStatusChanged(); + recoveryCode = _crypto.EncodeRecoveryCode(pending.VaultSeed); + } + finally + { + SetBusy(false); + _operationGate.Release(); + } + + try + { + await SyncCoreAsync("bootstrap", bypassClientRateLimit: true, cancellationToken).ConfigureAwait(false); + } + catch (Exception) + { + // The vault and its recovery credentials are already durable. Keep the + // pending bootstrap state so startup or the explicit retry action can + // resend the exact same encrypted envelopes. + } + return recoveryCode; + } + + public async Task RecoverVaultAsync( + string recoveryCode, + string displayName, + CancellationToken cancellationToken = default) + { + var transport = RequireTransport(); + if (!_crypto.TryDecodeRecoveryCode(recoveryCode, out var seed)) + { + throw new ArgumentException("Recovery code is invalid.", nameof(recoveryCode)); + } + + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + SetBusy(true); + try + { + PrepareDamagedStateForSetup(); + PendingSyncSecrets pending; + string? replaceDeviceId; + string effectiveDisplayName; + lock (_stateLock) + { + if (!string.IsNullOrWhiteSpace(_state.PendingProvisioningKind) && + !string.Equals(_state.PendingProvisioningKind, "recover", StringComparison.Ordinal)) + { + throw new InvalidOperationException("Another sync setup operation is already pending."); + } + } + if (_pendingSecretsStore.Exists) + { + pending = _pendingSecretsStore.Load(); + lock (_stateLock) + { + if (!string.Equals(pending.Kind, "recover", StringComparison.Ordinal) || + !string.Equals(_state.PendingProvisioningKind, "recover", StringComparison.Ordinal)) + { + throw new CryptographicException("Pending recovery state is inconsistent."); + } + replaceDeviceId = pending.ReplaceDeviceId; + effectiveDisplayName = _state.PendingProvisioningDisplayName ?? displayName; + } + if (!pending.VaultSeed.SequenceEqual(seed)) + { + throw new CryptographicException("The recovery code does not match the pending recovery."); + } + } + else + { + EnsureNotEnabled(); + string deviceId; + string previousVaultId; + lock (_stateLock) + { + var replacementCandidate = Guid.TryParse(_state.ReplacementCandidateDeviceId, out _) + ? _state.ReplacementCandidateDeviceId + : null; + deviceId = replacementCandidate ?? _state.InstallationDeviceId; + previousVaultId = _state.VaultId; + replaceDeviceId = _state.IsEnabled && !string.IsNullOrWhiteSpace(_state.DeviceId) + ? _state.DeviceId + : replacementCandidate; + } + pending = new PendingSyncSecrets + { + Kind = "recover", + VaultId = previousVaultId, + DeviceId = deviceId, + VaultSeed = seed, + DeviceToken = deviceId + "." + _crypto.GenerateTokenSecret(), + ReplaceDeviceId = replaceDeviceId + }; + _pendingSecretsStore.Save(pending); + lock (_stateLock) + { + _state.PendingProvisioningKind = "recover"; + _state.PendingProvisioningDisplayName = NormalizeProfileValue(displayName, 128); + _state.PendingProvisioningReplaceDeviceId = replaceDeviceId; + _stateStore.Save(_state); + effectiveDisplayName = _state.PendingProvisioningDisplayName; + } + } + + var recoveryAuthToken = SyncCrypto.Base64UrlEncode( + _crypto.DeriveRecoveryAuth(pending.VaultSeed)); + RecoverVaultResponse response; + try + { + response = await transport.RecoverVaultAsync( + CreateRecoverRequest(pending, replaceDeviceId, recoveryAuthToken), + cancellationToken).ConfigureAwait(false); + } + catch (SyncTransportException ex) when ( + replaceDeviceId != null && + ex.StatusCode == HttpStatusCode.Conflict && + string.Equals(ex.ErrorCode, "replace_device_not_found", StringComparison.Ordinal)) + { + // A repair candidate can belong to an old or deleted vault. Only + // this explicit response is safe to downgrade to a fresh shard; + // generic conflicts may mean a concurrent recovery and must not + // create a duplicate device. + var freshDeviceId = Guid.NewGuid().ToString("D"); + pending.VaultId = string.Empty; + pending.DeviceId = freshDeviceId; + pending.DeviceToken = freshDeviceId + "." + _crypto.GenerateTokenSecret(); + pending.ReplaceDeviceId = null; + _pendingSecretsStore.Save(pending); + replaceDeviceId = null; + lock (_stateLock) + { + _state.InstallationDeviceId = freshDeviceId; + _state.ReplacementCandidateDeviceId = null; + _state.DeviceId = freshDeviceId; + _state.PendingProvisioningReplaceDeviceId = null; + _state.HistoryCursor = 0; + _state.LocalRecords.Clear(); + _state.LastAcknowledgedCurrentSnapshot = null; + _state.PendingEncryptedDeviceProfile = null; + _stateStore.Save(_state); + } + response = await transport.RecoverVaultAsync( + CreateRecoverRequest(pending, replaceDeviceId, recoveryAuthToken), + cancellationToken).ConfigureAwait(false); + } + + if (string.IsNullOrWhiteSpace(response.VaultId) || + !string.Equals(response.DeviceId, pending.DeviceId, StringComparison.Ordinal) || + !string.Equals(response.DeviceToken, pending.DeviceToken, StringComparison.Ordinal) || + response.ActiveDeviceCount < 1 || response.ActiveDeviceCount > SyncProtocol.MaximumDevices || + response.Cursor != 0) + { + throw new InvalidDataException("Sync service returned incomplete recovery credentials."); + } + + pending.VaultId = response.VaultId; + _pendingSecretsStore.Save(pending); + var encryptedProfile = CreateEncryptedDeviceProfile( + pending.VaultSeed, + response.VaultId, + pending.DeviceId, + effectiveDisplayName); + _credentialStore.Save(new SyncCredentials + { + VaultId = response.VaultId, + DeviceId = pending.DeviceId, + VaultSeed = pending.VaultSeed, + DeviceToken = pending.DeviceToken + }); + lock (_stateLock) + { + var canPreserveRevisionState = + string.Equals(_state.VaultId, response.VaultId, StringComparison.Ordinal) && + string.Equals(_state.DeviceId, pending.DeviceId, StringComparison.Ordinal); + var existingLocalRecords = canPreserveRevisionState + ? _state.LocalRecords + : new Dictionary(StringComparer.Ordinal); + var existingCurrent = canPreserveRevisionState + ? _state.LastAcknowledgedCurrentSnapshot + : null; + _state = NewEnabledState( + response.VaultId, + pending.DeviceId, + effectiveDisplayName, + response.ActiveDeviceCount); + _state.LocalRecords = existingLocalRecords; + _state.LastAcknowledgedCurrentSnapshot = existingCurrent; + _state.HistoryCursor = 0; + _state.PendingEncryptedDeviceProfile = encryptedProfile; + _state.NeedsBootstrap = false; + _state.NeedsHistoryBootstrap = true; + _state.PendingBootstrapReason = "recovery"; + if (response.CurrentSnapshot != null) + { + RestoreOwnRecordLocked( + response.CurrentSnapshot, + isCurrent: true, + pending.VaultSeed); + } + _stateStore.Save(_state); + } + _pendingSecretsStore.Delete(); + _remoteCache.ResetForBootstrap(); + } + finally + { + SetBusy(false); + _operationGate.Release(); + } + + await ContinueHistoryBootstrapAsync(cancellationToken).ConfigureAwait(false); + } + + public IReadOnlyList GetRecoveryReplacementOptions( + SyncTransportException exception) + { + if (exception.StatusCode != HttpStatusCode.Conflict || + !string.Equals(exception.ErrorCode, "maximum_devices", StringComparison.Ordinal) || + !Guid.TryParse(exception.VaultId, out _) || + exception.Devices.Count != SyncProtocol.MaximumDevices) + { + throw new InvalidDataException("Sync service did not return a valid recovery device list."); + } + lock (_stateLock) + { + if (!string.Equals(_state.PendingProvisioningKind, "recover", StringComparison.Ordinal)) + { + throw new InvalidOperationException("There is no pending recovery to continue."); + } + } + var pending = _pendingSecretsStore.Load(); + if (!string.Equals(pending.Kind, "recover", StringComparison.Ordinal)) + { + throw new CryptographicException("Pending recovery credentials are invalid."); + } + + var dataKey = _crypto.DeriveDataKey(pending.VaultSeed); + var seen = new HashSet(StringComparer.Ordinal); + var options = new List(); + foreach (var device in exception.Devices) + { + if (!Guid.TryParse(device.DeviceId, out _) || !seen.Add(device.DeviceId)) + { + throw new InvalidDataException("Sync service returned an invalid recovery device list."); + } + DeviceProfileV1? profile = null; + if (device.EncryptedDeviceProfile != null) + { + var profileBytes = _crypto.DecryptDeviceProfile( + dataKey, + exception.VaultId!, + device.DeviceId, + device.EncryptedDeviceProfile); + profile = JsonSerializer.Deserialize(profileBytes, _wireJsonOptions) + ?? throw new CryptographicException("Encrypted device profile is empty."); + if (profile.SchemaVersion != SyncProtocol.SchemaVersion || + Encoding.UTF8.GetByteCount(profile.DisplayName ?? string.Empty) > 128 || + Encoding.UTF8.GetByteCount(profile.Platform ?? string.Empty) > 128) + { + throw new CryptographicException("Encrypted device profile is invalid."); + } + } + options.Add(new RecoveryReplacementOption + { + DeviceId = device.DeviceId, + DisplayName = NonEmptyProfileValue( + profile?.DisplayName, + null, + "Device " + device.DeviceId.Substring(0, Math.Min(6, device.DeviceId.Length))), + Platform = NonEmptyProfileValue(profile?.Platform, null, string.Empty) + }); + } + return options; + } + + public async Task RetryRecoveryReplacingAsync( + RecoveryReplacementOption option, + string vaultId, + CancellationToken cancellationToken = default) + { + if (option == null) throw new ArgumentNullException(nameof(option)); + if (!Guid.TryParse(vaultId, out _) || !Guid.TryParse(option.DeviceId, out _)) + { + throw new ArgumentException("Recovery replacement metadata is invalid."); + } + PendingSyncSecrets pending; + string displayName; + lock (_stateLock) + { + if (!string.Equals(_state.PendingProvisioningKind, "recover", StringComparison.Ordinal)) + { + throw new InvalidOperationException("There is no pending recovery to continue."); + } + displayName = _state.PendingProvisioningDisplayName ?? "Windows device"; + } + pending = _pendingSecretsStore.Load(); + if (!string.Equals(pending.Kind, "recover", StringComparison.Ordinal)) + { + throw new CryptographicException("Pending recovery credentials are invalid."); + } + + pending.VaultId = vaultId; + pending.DeviceId = option.DeviceId; + pending.DeviceToken = option.DeviceId + "." + _crypto.GenerateTokenSecret(); + pending.ReplaceDeviceId = option.DeviceId; + _pendingSecretsStore.Save(pending); + lock (_stateLock) + { + _state.InstallationDeviceId = option.DeviceId; + _state.DeviceId = option.DeviceId; + _state.VaultId = vaultId; + _state.PendingProvisioningReplaceDeviceId = option.DeviceId; + _state.LocalRecords.Clear(); + _state.LastAcknowledgedCurrentSnapshot = null; + _stateStore.Save(_state); + } + await RecoverVaultAsync( + _crypto.EncodeRecoveryCode(pending.VaultSeed), + displayName, + cancellationToken).ConfigureAwait(false); + } + + public async Task BeginPairingAsync( + string displayName, + CancellationToken cancellationToken = default) + { + var transport = RequireTransport(); + lock (_stateLock) + { + if (string.Equals(_state.PendingProvisioningKind, "pairing", StringComparison.Ordinal) && + _pendingSecretsStore.Exists) + { + return RestorePendingPairingContext(_pendingSecretsStore.Load()); + } + if (!string.IsNullOrWhiteSpace(_state.PendingProvisioningKind)) + { + throw new InvalidOperationException("Another sync setup operation is already pending."); + } + } + EnsureNotEnabled(); + string installationDeviceId; + lock (_stateLock) installationDeviceId = _state.InstallationDeviceId; + var context = _crypto.CreatePairingContext(installationDeviceId); + var response = await transport.CreatePairingSessionAsync(new CreatePairingSessionRequest + { + DeviceId = context.ProposedDeviceId, + JoiningPublicKey = Convert.ToBase64String(context.PublicKey) + }, cancellationToken).ConfigureAwait(false); + context.SessionId = response.SessionId; + context.Code = response.Code; + context.CompletionToken = response.CompletionToken; + context.ExpiresAt = response.ExpiresAt; + PrepareDamagedStateForSetup(); + _pendingSecretsStore.Save(new PendingSyncSecrets + { + Kind = "pairing", + DeviceId = context.ProposedDeviceId, + PairingPrivateKey = context.PrivateKey, + PairingPublicKey = context.PublicKey, + PairingSessionId = context.SessionId, + PairingCode = context.Code, + PairingCompletionToken = context.CompletionToken, + PairingExpiresAt = context.ExpiresAt + }); + lock (_stateLock) + { + _state.PendingProvisioningKind = "pairing"; + _state.PendingProvisioningDisplayName = NormalizeProfileValue(displayName, 128); + _stateStore.Save(_state); + } + RaiseStatusChanged(); + return context; + } + + public PairingSessionContext? GetPendingPairingContext() + { + lock (_stateLock) + { + if (!string.Equals(_state.PendingProvisioningKind, "pairing", StringComparison.Ordinal) || + !_pendingSecretsStore.Exists) + { + return null; + } + } + return RestorePendingPairingContext(_pendingSecretsStore.Load()); + } + + private static PairingSessionContext RestorePendingPairingContext(PendingSyncSecrets pending) + { + if (!string.Equals(pending.Kind, "pairing", StringComparison.Ordinal) || + !pending.PairingExpiresAt.HasValue) + { + throw new CryptographicException("Pending pairing state is invalid."); + } + return new PairingSessionContext + { + PrivateKey = pending.PairingPrivateKey, + PublicKey = pending.PairingPublicKey, + SessionId = pending.PairingSessionId, + Code = pending.PairingCode, + CompletionToken = pending.PairingCompletionToken, + ProposedDeviceId = pending.DeviceId, + ExpiresAt = pending.PairingExpiresAt.Value + }; + } + + public async Task JoinPairingAsync( + string code, + CancellationToken cancellationToken = default) + { + var transport = RequireTransport(); + var credentials = RequireCredentials(); + EnsureEnabled(); + var localKeys = _crypto.CreatePairingContext(string.Empty); + var normalizedCode = new string((code ?? string.Empty).Where(char.IsDigit).ToArray()); + if (normalizedCode.Length != 6) throw new ArgumentException("Pairing code must contain six digits.", nameof(code)); + + var response = await transport.JoinPairingSessionAsync( + normalizedCode, + new JoinPairingSessionRequest { ApprovingPublicKey = Convert.ToBase64String(localKeys.PublicKey) }, + credentials.DeviceToken, + cancellationToken).ConfigureAwait(false); + var expiresAt = NormalizeUtc(response.ExpiresAt); + if (!expiresAt.HasValue || expiresAt.Value <= DateTime.UtcNow) + { + throw new InvalidDataException("Pairing session expiration is invalid."); + } + var peerKey = Convert.FromBase64String(response.JoiningPublicKey); + return new PairingApprovalContext + { + PrivateKey = localKeys.PrivateKey, + PublicKey = localKeys.PublicKey, + PeerPublicKey = peerKey, + SessionId = response.SessionId, + NewDeviceId = response.JoiningDeviceId, + SafetyCode = _crypto.CreatePairingSafetyCode(localKeys.PublicKey, peerKey, localKeys.PrivateKey), + ExpiresAt = expiresAt.Value + }; + } + + public async Task ApprovePairingAsync( + PairingApprovalContext context, + CancellationToken cancellationToken = default) + { + var transport = RequireTransport(); + var credentials = RequireCredentials(); + string vaultId; + lock (_stateLock) vaultId = _state.VaultId; + + var newDeviceToken = context.NewDeviceId + "." + _crypto.GenerateTokenSecret(); + var grant = new PairingProvisioningPayload + { + VaultId = vaultId, + DeviceToken = newDeviceToken, + RecoverySeed = Convert.ToBase64String(credentials.VaultSeed) + }; + var wrapKey = _crypto.DerivePairingWrapKey(context.PrivateKey, context.PeerPublicKey); + var encryptedGrant = _crypto.EncryptPairingPayload( + wrapKey, + context.SessionId, + SerializeCanonical(grant)); + await transport.ApprovePairingSessionAsync( + context.SessionId, + new ApprovePairingSessionRequest + { + ApprovingPublicKey = Convert.ToBase64String(context.PublicKey), + EncryptedGrant = encryptedGrant, + NewDeviceToken = newDeviceToken + }, + credentials.DeviceToken, + cancellationToken).ConfigureAwait(false); + RunPairingRefreshInBackground(context.ExpiresAt); + } + + public async Task PreviewPairingCompletionAsync( + PairingSessionContext context, + CancellationToken cancellationToken = default) + { + var transport = RequireTransport(); + var response = await transport.CompletePairingSessionAsync( + context.SessionId, + new CompletePairingSessionRequest { CompletionToken = context.CompletionToken }, + cancellationToken).ConfigureAwait(false); + if (response.Pending) + { + throw new InvalidOperationException("Pairing approval is still pending."); + } + if (!response.RequiresProfile || string.IsNullOrWhiteSpace(response.ApprovingPublicKey) || + response.EncryptedGrant == null) + { + throw new InvalidDataException("Pairing completion response is incomplete."); + } + var peerKey = Convert.FromBase64String(response.ApprovingPublicKey); + return new PairingCompletionPreview + { + Context = context, + Response = response, + SafetyCode = _crypto.CreatePairingSafetyCode(context.PublicKey, peerKey, context.PrivateKey) + }; + } + + public async Task CompletePairingAsync( + PairingCompletionPreview preview, + string displayName, + CancellationToken cancellationToken = default) + { + RequireTransport(); + EnsureNotEnabled(); + if (string.IsNullOrWhiteSpace(preview.Response.ApprovingPublicKey) || + preview.Response.EncryptedGrant == null) + { + throw new InvalidDataException("Pairing completion response is incomplete."); + } + var peerKey = Convert.FromBase64String(preview.Response.ApprovingPublicKey); + var wrapKey = _crypto.DerivePairingWrapKey(preview.Context.PrivateKey, peerKey); + var plaintext = _crypto.DecryptPairingPayload( + wrapKey, + preview.Context.SessionId, + preview.Response.EncryptedGrant); + var grant = JsonSerializer.Deserialize(plaintext, _wireJsonOptions) + ?? throw new CryptographicException("Pairing grant is invalid."); + var seed = Convert.FromBase64String(grant.RecoverySeed); + if (seed.Length != 16 || + string.IsNullOrWhiteSpace(grant.VaultId) || + string.IsNullOrWhiteSpace(grant.DeviceToken) || + !grant.DeviceToken.StartsWith(preview.Context.ProposedDeviceId + ".", StringComparison.Ordinal)) + { + throw new CryptographicException("Pairing grant does not match this device."); + } + + PendingSyncSecrets existingPending; + try + { + existingPending = _pendingSecretsStore.Load(); + } + catch (CryptographicException) + { + throw new CryptographicException("The pending pairing credentials cannot be unlocked."); + } + if (!string.Equals(existingPending.Kind, "pairing", StringComparison.Ordinal) || + !PairingContextMatchesPending(preview.Context, existingPending)) + { + throw new CryptographicException("Pairing completion does not match the pending session."); + } + + var effectiveDisplayName = NormalizeProfileValue(displayName, 128); + var profile = CreateEncryptedDeviceProfile( + seed, + grant.VaultId, + preview.Context.ProposedDeviceId, + effectiveDisplayName); + lock (_stateLock) + { + if (!string.Equals(_state.PendingProvisioningKind, "pairing", StringComparison.Ordinal)) + { + throw new InvalidOperationException("There is no pending pairing session to complete."); + } + // Persist the exact encrypted profile before replacing the pairing secret + // bundle. If the process exits between these two durable writes, startup + // can reconcile the pairing-final bundle with this exact request body. + _state.PendingEncryptedDeviceProfile = profile; + _state.PendingProvisioningDisplayName = effectiveDisplayName; + _stateStore.Save(_state); + } + var pending = new PendingSyncSecrets + { + Kind = "pairing-final", + VaultId = grant.VaultId, + DeviceId = preview.Context.ProposedDeviceId, + VaultSeed = seed, + DeviceToken = grant.DeviceToken, + PairingPrivateKey = preview.Context.PrivateKey, + PairingPublicKey = preview.Context.PublicKey, + PairingSessionId = preview.Context.SessionId, + PairingCode = preview.Context.Code, + PairingCompletionToken = preview.Context.CompletionToken, + PairingExpiresAt = preview.Context.ExpiresAt + }; + _pendingSecretsStore.Save(pending); + lock (_stateLock) + { + _state.PendingProvisioningKind = "pairing-final"; + _stateStore.Save(_state); + } + RaiseStatusChanged(); + + await RetryPairingFinalAsync(pending, effectiveDisplayName, cancellationToken) + .ConfigureAwait(false); + } + + private async Task RetryPairingFinalAsync( + PendingSyncSecrets pending, + string displayName, + CancellationToken cancellationToken) + { + var transport = RequireTransport(); + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + SetBusy(true); + try + { + PairingEncryptedPayload profile; + lock (_stateLock) + { + if (!string.Equals(_state.PendingProvisioningKind, "pairing-final", StringComparison.Ordinal) || + _state.PendingEncryptedDeviceProfile == null || + !string.Equals(_state.InstallationDeviceId, pending.DeviceId, StringComparison.Ordinal)) + { + throw new CryptographicException("Pending pairing completion state is inconsistent."); + } + profile = _state.PendingEncryptedDeviceProfile; + } + if (!string.Equals(pending.Kind, "pairing-final", StringComparison.Ordinal)) + { + throw new CryptographicException("Pending pairing completion credentials are invalid."); + } + + var completion = await transport.CompletePairingSessionAsync( + pending.PairingSessionId, + new CompletePairingSessionRequest + { + CompletionToken = pending.PairingCompletionToken, + EncryptedDeviceProfile = profile + }, + cancellationToken).ConfigureAwait(false); + ValidateCompletedPairingResponse(completion, pending); + + _credentialStore.Save(new SyncCredentials + { + VaultId = pending.VaultId, + DeviceId = pending.DeviceId, + VaultSeed = pending.VaultSeed, + DeviceToken = pending.DeviceToken + }); + lock (_stateLock) + { + var canPreserveRevisionState = + string.Equals(_state.VaultId, pending.VaultId, StringComparison.Ordinal) && + string.Equals(_state.DeviceId, pending.DeviceId, StringComparison.Ordinal); + var existingLocalRecords = canPreserveRevisionState + ? _state.LocalRecords + : new Dictionary(StringComparer.Ordinal); + var existingCurrent = canPreserveRevisionState + ? _state.LastAcknowledgedCurrentSnapshot + : null; + _state = NewEnabledState( + pending.VaultId, + pending.DeviceId, + displayName, + completion.ActiveDeviceCount ?? 1); + _state.LocalRecords = existingLocalRecords; + _state.LastAcknowledgedCurrentSnapshot = existingCurrent; + _state.HistoryCursor = 0; + _state.NeedsBootstrap = false; + _state.NeedsHistoryBootstrap = true; + _state.NeedsStateRefreshBeforeBootstrap = true; + _state.PendingBootstrapReason = "pairing"; + _state.PendingEncryptedDeviceProfile = null; + _lastError = null; + SaveRecoveredStateLocked(); + } + _pendingSecretsStore.Delete(); + _remoteCache.ResetForBootstrap(); + RaiseStatusChanged(); + } + catch (Exception ex) + { + lock (_stateLock) _lastError = SafeErrorMessage(ex); + RaiseStatusChanged(); + throw; + } + finally + { + SetBusy(false); + _operationGate.Release(); + } + + await ContinueRevisionRebuildAsync(cancellationToken).ConfigureAwait(false); + } + + private void ValidateCompletedPairingResponse( + CompletePairingSessionResponse completion, + PendingSyncSecrets pending) + { + if (completion.Pending || completion.RequiresProfile || + completion.ActiveDeviceCount is < 1 or > SyncProtocol.MaximumDevices || + string.IsNullOrWhiteSpace(completion.ApprovingPublicKey) || + completion.EncryptedGrant == null) + { + throw new InvalidDataException("Pairing completion was not finalized by the sync service."); + } + var peerKey = Convert.FromBase64String(completion.ApprovingPublicKey); + var wrapKey = _crypto.DerivePairingWrapKey(pending.PairingPrivateKey, peerKey); + var plaintext = _crypto.DecryptPairingPayload( + wrapKey, + pending.PairingSessionId, + completion.EncryptedGrant); + var replayedGrant = JsonSerializer.Deserialize(plaintext, _wireJsonOptions) + ?? throw new CryptographicException("Pairing grant is invalid."); + byte[] replayedSeed; + try + { + replayedSeed = Convert.FromBase64String(replayedGrant.RecoverySeed); + } + catch (FormatException ex) + { + throw new CryptographicException("Pairing grant is invalid.", ex); + } + if (!string.Equals(replayedGrant.VaultId, pending.VaultId, StringComparison.Ordinal) || + !string.Equals(replayedGrant.DeviceToken, pending.DeviceToken, StringComparison.Ordinal) || + !replayedSeed.SequenceEqual(pending.VaultSeed)) + { + throw new CryptographicException("Replayed pairing grant does not match the pending credentials."); + } + } + + private static bool PairingContextMatchesPending( + PairingSessionContext context, + PendingSyncSecrets pending) + => string.Equals(context.ProposedDeviceId, pending.DeviceId, StringComparison.Ordinal) && + string.Equals(context.SessionId, pending.PairingSessionId, StringComparison.Ordinal) && + string.Equals(context.Code, pending.PairingCode, StringComparison.Ordinal) && + string.Equals(context.CompletionToken, pending.PairingCompletionToken, StringComparison.Ordinal) && + context.PrivateKey.SequenceEqual(pending.PairingPrivateKey) && + context.PublicKey.SequenceEqual(pending.PairingPublicKey); + + public Task SyncNowAsync(CancellationToken cancellationToken = default) + => SyncCoreAsync("manual", bypassClientRateLimit: false, cancellationToken); + + public Task RetryBootstrapAsync(CancellationToken cancellationToken = default) + { + string? provisioningKind; + bool pendingDeletion; + bool historyOnly; + bool stateRefreshFirst; + lock (_stateLock) + { + pendingDeletion = _state.PendingVaultDeletion; + provisioningKind = _state.PendingProvisioningKind; + if (pendingDeletion || IsRetryableProvisioningKind(provisioningKind)) + { + historyOnly = false; + stateRefreshFirst = false; + } + else + { + if (!_state.IsEnabled || _state.NeedsRepair || + (!_state.NeedsBootstrap && !_state.NeedsHistoryBootstrap && + !_state.NeedsStateRefreshBeforeBootstrap)) + { + throw new InvalidOperationException("There is no pending sync bootstrap to retry."); + } + stateRefreshFirst = _state.NeedsStateRefreshBeforeBootstrap; + historyOnly = !_state.NeedsBootstrap && _state.NeedsHistoryBootstrap; + } + } + if (pendingDeletion) return DeleteVaultAsync(cancellationToken); + if (IsRetryableProvisioningKind(provisioningKind)) + { + return RetryPendingProvisioningAsync(provisioningKind!, cancellationToken); + } + if (stateRefreshFirst) return ContinueRevisionRebuildAsync(cancellationToken); + return historyOnly + ? ContinueHistoryBootstrapAsync(cancellationToken) + : SyncCoreAsync("bootstrap", bypassClientRateLimit: true, cancellationToken); + } + + private Task RetryPendingProvisioningAsync(string kind, CancellationToken cancellationToken) + { + var pending = _pendingSecretsStore.Load(); + string displayName; + lock (_stateLock) displayName = _state.PendingProvisioningDisplayName ?? "Windows device"; + return kind switch + { + "create" => RetryCreateProvisioningAsync(pending, displayName, cancellationToken), + "recover" => RecoverVaultAsync( + _crypto.EncodeRecoveryCode(pending.VaultSeed), + displayName, + cancellationToken), + "pairing-final" => RetryPairingFinalAsync(pending, displayName, cancellationToken), + _ => throw new InvalidOperationException("The pending sync setup cannot be retried automatically.") + }; + } + + private async Task RetryCreateProvisioningAsync( + PendingSyncSecrets pending, + string displayName, + CancellationToken cancellationToken) + { + _ = pending; + await CreateVaultAsync(displayName, cancellationToken).ConfigureAwait(false); + } + + private static bool IsRetryableProvisioningKind(string? kind) + => string.Equals(kind, "create", StringComparison.Ordinal) || + string.Equals(kind, "recover", StringComparison.Ordinal) || + string.Equals(kind, "pairing-final", StringComparison.Ordinal); + + private async Task ContinueRevisionRebuildAsync(CancellationToken cancellationToken) + { + await RefreshStateAsync(rebuildOwnRevisionState: true, cancellationToken).ConfigureAwait(false); + lock (_stateLock) + { + _state.NeedsStateRefreshBeforeBootstrap = false; + _stateStore.Save(_state); + } + await ContinueHistoryBootstrapAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task ContinueHistoryBootstrapAsync(CancellationToken cancellationToken) + { + RequireTransport(); + string? followupReason = null; + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + SetBusy(true); + try + { + lock (_stateLock) + { + EnsureEnabledLocked(); + if (!_state.NeedsHistoryBootstrap || _state.NeedsBootstrap || + _state.NeedsStateRefreshBeforeBootstrap) + { + throw new InvalidOperationException("History bootstrap is not ready to resume."); + } + } + + var credentials = RequireCredentials(); + var dataKey = _crypto.DeriveDataKey(credentials.VaultSeed); + var indexKey = _crypto.DeriveIndexKey(credentials.VaultSeed); + followupReason = await FinishHistoryBootstrapWithinGateAsync( + credentials, + dataKey, + indexKey, + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + lock (_stateLock) _lastError = SafeErrorMessage(ex); + RaiseStatusChanged(); + throw; + } + finally + { + SetBusy(false); + _operationGate.Release(); + } + if (!string.IsNullOrWhiteSpace(followupReason)) + { + await SyncCoreAsync(followupReason, bypassClientRateLimit: true, cancellationToken) + .ConfigureAwait(false); + } + } + + public async Task LeaveSyncAsync(CancellationToken cancellationToken = default) + { + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + SetBusy(true); + try + { + bool repairOnly; + lock (_stateLock) repairOnly = _state.NeedsRepair; + if (!repairOnly) + { + var transport = RequireTransport(); + var credentials = RequireCredentials(); + string deviceId; + lock (_stateLock) + { + EnsureEnabledLocked(); + deviceId = _state.DeviceId; + } + await transport.RevokeDeviceAsync(deviceId, credentials.DeviceToken, cancellationToken) + .ConfigureAwait(false); + } + string? replacementCandidate; + lock (_stateLock) + { + replacementCandidate = Guid.TryParse(_state.DeviceId, out _) ? _state.DeviceId : null; + } + DisableLocally(replacementCandidate); + } + finally + { + SetBusy(false); + _operationGate.Release(); + } + } + + public async Task RevokeDeviceAsync(string deviceId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(deviceId)) throw new ArgumentException("Device identifier is required.", nameof(deviceId)); + var transport = RequireTransport(); + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + SetBusy(true); + try + { + var credentials = RequireCredentials(); + lock (_stateLock) + { + EnsureEnabledLocked(); + if (string.Equals(deviceId, _state.DeviceId, StringComparison.Ordinal)) + { + throw new InvalidOperationException("Use leave sync to remove this device."); + } + } + + await transport.RevokeDeviceAsync(deviceId, credentials.DeviceToken, cancellationToken) + .ConfigureAwait(false); + lock (_stateLock) + { + _state.Devices.RemoveAll(item => string.Equals(item.DeviceId, deviceId, StringComparison.Ordinal)); + _state.ActiveDeviceCount = Math.Max(1, _state.ActiveDeviceCount - 1); + ScheduleForDeviceCountLocked(DateTime.UtcNow); + _stateStore.Save(_state); + _lastError = null; + } + RaiseStatusChanged(); + } + finally + { + SetBusy(false); + _operationGate.Release(); + } + } + + public async Task DeleteVaultAsync(CancellationToken cancellationToken = default) + { + var transport = RequireTransport(); + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + SetBusy(true); + try + { + lock (_stateLock) EnsureEnabledLocked(); + var credentials = RequireCredentials(); + lock (_stateLock) + { + // Persist the user's destructive intent before contacting the + // service. DELETE /vault is replay-safe for this exact token, so + // startup can finish a request whose successful response was lost. + _state.PendingVaultDeletion = true; + _state.NextAutomaticSyncAtUtc = null; + _automaticTimer?.Change(Timeout.Infinite, Timeout.Infinite); + _stateRefreshTimer?.Change(Timeout.Infinite, Timeout.Infinite); + _stateStore.Save(_state); + } + await transport.DeleteVaultAsync(credentials.DeviceToken, cancellationToken).ConfigureAwait(false); + DisableLocally(replacementCandidateDeviceId: null); + } + catch (Exception ex) + { + lock (_stateLock) + { + _lastError = SafeErrorMessage(ex); + if (!_stateStore.NeedsRepair) _stateStore.Save(_state); + } + RaiseStatusChanged(); + throw; + } + finally + { + SetBusy(false); + _operationGate.Release(); + } + } + + public async Task ClearLocalSyncConfigurationAsync( + CancellationToken cancellationToken = default) + { + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + SetBusy(true); + try + { + string? replacementCandidate; + lock (_stateLock) + { + if (!_state.NeedsRepair && string.IsNullOrWhiteSpace(_state.PendingProvisioningKind)) + { + throw new InvalidOperationException( + "Only damaged or incomplete local sync setup can be cleared without contacting the service."); + } + replacementCandidate = _state.NeedsRepair && + string.IsNullOrWhiteSpace(_state.PendingProvisioningKind) && + Guid.TryParse(_state.DeviceId, out _) + ? _state.DeviceId + : null; + } + DisableLocally(replacementCandidate); + } + finally + { + SetBusy(false); + _operationGate.Release(); + } + } + + private async Task SyncCoreAsync( + string reason, + bool bypassClientRateLimit, + CancellationToken cancellationToken, + bool pairingRefreshOnly = false) + { + var transport = RequireTransport(); + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + SetBusy(true); + try + { + SyncCredentials credentials; + lock (_stateLock) + { + EnsureEnabledLocked(); + if (_state.PendingVaultDeletion) + { + throw new InvalidOperationException("Cloud sync deletion is pending."); + } + var bootstrapReason = SyncBatchPlanner.IsBootstrapReason(reason); + if (bootstrapReason && !_state.NeedsBootstrap && !pairingRefreshOnly) + { + throw new InvalidOperationException("There is no pending sync bootstrap to upload."); + } + if (pairingRefreshOnly && + (!string.Equals(reason, "pairing", StringComparison.Ordinal) || + _state.NeedsBootstrap || _state.NeedsHistoryBootstrap)) + { + throw new InvalidOperationException("The pairing refresh cannot run in the current sync phase."); + } + if (!bootstrapReason && (_state.NeedsBootstrap || _state.NeedsHistoryBootstrap || + _state.NeedsStateRefreshBeforeBootstrap)) + { + throw new InvalidOperationException("The pending sync bootstrap must finish first."); + } + RefreshUtcDayWindowsLocked(DateTime.UtcNow); + if (_state.ActiveDeviceCount < 2 && !bypassClientRateLimit) + { + throw new InvalidOperationException("Sync is available after a second device is connected."); + } + + if (!bypassClientRateLimit) + { + var now = DateTime.UtcNow; + var allowedAt = GetEffectiveAllowedAtLocked(); + if (now < allowedAt) throw new SyncRateLimitedException(allowedAt); + if (_state.RemainingDailySyncs <= 0) throw new SyncRateLimitedException(NextUtcMidnight(now)); + } + } + credentials = RequireCredentials(); + + var prepared = pairingRefreshOnly + ? PreparePairingRefresh() + : PrepareSync(credentials, reason); + var batches = SyncBatchPlanner.CreateBatches( + prepared.Request, + prepared.LastAcknowledgedCurrentSnapshot); + SyncRequest? finalRequest = null; + SyncResponse? finalResponse = null; + foreach (var batch in batches) + { + SyncResponse response; + try + { + response = await transport.SyncAsync( + batch, + credentials.DeviceToken, + SyncCrypto.Sha256Base64Url(SerializeCanonical(batch)), + cancellationToken).ConfigureAwait(false); + } + catch (SyncTransportException ex) when (ex.StatusCode == (HttpStatusCode)429) + { + lock (_stateLock) + { + var retryAt = DateTime.UtcNow + (ex.RetryAfter ?? SyncProtocol.ManualSyncInterval); + _state.NextAllowedSyncAtUtc = retryAt; + _stateStore.Save(_state); + ScheduleAutomaticTimerLocked(DateTime.UtcNow); + } + throw new SyncRateLimitedException( + GetStatus().NextAllowedSyncAtUtc ?? DateTime.UtcNow.AddHours(1), + ex); + } + catch (SyncTransportException ex) when (IsSingleDeviceSyncDisabled(ex)) + { + lock (_stateLock) + { + TransitionToSingleDeviceLocked(); + } + RaiseStatusChanged(); + return; + } + + ValidateSyncResponse(response); + if (!batch.BootstrapComplete) + { + lock (_stateLock) + { + CompletePreparedUploadsLocked(batch); + _stateStore.Save(_state); + } + continue; + } + + finalRequest = batch; + finalResponse = response; + } + + if (finalRequest == null || finalResponse == null) + { + throw new InvalidDataException("Sync batching did not produce a final request."); + } + + var dataKey = _crypto.DeriveDataKey(credentials.VaultSeed); + var indexKey = _crypto.DeriveIndexKey(credentials.VaultSeed); + ApplyCurrentSnapshots(finalResponse.CurrentSnapshots, dataKey, indexKey); + ApplyHistoryChanges(finalResponse.HistoryChanges, dataKey, indexKey); + RefreshDevices(finalResponse.Devices, credentials); + + var requiresHistoryBootstrap = finalResponse.HistoryHasMore; + + lock (_stateLock) + { + CompletePreparedUploadsLocked(finalRequest); + _state.LastSuccessfulSyncAtUtc = NormalizeUtc(finalResponse.ServerTime); + _state.NextAllowedSyncAtUtc = NormalizeUtc(finalResponse.NextAllowedSyncAt); + _state.RemainingDailySyncs = Math.Max(0, finalResponse.RemainingDailySyncs); + _state.QuotaUtcDay = UtcDay(finalResponse.ServerTime); + _state.ActiveDeviceCount = Math.Max(1, finalResponse.ActiveDeviceCount); + _state.HistoryCursor = finalResponse.Cursor; + _state.NeedsBootstrap = false; + _state.NeedsHistoryBootstrap = requiresHistoryBootstrap; + _state.PendingBootstrapReason = null; + if (finalRequest.EncryptedDeviceProfile != null) + { + _state.PendingEncryptedDeviceProfile = null; + } + _lastError = null; + _state.AutomaticFailureCount = 0; + _state.AutomaticFailureUtcDay = UtcDay(finalResponse.ServerTime); + if (!requiresHistoryBootstrap) + { + ScheduleNextSuccessfulAutomaticSyncLocked(); + } + else + { + _state.NextAutomaticSyncAtUtc = null; + _automaticTimer?.Change(Timeout.Infinite, Timeout.Infinite); + } + _stateStore.Save(_state); + } + RaiseStatusChanged(); + + if (requiresHistoryBootstrap) + { + await FinishHistoryBootstrapWithinGateAsync( + credentials, + dataKey, + indexKey, + cancellationToken).ConfigureAwait(false); + } + } + catch (Exception ex) + { + var expectedPairingConflict = pairingRefreshOnly && + ex is SyncTransportException pairingError && + pairingError.StatusCode == HttpStatusCode.Conflict; + lock (_stateLock) + { + if (!expectedPairingConflict) _lastError = SafeErrorMessage(ex); + if (string.Equals(reason, "automatic", StringComparison.Ordinal)) + { + if (ex is SyncRateLimitedException { InnerException: SyncTransportException transportError } && + transportError.StatusCode == (HttpStatusCode)429) + { + _state.NextAutomaticSyncAtUtc = _state.NextAllowedSyncAtUtc ?? DateTime.UtcNow.AddHours(1); + ScheduleAutomaticTimerLocked(DateTime.UtcNow); + } + else + { + RecordAutomaticFailureLocked(); + } + _stateStore.Save(_state); + } + else if (string.Equals(reason, "manual", StringComparison.Ordinal) && + IsManualTransportFailure(ex, cancellationToken)) + { + RecordManualFailureLocked(); + _stateStore.Save(_state); + } + } + RaiseStatusChanged(); + throw; + } + finally + { + SetBusy(false); + _operationGate.Release(); + } + } + + private PreparedSync PrepareSync(SyncCredentials credentials, string reason) + { + var localHistory = _statsManager.GetLocalHistorySnapshot(); + var dataKey = _crypto.DeriveDataKey(credentials.VaultSeed); + var indexKey = _crypto.DeriveIndexKey(credentials.VaultSeed); + var today = DateTime.Today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + var request = new SyncRequest { Reason = reason }; + EncryptedSyncRecord? lastAcknowledgedCurrentSnapshot; + + lock (_stateLock) + { + if (_state.LastAcknowledgedCurrentSnapshot == null) + { + var inferredCurrent = _state.LocalRecords.Values + .Where(record => !record.IsPending && !record.UploadedAsArchive && + record.Envelope != null && + !string.IsNullOrWhiteSpace(record.Envelope.RecordId)) + .OrderByDescending(record => record.LocalDay, StringComparer.Ordinal) + .FirstOrDefault(); + if (inferredCurrent != null) + { + _state.LastAcknowledgedCurrentSnapshot = CloneEncryptedRecord(inferredCurrent.Envelope); + } + } + lastAcknowledgedCurrentSnapshot = _state.LastAcknowledgedCurrentSnapshot == null + ? null + : CloneEncryptedRecord(_state.LastAcknowledgedCurrentSnapshot); + request.HistoryCursor = _state.HistoryCursor; + request.EncryptedDeviceProfile = _state.PendingEncryptedDeviceProfile; + foreach (var pair in localHistory.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + var localDay = pair.Key; + var contentSnapshot = CreateCoreSnapshot(_state.DeviceId, localDay, pair.Value, revision: 0); + var contentBytes = SerializeCanonical(contentSnapshot); + var contentHash = SyncCrypto.Sha256Base64Url(contentBytes); + _state.LocalRecords.TryGetValue(localDay, out var recordState); + + var hasReusablePendingEnvelope = recordState != null && + recordState.IsPending && + recordState.Envelope != null && + !string.IsNullOrWhiteSpace(recordState.Envelope.Ciphertext); + if (!hasReusablePendingEnvelope && + (recordState == null || + !string.Equals(recordState.ContentHash, contentHash, StringComparison.Ordinal) || + recordState.Envelope == null || + string.IsNullOrWhiteSpace(recordState.Envelope.Ciphertext))) + { + var revision = checked((recordState?.Revision ?? 0) + 1); + var snapshot = CreateCoreSnapshot(_state.DeviceId, localDay, pair.Value, revision); + var recordId = _crypto.CreateRecordId(indexKey, _state.DeviceId, localDay); + recordState = new LocalSyncRecordState + { + LocalDay = localDay, + ContentHash = contentHash, + Revision = revision, + IsPending = true, + UploadedAsArchive = false, + Envelope = _crypto.EncryptRecord( + dataKey, + _state.VaultId, + _state.DeviceId, + recordId, + revision, + SerializeCanonical(snapshot)) + }; + _state.LocalRecords[localDay] = recordState; + } + + var isArchive = !string.Equals(localDay, today, StringComparison.Ordinal); + if (isArchive && !recordState.UploadedAsArchive) + { + recordState.IsPending = true; + } + + if (!recordState.IsPending) continue; + if (isArchive) request.Archives.Add(recordState.Envelope); + else request.CurrentSnapshot = recordState.Envelope; + } + + _stateStore.Save(_state); + } + + return new PreparedSync + { + Request = request, + LastAcknowledgedCurrentSnapshot = lastAcknowledgedCurrentSnapshot + }; + } + + private PreparedSync PreparePairingRefresh() + { + lock (_stateLock) + { + return new PreparedSync + { + Request = new SyncRequest + { + Reason = "pairing", + HistoryCursor = _state.HistoryCursor, + BootstrapComplete = true + } + }; + } + } + + private void ApplyCurrentSnapshots( + IEnumerable records, + byte[] dataKey, + byte[] indexKey) + { + foreach (var record in records ?? Enumerable.Empty()) + { + if (IsLocalDevice(record.DeviceId)) continue; + _remoteCache.Apply(DecryptAndValidate(record, isCurrent: true, dataKey, indexKey)); + } + } + + private void ApplyHistoryChanges( + IEnumerable changes, + byte[] dataKey, + byte[] indexKey, + bool rebuildLocalRevisionState = false) + { + foreach (var change in changes ?? Enumerable.Empty()) + { + if (change.Cursor < 0 || string.IsNullOrWhiteSpace(change.RecordId)) + { + throw new InvalidDataException("History change metadata is invalid."); + } + if (change.Tombstone) + { + if (rebuildLocalRevisionState) + { + lock (_stateLock) + { + var local = _state.LocalRecords.FirstOrDefault(pair => + string.Equals(pair.Value.Envelope.RecordId, change.RecordId, StringComparison.Ordinal)); + if (!string.IsNullOrWhiteSpace(local.Key)) _state.LocalRecords.Remove(local.Key); + } + } + _remoteCache.ApplyTombstone(change.RecordId, Math.Max(1, change.Cursor)); + continue; + } + if (change.Record == null) throw new InvalidDataException("History change is missing its encrypted record."); + if (!string.Equals(change.Record.RecordId, change.RecordId, StringComparison.Ordinal)) + { + throw new InvalidDataException("History change record identifier is inconsistent."); + } + var decrypted = DecryptAndValidate(change.Record, isCurrent: false, dataKey, indexKey); + if (IsLocalDevice(change.Record.DeviceId)) + { + if (rebuildLocalRevisionState) + { + lock (_stateLock) RestoreOwnDecryptedRecordLocked(change.Record, decrypted, isCurrent: false); + } + continue; + } + _remoteCache.Apply(decrypted); + } + } + + private CachedRemoteRecord DecryptAndValidate( + EncryptedSyncRecord record, + bool isCurrent, + byte[] dataKey, + byte[] indexKey) + { + var plaintextBytes = _crypto.DecryptRecord(dataKey, GetVaultId(), record); + var snapshot = JsonSerializer.Deserialize(plaintextBytes, _wireJsonOptions) + ?? throw new InvalidDataException("Encrypted snapshot is empty."); + if (snapshot.SchemaVersion != SyncProtocol.SchemaVersion || + snapshot.Revision != record.Revision || + !string.Equals(snapshot.DeviceId, record.DeviceId, StringComparison.Ordinal) || + !DateTime.TryParseExact(snapshot.LocalDay, "yyyy-MM-dd", CultureInfo.InvariantCulture, + DateTimeStyles.None, out _) || + !string.Equals( + _crypto.CreateRecordId(indexKey, record.DeviceId, snapshot.LocalDay), + record.RecordId, + StringComparison.Ordinal)) + { + throw new CryptographicException("Encrypted snapshot metadata is inconsistent."); + } + + ValidateCoreSnapshot(snapshot); + return new CachedRemoteRecord + { + DeviceId = record.DeviceId, + RecordId = record.RecordId, + Revision = record.Revision, + CiphertextHash = record.CiphertextHash, + IsCurrent = isCurrent, + Plaintext = snapshot + }; + } + + private void RestoreOwnRecordLocked( + EncryptedSyncRecord record, + bool isCurrent, + byte[] vaultSeed) + { + var dataKey = _crypto.DeriveDataKey(vaultSeed); + var indexKey = _crypto.DeriveIndexKey(vaultSeed); + var decrypted = DecryptAndValidate(record, isCurrent, dataKey, indexKey); + RestoreOwnDecryptedRecordLocked(record, decrypted, isCurrent); + } + + private void RestoreOwnDecryptedRecordLocked( + EncryptedSyncRecord record, + CachedRemoteRecord decrypted, + bool isCurrent) + { + if (!string.Equals(record.DeviceId, _state.DeviceId, StringComparison.Ordinal) || + !string.Equals(decrypted.DeviceId, _state.DeviceId, StringComparison.Ordinal)) + { + throw new CryptographicException("Recovered revision state belongs to a different device."); + } + + var snapshot = decrypted.Plaintext; + var originalRevision = snapshot.Revision; + snapshot.Revision = 0; + var contentHash = SyncCrypto.Sha256Base64Url(SerializeCanonical(snapshot)); + snapshot.Revision = originalRevision; + + _state.LocalRecords.TryGetValue(snapshot.LocalDay, out var existing); + if (existing == null || existing.Revision <= record.Revision) + { + if (existing != null && existing.Revision == record.Revision && + !string.Equals(existing.Envelope.CiphertextHash, record.CiphertextHash, StringComparison.Ordinal)) + { + throw new CryptographicException("Recovered revision state conflicts with local metadata."); + } + _state.LocalRecords[snapshot.LocalDay] = new LocalSyncRecordState + { + LocalDay = snapshot.LocalDay, + ContentHash = contentHash, + Revision = record.Revision, + IsPending = false, + UploadedAsArchive = !isCurrent, + Envelope = CloneEncryptedRecord(record) + }; + } + if (isCurrent) + { + _state.LastAcknowledgedCurrentSnapshot = CloneEncryptedRecord(record); + } + } + + private async Task FetchRemainingHistoryAsync( + long cursor, + SyncCredentials credentials, + byte[] dataKey, + byte[] indexKey, + CancellationToken cancellationToken) + { + if (_transport == null) return cursor; + for (var page = 0; page < SyncProtocol.MaximumHistoryPagesPerAttempt; page++) + { + var previousCursor = cursor; + var response = await _transport.GetHistoryAsync(cursor, credentials.DeviceToken, cancellationToken) + .ConfigureAwait(false); + if (response.Cursor < previousCursor || (response.HasMore && response.Cursor == previousCursor)) + { + throw new InvalidDataException("History response cursor is invalid."); + } + bool rebuildLocalRevisionState; + lock (_stateLock) + { + rebuildLocalRevisionState = + string.Equals(_state.PendingBootstrapReason, "recovery", StringComparison.Ordinal) || + string.Equals(_state.PendingBootstrapReason, "pairing", StringComparison.Ordinal); + } + ApplyHistoryChanges(response.Changes, dataKey, indexKey, rebuildLocalRevisionState); + cursor = response.Cursor; + lock (_stateLock) + { + if (_state.NeedsBootstrap || !_state.NeedsHistoryBootstrap) + { + throw new InvalidOperationException("History bootstrap is no longer pending."); + } + _state.HistoryCursor = cursor; + _stateStore.Save(_state); + } + if (!response.HasMore) return cursor; + } + throw new InvalidDataException("History bootstrap exceeded the page limit."); + } + + private async Task FinishHistoryBootstrapWithinGateAsync( + SyncCredentials credentials, + byte[] dataKey, + byte[] indexKey, + CancellationToken cancellationToken) + { + long cursor; + lock (_stateLock) + { + EnsureEnabledLocked(); + if (_state.NeedsBootstrap || !_state.NeedsHistoryBootstrap) + { + throw new InvalidOperationException("History bootstrap is not ready to continue."); + } + cursor = _state.HistoryCursor; + } + + cursor = await FetchRemainingHistoryAsync( + cursor, + credentials, + dataKey, + indexKey, + cancellationToken).ConfigureAwait(false); + + string? followupReason; + lock (_stateLock) + { + _state.HistoryCursor = cursor; + _state.NeedsHistoryBootstrap = false; + _lastError = null; + followupReason = _state.PendingBootstrapReason; + if (!string.IsNullOrWhiteSpace(followupReason)) + { + _state.NeedsBootstrap = true; + } + else + { + ScheduleNextSuccessfulAutomaticSyncLocked(); + } + _stateStore.Save(_state); + } + RaiseStatusChanged(); + return followupReason; + } + + private static void ValidateSyncResponse(SyncResponse response) + { + if (response.ServerTime == default || response.NextAllowedSyncAt == default || response.Cursor < 0 || + response.ActiveDeviceCount < 1 || response.ActiveDeviceCount > SyncProtocol.MaximumDevices || + response.RemainingDailySyncs < 0 || response.RemainingDailySyncs > 8 || + response.CurrentSnapshots == null || response.HistoryChanges == null || response.Devices == null) + { + throw new InvalidDataException("Sync service returned an invalid response."); + } + } + + private void RefreshDevices( + IReadOnlyList? encryptedDevices, + SyncCredentials credentials) + { + var devices = encryptedDevices ?? Array.Empty(); + if (devices.Count > SyncProtocol.MaximumDevices) + { + throw new InvalidDataException("Sync service returned too many devices."); + } + + string vaultId; + string currentDeviceId; + SyncDeviceInfo currentDevice; + Dictionary existing; + lock (_stateLock) + { + vaultId = _state.VaultId; + currentDeviceId = _state.DeviceId; + var found = _state.Devices.FirstOrDefault(item => + string.Equals(item.DeviceId, currentDeviceId, StringComparison.Ordinal)); + currentDevice = found != null ? CloneDevice(found) : CreateCurrentDeviceInfo(_state); + existing = _state.Devices + .Where(item => !string.IsNullOrWhiteSpace(item.DeviceId)) + .GroupBy(item => item.DeviceId, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => CloneDevice(group.Last()), StringComparer.Ordinal); + } + + var dataKey = _crypto.DeriveDataKey(credentials.VaultSeed); + var seen = new HashSet(StringComparer.Ordinal); + var refreshed = new List(); + foreach (var encrypted in devices) + { + if (string.IsNullOrWhiteSpace(encrypted.DeviceId) || !seen.Add(encrypted.DeviceId)) + { + throw new InvalidDataException("Sync service returned an invalid device list."); + } + + existing.TryGetValue(encrypted.DeviceId, out var previous); + DeviceProfileV1? profile = null; + if (encrypted.EncryptedDeviceProfile != null) + { + var profileBytes = _crypto.DecryptDeviceProfile( + dataKey, + vaultId, + encrypted.DeviceId, + encrypted.EncryptedDeviceProfile); + profile = JsonSerializer.Deserialize(profileBytes, _wireJsonOptions) + ?? throw new CryptographicException("Encrypted device profile is empty."); + if (profile.SchemaVersion != SyncProtocol.SchemaVersion || + Encoding.UTF8.GetByteCount(profile.DisplayName ?? string.Empty) > 128 || + Encoding.UTF8.GetByteCount(profile.Platform ?? string.Empty) > 128) + { + throw new CryptographicException("Encrypted device profile is invalid."); + } + } + + refreshed.Add(new SyncDeviceInfo + { + DeviceId = encrypted.DeviceId, + DisplayName = NonEmptyProfileValue( + profile?.DisplayName, + previous?.DisplayName, + "Device " + encrypted.DeviceId.Substring(0, Math.Min(6, encrypted.DeviceId.Length))), + Platform = NonEmptyProfileValue(profile?.Platform, previous?.Platform, string.Empty), + LastSyncAt = NormalizeUtc(encrypted.LastSyncAt), + IsCurrent = string.Equals(encrypted.DeviceId, currentDeviceId, StringComparison.Ordinal), + IsRevoked = encrypted.Revoked + }); + } + + if (!seen.Contains(currentDeviceId)) refreshed.Add(currentDevice); + lock (_stateLock) _state.Devices = refreshed; + } + + private PairingEncryptedPayload CreateEncryptedDeviceProfile( + byte[] seed, + string vaultId, + string deviceId, + string displayName) + { + var profile = new DeviceProfileV1 + { + DisplayName = NormalizeProfileValue(displayName, 128), + Platform = "windows" + }; + var bytes = SerializeCanonical(profile); + return _crypto.EncryptDeviceProfile(_crypto.DeriveDataKey(seed), vaultId, deviceId, bytes); + } + + private CoreDaySnapshotV1 CreateCoreSnapshot( + string deviceId, + string localDay, + DailyStats source, + long revision) + { + var keyCounts = new Dictionary(StringComparer.Ordinal); + foreach (var pair in source.KeyPressCounts.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + if (!string.IsNullOrWhiteSpace(pair.Key) && pair.Value > 0) + { + var key = SyncKeyCanonicalizer.Canonicalize(pair.Key, "windows"); + if (key.Length == 0) continue; + keyCounts[key] = SaturatingAdd( + keyCounts.TryGetValue(key, out var existing) ? existing : 0, + pair.Value); + } + } + + var snapshot = new CoreDaySnapshotV1 + { + DeviceId = deviceId, + LocalDay = localDay, + Revision = revision, + KeyPresses = Math.Max(0, source.KeyPresses), + KeyPressCounts = keyCounts, + Clicks = new CoreClickSnapshotV1 + { + Left = Math.Max(0, source.LeftClicks), + Right = Math.Max(0, source.RightClicks), + Middle = Math.Max(0, source.MiddleClicks), + SideBack = Math.Max(0, source.SideBackClicks), + SideForward = Math.Max(0, source.SideForwardClicks) + } + }; + ValidateCoreSnapshot(snapshot); + return snapshot; + } + + private static byte[] SerializeCanonical(CoreDaySnapshotV1 snapshot) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + })) + { + writer.WriteStartObject(); + writer.WritePropertyName("clicks"); + writer.WriteStartObject(); + writer.WriteNumber("left", snapshot.Clicks.Left); + writer.WriteNumber("middle", snapshot.Clicks.Middle); + writer.WriteNumber("right", snapshot.Clicks.Right); + writer.WriteNumber("sideBack", snapshot.Clicks.SideBack); + writer.WriteNumber("sideForward", snapshot.Clicks.SideForward); + writer.WriteEndObject(); + writer.WriteString("deviceId", snapshot.DeviceId); + writer.WritePropertyName("keyPressCounts"); + writer.WriteStartObject(); + foreach (var pair in snapshot.KeyPressCounts.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + writer.WriteNumber(pair.Key, pair.Value); + } + writer.WriteEndObject(); + writer.WriteNumber("keyPresses", snapshot.KeyPresses); + writer.WriteString("localDay", snapshot.LocalDay); + writer.WriteNumber("revision", snapshot.Revision); + writer.WriteNumber("schemaVersion", snapshot.SchemaVersion); + writer.WriteEndObject(); + writer.Flush(); + } + return stream.ToArray(); + } + + private static byte[] SerializeCanonical(SyncRequest request) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + })) + { + writer.WriteStartObject(); + writer.WritePropertyName("archives"); + writer.WriteStartArray(); + foreach (var archive in request.Archives) WriteEncryptedRecord(writer, archive); + writer.WriteEndArray(); + writer.WriteBoolean("bootstrapComplete", request.BootstrapComplete); + if (request.CurrentSnapshot != null) + { + writer.WritePropertyName("currentSnapshot"); + WriteEncryptedRecord(writer, request.CurrentSnapshot); + } + if (request.EncryptedDeviceProfile != null) + { + writer.WritePropertyName("encryptedDeviceProfile"); + WriteEncryptedPayload(writer, request.EncryptedDeviceProfile); + } + writer.WriteNumber("historyCursor", request.HistoryCursor); + writer.WriteString("reason", request.Reason); + writer.WriteEndObject(); + writer.Flush(); + } + return stream.ToArray(); + } + + private static byte[] SerializeCanonical(PairingProvisioningPayload grant) + { + using var stream = new MemoryStream(); + using (var writer = CreateCanonicalWriter(stream)) + { + writer.WriteStartObject(); + if (grant.DeviceToken != null) writer.WriteString("deviceToken", grant.DeviceToken); + writer.WriteString("recoverySeed", grant.RecoverySeed); + writer.WriteString("vaultId", grant.VaultId); + writer.WriteEndObject(); + writer.Flush(); + } + return stream.ToArray(); + } + + private static byte[] SerializeCanonical(DeviceProfileV1 profile) + { + using var stream = new MemoryStream(); + using (var writer = CreateCanonicalWriter(stream)) + { + writer.WriteStartObject(); + writer.WriteString("displayName", profile.DisplayName); + writer.WriteString("platform", profile.Platform); + writer.WriteNumber("schemaVersion", profile.SchemaVersion); + writer.WriteEndObject(); + writer.Flush(); + } + return stream.ToArray(); + } + + private static Utf8JsonWriter CreateCanonicalWriter(Stream stream) + { + return new Utf8JsonWriter(stream, new JsonWriterOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); + } + + private static void WriteEncryptedRecord(Utf8JsonWriter writer, EncryptedSyncRecord record) + { + writer.WriteStartObject(); + writer.WriteString("ciphertext", record.Ciphertext); + writer.WriteString("ciphertextHash", record.CiphertextHash); + writer.WriteString("deviceId", record.DeviceId); + writer.WriteString("nonce", record.Nonce); + writer.WriteString("recordId", record.RecordId); + writer.WriteNumber("revision", record.Revision); + writer.WriteNumber("schemaVersion", record.SchemaVersion); + writer.WriteString("tag", record.Tag); + writer.WriteEndObject(); + } + + private static void WriteEncryptedPayload(Utf8JsonWriter writer, PairingEncryptedPayload payload) + { + writer.WriteStartObject(); + writer.WriteString("ciphertext", payload.Ciphertext); + writer.WriteString("nonce", payload.Nonce); + writer.WriteString("tag", payload.Tag); + writer.WriteEndObject(); + } + + private static void ValidateCoreSnapshot(CoreDaySnapshotV1 snapshot) + { + if (snapshot.SchemaVersion != SyncProtocol.SchemaVersion || + string.IsNullOrWhiteSpace(snapshot.DeviceId) || + snapshot.Revision < 0 || snapshot.KeyPresses < 0 || snapshot.Clicks == null || + snapshot.Clicks.Left < 0 || snapshot.Clicks.Right < 0 || snapshot.Clicks.Middle < 0 || + snapshot.Clicks.SideBack < 0 || snapshot.Clicks.SideForward < 0 || + snapshot.KeyPressCounts == null || snapshot.KeyPressCounts.Count > SyncProtocol.MaximumKeyEntries || + !DateTime.TryParseExact(snapshot.LocalDay, "yyyy-MM-dd", CultureInfo.InvariantCulture, + DateTimeStyles.None, out var parsedDay) || + !string.Equals(parsedDay.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), snapshot.LocalDay, + StringComparison.Ordinal)) + { + throw new InvalidDataException("Encrypted snapshot contains invalid counters."); + } + + var normalized = new Dictionary(StringComparer.Ordinal); + foreach (var pair in snapshot.KeyPressCounts.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + if (pair.Value < 0) + { + throw new InvalidDataException("Encrypted snapshot contains an invalid key counter."); + } + var key = SyncKeyCanonicalizer.Canonicalize(pair.Key, "windows"); + if (string.IsNullOrWhiteSpace(key) || + Encoding.UTF8.GetByteCount(key) > SyncProtocol.MaximumKeyNameBytes) + { + throw new InvalidDataException("Encrypted snapshot contains an invalid key counter."); + } + normalized[key] = SaturatingAdd( + normalized.TryGetValue(key, out var existing) ? existing : 0, + pair.Value); + } + if (normalized.Count > SyncProtocol.MaximumKeyEntries) + { + throw new InvalidDataException("Encrypted snapshot contains too many key counters."); + } + snapshot.KeyPressCounts = normalized; + if (SerializeCanonical(snapshot).Length > SyncProtocol.MaximumSnapshotBytes) + { + throw new InvalidDataException("Encrypted snapshot exceeds the size limit."); + } + } + + private static long SaturatingAdd(long left, long right) + { + if (left < 0 || right <= 0) return Math.Max(0, left); + return left > long.MaxValue - right ? long.MaxValue : left + right; + } + + private void CompletePreparedUploadsLocked(SyncRequest request) + { + foreach (var archive in request.Archives) + { + CompleteRecordLocked(archive, uploadedAsArchive: true); + } + if (request.CurrentSnapshot != null) + { + CompleteRecordLocked(request.CurrentSnapshot, uploadedAsArchive: false); + } + } + + private void CompleteRecordLocked(EncryptedSyncRecord record, bool uploadedAsArchive) + { + if (uploadedAsArchive) + { + if (string.Equals( + _state.LastAcknowledgedCurrentSnapshot?.RecordId, + record.RecordId, + StringComparison.Ordinal)) + { + _state.LastAcknowledgedCurrentSnapshot = null; + } + } + else + { + _state.LastAcknowledgedCurrentSnapshot = CloneEncryptedRecord(record); + } + + var state = _state.LocalRecords.Values.FirstOrDefault(item => + item.Revision == record.Revision && + string.Equals(item.Envelope.RecordId, record.RecordId, StringComparison.Ordinal) && + string.Equals(item.Envelope.CiphertextHash, record.CiphertextHash, StringComparison.Ordinal)); + if (state == null) return; + state.IsPending = false; + state.UploadedAsArchive = uploadedAsArchive; + } + + private static EncryptedSyncRecord CloneEncryptedRecord(EncryptedSyncRecord record) + { + return new EncryptedSyncRecord + { + SchemaVersion = record.SchemaVersion, + RecordId = record.RecordId, + DeviceId = record.DeviceId, + Revision = record.Revision, + Nonce = record.Nonce, + Ciphertext = record.Ciphertext, + Tag = record.Tag, + CiphertextHash = record.CiphertextHash + }; + } + + private void ScheduleForDeviceCountLocked(DateTime now) + { + if (_state.ActiveDeviceCount < 2) + { + _state.NextAutomaticSyncAtUtc = null; + _automaticTimer?.Change(Timeout.Infinite, Timeout.Infinite); + ScheduleStateRefreshLocked(now); + return; + } + _stateRefreshTimer?.Change(Timeout.Infinite, Timeout.Infinite); + EnsureAutomaticAttemptLocked(now); + ScheduleAutomaticTimerLocked(now); + } + + private void EnsureAutomaticAttemptLocked(DateTime now) + { + if (_state.NextAutomaticSyncAtUtc.HasValue) return; + if (_state.LastSuccessfulSyncAtUtc.HasValue) + { + var baseDue = _state.LastSuccessfulSyncAtUtc.Value + SyncProtocol.AutomaticSyncInterval; + _state.NextAutomaticSyncAtUtc = baseDue + GetDeterministicJitter(baseDue); + } + else + { + _state.NextAutomaticSyncAtUtc = now + GetDeterministicJitter(now.Date); + } + _stateStore.Save(_state); + } + + private void ScheduleNextSuccessfulAutomaticSyncLocked() + { + if (_state.ActiveDeviceCount < 2) + { + _state.NextAutomaticSyncAtUtc = null; + _automaticTimer?.Change(Timeout.Infinite, Timeout.Infinite); + ScheduleStateRefreshLocked(DateTime.UtcNow); + return; + } + var lastSuccess = _state.LastSuccessfulSyncAtUtc ?? DateTime.UtcNow; + var baseDue = lastSuccess + SyncProtocol.AutomaticSyncInterval; + _state.NextAutomaticSyncAtUtc = baseDue + GetDeterministicJitter(baseDue); + ScheduleAutomaticTimerLocked(DateTime.UtcNow); + } + + private void RecordAutomaticFailureLocked(DateTime? currentTimeUtc = null) + { + if (_state.ActiveDeviceCount < 2) return; + var now = (currentTimeUtc ?? DateTime.UtcNow).ToUniversalTime(); + var utcDay = UtcDay(now); + if (!string.Equals(_state.AutomaticFailureUtcDay, utcDay, StringComparison.Ordinal)) + { + _state.AutomaticFailureUtcDay = utcDay; + _state.AutomaticFailureCount = 0; + } + _state.AutomaticFailureCount = Math.Min( + SyncProtocol.MaximumAutomaticFailuresPerUtcDay, + _state.AutomaticFailureCount + 1); + + var retryAt = _state.AutomaticFailureCount >= SyncProtocol.MaximumAutomaticFailuresPerUtcDay + ? NextUtcMidnight(now) + GetDeterministicJitter(NextUtcMidnight(now)) + : _state.AutomaticFailureCount == 1 + ? now.AddHours(1) + : now.AddHours(6); + if (_state.NextAllowedSyncAtUtc.HasValue && _state.NextAllowedSyncAtUtc.Value > retryAt) + { + retryAt = _state.NextAllowedSyncAtUtc.Value; + } + _state.NextAutomaticSyncAtUtc = retryAt; + ScheduleAutomaticTimerLocked(now); + } + + private void RecordManualFailureLocked() + { + var now = DateTime.UtcNow; + var retryAt = now.AddMinutes(1); + if (!_state.NextAllowedSyncAtUtc.HasValue || _state.NextAllowedSyncAtUtc.Value < retryAt) + { + _state.NextAllowedSyncAtUtc = retryAt; + } + ScheduleAutomaticTimerLocked(now); + } + + private void TransitionToSingleDeviceLocked() + { + _state.ActiveDeviceCount = 1; + _state.NextAutomaticSyncAtUtc = null; + _automaticTimer?.Change(Timeout.Infinite, Timeout.Infinite); + ScheduleStateRefreshLocked(DateTime.UtcNow); + _lastError = null; + _stateStore.Save(_state); + } + + private void ScheduleStateRefreshLocked(DateTime now) + { + if (_transport == null || !_state.IsEnabled || _state.NeedsRepair || + _state.NeedsBootstrap || _state.NeedsHistoryBootstrap || + _state.NeedsStateRefreshBeforeBootstrap || _state.PendingVaultDeletion || + _state.ActiveDeviceCount >= 2) + { + _stateRefreshTimer?.Change(Timeout.Infinite, Timeout.Infinite); + return; + } + var refreshBase = _state.LastStateRefreshAtUtc ?? _state.LastSuccessfulSyncAtUtc ?? now; + var dueAt = refreshBase + StateRefreshInterval; + var due = dueAt <= now ? TimeSpan.Zero : dueAt - now; + if (due > TimeSpan.FromMilliseconds(int.MaxValue)) due = TimeSpan.FromMilliseconds(int.MaxValue); + _stateRefreshTimer ??= new Timer(_ => RunStateRefreshInBackground(), null, Timeout.Infinite, Timeout.Infinite); + _stateRefreshTimer.Change(due, Timeout.InfiniteTimeSpan); + } + + private void RunStateRefreshInBackground() + { + _ = Task.Run(async () => + { + try + { + await RefreshStateAsync(false, _lifetimeCancellation.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested) + { + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Sync state refresh failed: {SafeErrorMessage(ex)}"); + } + }); + } + + private static bool IsSingleDeviceSyncDisabled(SyncTransportException exception) + { + return exception.StatusCode == HttpStatusCode.Conflict && + string.Equals(exception.ErrorCode, "single_device_sync_disabled", StringComparison.Ordinal) && + exception.ActiveDeviceCount == 1; + } + + private static bool IsManualTransportFailure( + Exception exception, + CancellationToken cancellationToken) + { + return exception is SyncTransportException || + exception is HttpRequestException || + exception is IOException || + (exception is TaskCanceledException && !cancellationToken.IsCancellationRequested); + } + + private void ScheduleAutomaticTimerLocked(DateTime now) + { + if (_transport == null || _state.PendingVaultDeletion || _state.ActiveDeviceCount < 2 || + !_state.NextAutomaticSyncAtUtc.HasValue) + { + _automaticTimer?.Change(Timeout.Infinite, Timeout.Infinite); + return; + } + var dueAt = _state.NextAutomaticSyncAtUtc.Value; + RefreshUtcDayWindowsLocked(now); + if (_state.RemainingDailySyncs <= 0) + { + var nextQuotaWindow = NextUtcMidnight(now) + GetDeterministicJitter(NextUtcMidnight(now)); + if (nextQuotaWindow > dueAt) dueAt = nextQuotaWindow; + } + var allowedAt = GetEffectiveAllowedAtLocked(); + if (allowedAt > dueAt) dueAt = allowedAt; + var due = dueAt <= now ? TimeSpan.Zero : dueAt - now; + if (due > TimeSpan.FromMilliseconds(int.MaxValue)) due = TimeSpan.FromMilliseconds(int.MaxValue); + _automaticTimer ??= new Timer(_ => RunAutomaticSyncInBackground(), null, Timeout.Infinite, Timeout.Infinite); + _automaticTimer.Change(due, Timeout.InfiniteTimeSpan); + } + + private void RunAutomaticSyncInBackground() + { + _ = Task.Run(async () => + { + try + { + await SyncCoreAsync("automatic", bypassClientRateLimit: false, _lifetimeCancellation.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested) + { + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Automatic sync failed: {SafeErrorMessage(ex)}"); + } + }); + } + + private void RunBootstrapInBackground() + { + _ = Task.Run(async () => + { + try + { + await RetryBootstrapAsync(_lifetimeCancellation.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested) + { + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Sync bootstrap failed: {SafeErrorMessage(ex)}"); + } + }); + } + + private void RunPendingProvisioningInBackground() => RunBootstrapInBackground(); + + private void RunVaultDeletionInBackground() + { + _ = Task.Run(async () => + { + try + { + await DeleteVaultAsync(_lifetimeCancellation.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested) + { + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine( + $"Sync vault deletion retry failed: {SafeErrorMessage(ex)}"); + } + }); + } + + private void RunPairingRefreshInBackground(DateTime expiresAtUtc) + { + _ = Task.Run(async () => + { + var delayIndex = 0; + while (!_lifetimeCancellation.IsCancellationRequested) + { + var delay = PairingRefreshRetryDelays[Math.Min( + delayIndex, + PairingRefreshRetryDelays.Length - 1)]; + if (DateTime.UtcNow + delay >= expiresAtUtc) return; + + try + { + await Task.Delay(delay, _lifetimeCancellation.Token).ConfigureAwait(false); + await SyncCoreAsync( + "pairing", + bypassClientRateLimit: true, + cancellationToken: _lifetimeCancellation.Token, + pairingRefreshOnly: true).ConfigureAwait(false); + return; + } + catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested) + { + return; + } + catch (SyncTransportException ex) when (ex.StatusCode == HttpStatusCode.Conflict) + { + delayIndex++; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine( + $"Pairing refresh failed: {SafeErrorMessage(ex)}"); + return; + } + } + }); + } + + private TimeSpan GetDeterministicJitter(DateTime cycle) + { + string deviceId; + lock (_stateLock) deviceId = _state.DeviceId; + using var sha = SHA256.Create(); + var digest = sha.ComputeHash(Encoding.UTF8.GetBytes( + deviceId + "\n" + UtcDay(cycle))); + var value = ((int)digest[0] << 8) | digest[1]; + return TimeSpan.FromSeconds(value % 3601); + } + + private DateTime GetEffectiveAllowedAtLocked() + { + var allowedAt = _state.NextAllowedSyncAtUtc ?? DateTime.MinValue; + if (_state.LastSuccessfulSyncAtUtc.HasValue) + { + var localLimit = _state.LastSuccessfulSyncAtUtc.Value + SyncProtocol.ManualSyncInterval; + if (localLimit > allowedAt) allowedAt = localLimit; + } + return allowedAt; + } + + private void RefreshUtcDayWindowsLocked(DateTime now) + { + var utcDay = UtcDay(now); + if (!string.Equals(_state.QuotaUtcDay, utcDay, StringComparison.Ordinal)) + { + _state.QuotaUtcDay = utcDay; + _state.RemainingDailySyncs = 8; + } + if (!string.Equals(_state.AutomaticFailureUtcDay, utcDay, StringComparison.Ordinal)) + { + _state.AutomaticFailureUtcDay = utcDay; + _state.AutomaticFailureCount = 0; + } + } + + private static string UtcDay(DateTime value) + => value.ToUniversalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + + private static DateTime NextUtcMidnight(DateTime value) + => value.ToUniversalTime().Date.AddDays(1); + + private static DateTime? NormalizeUtc(DateTime? value) + { + if (!value.HasValue) return null; + return value.Value.Kind == DateTimeKind.Utc ? value : value.Value.ToUniversalTime(); + } + + private static string? ValidateReplacementDeviceId(string deviceId, string? replaceDeviceId) + { + if (string.IsNullOrWhiteSpace(replaceDeviceId)) return null; + if (!string.Equals(deviceId, replaceDeviceId, StringComparison.Ordinal)) + { + throw new CryptographicException( + "Recovery can only replace this installation's existing device identity."); + } + return replaceDeviceId; + } + + private static RecoverVaultRequest CreateRecoverRequest( + PendingSyncSecrets pending, + string? replaceDeviceId, + string recoveryAuthToken) + => new() + { + DeviceId = pending.DeviceId, + DeviceToken = pending.DeviceToken, + ReplaceDeviceId = ValidateReplacementDeviceId(pending.DeviceId, replaceDeviceId), + RecoveryAuthToken = recoveryAuthToken + }; + + private static string NormalizeProfileValue(string value, int maximumUtf8Bytes) + { + var normalized = (value ?? string.Empty).Trim(); + while (normalized.Length > 0 && Encoding.UTF8.GetByteCount(normalized) > maximumUtf8Bytes) + { + var charactersToRemove = normalized.Length >= 2 && + char.IsLowSurrogate(normalized[normalized.Length - 1]) && + char.IsHighSurrogate(normalized[normalized.Length - 2]) + ? 2 + : 1; + normalized = normalized.Substring(0, normalized.Length - charactersToRemove); + } + return string.IsNullOrWhiteSpace(normalized) ? "Windows device" : normalized; + } + + private SyncState NewEnabledState(string vaultId, string deviceId, string displayName, int activeDeviceCount) + { + var state = new SyncState + { + IsEnabled = true, + NeedsRepair = false, + NeedsBootstrap = true, + NeedsHistoryBootstrap = false, + VaultId = vaultId, + DeviceId = deviceId, + DeviceName = NormalizeProfileValue(displayName, 128), + InstallationDeviceId = deviceId, + Platform = "windows", + ActiveDeviceCount = Math.Max(1, activeDeviceCount), + RemainingDailySyncs = 8, + LocalRecords = new Dictionary(StringComparer.Ordinal) + }; + state.Devices.Add(CreateCurrentDeviceInfo(state)); + return state; + } + + private static SyncDeviceInfo CreateCurrentDeviceInfo(SyncState state) + { + return new SyncDeviceInfo + { + DeviceId = state.DeviceId, + DisplayName = state.DeviceName, + Platform = state.Platform, + LastSyncAt = state.LastSuccessfulSyncAtUtc, + IsCurrent = true, + IsRevoked = false + }; + } + + private static SyncDeviceInfo CloneDevice(SyncDeviceInfo value) + { + return new SyncDeviceInfo + { + DeviceId = value.DeviceId, + DisplayName = value.DisplayName, + Platform = value.Platform, + LastSyncAt = value.LastSyncAt, + IsCurrent = value.IsCurrent, + IsRevoked = value.IsRevoked + }; + } + + private static string NonEmptyProfileValue(string? preferred, string? fallback, string defaultValue) + { + var value = (preferred ?? string.Empty).Trim(); + if (value.Length > 0) return value; + value = (fallback ?? string.Empty).Trim(); + return value.Length > 0 ? value : defaultValue; + } + + private void PrepareDamagedStateForSetup() + { + lock (_stateLock) + { + if (!_stateStore.NeedsRepair) return; + var installationDeviceId = string.IsNullOrWhiteSpace(_state.InstallationDeviceId) + ? Guid.NewGuid().ToString("D") + : _state.InstallationDeviceId; + _stateStore.Delete(); + _state = new SyncState { InstallationDeviceId = installationDeviceId }; + _stateStore.Save(_state); + _lastError = null; + } + } + + private void DisableLocally(string? replacementCandidateDeviceId) + { + lock (_stateLock) + { + _automaticTimer?.Change(Timeout.Infinite, Timeout.Infinite); + _stateRefreshTimer?.Change(Timeout.Infinite, Timeout.Infinite); + _state = new SyncState + { + InstallationDeviceId = Guid.NewGuid().ToString("D"), + ReplacementCandidateDeviceId = Guid.TryParse(replacementCandidateDeviceId, out _) + ? replacementCandidateDeviceId + : null + }; + _lastError = null; + _stateStore.Delete(); + _credentialStore.Delete(); + _pendingSecretsStore.Delete(); + _stateStore.Save(_state); + } + _remoteCache.Clear(); + RaiseStatusChanged(); + } + + private ISyncTransport RequireTransport() + => _transport ?? throw new InvalidOperationException("Sync service is not configured in this build."); + + private SyncCredentials RequireCredentials() + { + string vaultId; + string deviceId; + lock (_stateLock) + { + vaultId = _state.VaultId; + deviceId = _state.DeviceId; + } + try + { + return _credentialStore.Load(vaultId, deviceId); + } + catch (CryptographicException) + { + lock (_stateLock) + { + _state.NeedsRepair = true; + _lastError = "The local sync configuration needs repair."; + if (!_stateStore.NeedsRepair) _stateStore.Save(_state); + } + RaiseStatusChanged(); + throw; + } + } + + private void EnsureEnabled() + { + lock (_stateLock) EnsureEnabledLocked(); + } + + private void EnsureEnabledLocked() + { + if (!_state.IsEnabled) throw new InvalidOperationException("Sync is not enabled on this device."); + if (_state.NeedsRepair) throw new InvalidOperationException("The local sync configuration needs repair."); + } + + private void EnsureNotEnabled() + { + lock (_stateLock) + { + if (_state.IsEnabled && !_state.NeedsRepair) + { + throw new InvalidOperationException("Sync is already enabled on this device."); + } + } + } + + private void EnsureCanCreate() + { + lock (_stateLock) + { + if (_state.IsEnabled || _state.NeedsRepair || _credentialStore.Exists) + { + throw new InvalidOperationException("Repair or clear the existing sync configuration first."); + } + } + } + + private void SaveRecoveredStateLocked() + { + if (_stateStore.NeedsRepair) _stateStore.ReplaceAfterRepair(_state); + else _stateStore.Save(_state); + } + + private bool IsLocalDevice(string deviceId) + { + lock (_stateLock) return string.Equals(deviceId, _state.DeviceId, StringComparison.Ordinal); + } + + private string GetVaultId() + { + lock (_stateLock) return _state.VaultId; + } + + private void OnRemoteCacheChanged() => _statsManager.NotifyRemoteStatsChanged(); + + private void OnLocalStatsReset() + { + lock (_stateLock) + { + foreach (var record in _state.LocalRecords.Values) record.ContentHash = string.Empty; + if (_state.IsEnabled && !_state.NeedsRepair) _stateStore.Save(_state); + } + } + + private void SetBusy(bool value) + { + lock (_stateLock) _isBusy = value; + RaiseStatusChanged(); + } + + private void RaiseStatusChanged() + { + try { StatusChanged?.Invoke(); } + catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"Sync status observer failed: {ex.Message}"); } + } + + private static string SafeErrorMessage(Exception exception) + { + return exception switch + { + SyncRateLimitedException => exception.Message, + SyncTransportException => exception.Message, + CryptographicException => "Encrypted sync data could not be verified.", + _ => "Sync could not be completed. Please try again later." + }; + } + + public void Dispose() + { + lock (_stateLock) + { + if (_disposed) return; + _disposed = true; + } + _lifetimeCancellation.Cancel(); + _automaticTimer?.Dispose(); + _stateRefreshTimer?.Dispose(); + _remoteCache.Changed -= OnRemoteCacheChanged; + _statsManager.LocalStatsReset -= OnLocalStatsReset; + if (_ownsTransport) _transport?.Dispose(); + _lifetimeCancellation.Dispose(); + _operationGate.Dispose(); + } + + private sealed class PreparedSync + { + public SyncRequest Request { get; set; } = new(); + public EncryptedSyncRecord? LastAcknowledgedCurrentSnapshot { get; set; } + } +} + +public sealed class SyncRateLimitedException : Exception +{ + public DateTime AllowedAtUtc { get; } + + public SyncRateLimitedException(DateTime allowedAtUtc, Exception? innerException = null) + : base("Sync is temporarily limited. Try again after " + + allowedAtUtc.ToLocalTime().ToString("g", CultureInfo.CurrentCulture) + ".", innerException) + { + AllowedAtUtc = allowedAtUtc; + } +} diff --git a/KeyStats.Windows/KeyStats/Services/SyncCredentialStore.cs b/KeyStats.Windows/KeyStats/Services/SyncCredentialStore.cs new file mode 100644 index 0000000..ff52f13 --- /dev/null +++ b/KeyStats.Windows/KeyStats/Services/SyncCredentialStore.cs @@ -0,0 +1,161 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using KeyStats.Models; + +namespace KeyStats.Services; + +public sealed class SyncCredentialStore +{ + private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("KeyStats.Sync.Credentials.v1"); + private readonly string _path; + private readonly object _lock = new(); + private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + public bool Exists => File.Exists(_path) || File.Exists(_path + ".bak"); + + public SyncCredentialStore(string dataFolder) + { + Directory.CreateDirectory(dataFolder); + _path = Path.Combine(dataFolder, "sync_credentials.bin"); + } + + public void Save(SyncCredentials credentials) + { + if (credentials.VaultSeed == null || credentials.VaultSeed.Length != 16 || + string.IsNullOrWhiteSpace(credentials.DeviceToken)) + { + throw new CryptographicException("Sync credentials are incomplete."); + } + var hasVaultBinding = !string.IsNullOrWhiteSpace(credentials.VaultId); + var hasDeviceBinding = !string.IsNullOrWhiteSpace(credentials.DeviceId); + if (hasVaultBinding != hasDeviceBinding || + (hasDeviceBinding && !IsTokenBoundTo(credentials.DeviceToken, credentials.DeviceId))) + { + throw new CryptographicException("Sync credential binding is inconsistent."); + } + + lock (_lock) + { + var plaintext = JsonSerializer.SerializeToUtf8Bytes(credentials, _jsonOptions); + var protectedBytes = ProtectedData.Protect(plaintext, Entropy, DataProtectionScope.CurrentUser); + SyncStateStore.WriteDurable(_path, protectedBytes); + } + } + + public SyncCredentials Load() + { + lock (_lock) + { + if (TryLoad(_path, out var credentials, out _)) return credentials; + + var backupPath = _path + ".bak"; + if (TryLoad(backupPath, out credentials, out var backupBytes)) + { + try + { + SyncStateStore.WriteDurable(_path, backupBytes); + return credentials; + } + catch (Exception ex) + { + throw new CryptographicException( + "The sync credential backup could not be restored for this Windows user.", ex); + } + } + + throw new CryptographicException("The sync credential cannot be unlocked for this Windows user."); + } + } + + public SyncCredentials Load(string expectedDeviceId) + => Load(string.Empty, expectedDeviceId); + + public SyncCredentials Load(string expectedVaultId, string expectedDeviceId) + { + if (string.IsNullOrWhiteSpace(expectedDeviceId)) + { + throw new CryptographicException("The sync device binding is missing."); + } + + var credentials = Load(); + if (!IsTokenBoundTo(credentials.DeviceToken, expectedDeviceId) || + (!string.IsNullOrWhiteSpace(credentials.DeviceId) && + !string.Equals(credentials.DeviceId, expectedDeviceId, StringComparison.Ordinal)) || + (!string.IsNullOrWhiteSpace(expectedVaultId) && + !string.IsNullOrWhiteSpace(credentials.VaultId) && + !string.Equals(credentials.VaultId, expectedVaultId, StringComparison.Ordinal))) + { + throw new CryptographicException( + "The sync credential belongs to a different device and must be repaired."); + } + if (string.IsNullOrWhiteSpace(credentials.DeviceId) || + (!string.IsNullOrWhiteSpace(expectedVaultId) && string.IsNullOrWhiteSpace(credentials.VaultId))) + { + credentials.DeviceId = expectedDeviceId; + credentials.VaultId = expectedVaultId; + Save(credentials); + } + return credentials; + } + + private static bool IsTokenBoundTo(string token, string deviceId) + { + var expectedPrefix = deviceId + "."; + return token.StartsWith(expectedPrefix, StringComparison.Ordinal) && + token.Length > expectedPrefix.Length; + } + + public void Delete() + { + lock (_lock) + { + TryDelete(_path); + TryDelete(_path + ".tmp"); + TryDelete(_path + ".bak"); + } + } + + private bool TryLoad(string path, out SyncCredentials credentials, out byte[] protectedBytes) + { + credentials = new SyncCredentials(); + protectedBytes = Array.Empty(); + if (!File.Exists(path)) return false; + try + { + protectedBytes = File.ReadAllBytes(path); + var plaintext = ProtectedData.Unprotect(protectedBytes, Entropy, DataProtectionScope.CurrentUser); + credentials = JsonSerializer.Deserialize(plaintext, _jsonOptions) + ?? throw new CryptographicException("Sync credentials are invalid."); + if (credentials.VaultSeed == null || credentials.VaultSeed.Length != 16 || + string.IsNullOrWhiteSpace(credentials.DeviceToken)) + { + throw new CryptographicException("Sync credentials are incomplete."); + } + return true; + } + catch (Exception ex) when ( + ex is IOException || ex is UnauthorizedAccessException || ex is JsonException || + ex is CryptographicException || ex is NotSupportedException) + { + System.Diagnostics.Debug.WriteLine($"Could not load sync credential {path}: {ex.Message}"); + credentials = new SyncCredentials(); + protectedBytes = Array.Empty(); + return false; + } + } + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) File.Delete(path); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Could not delete {path}: {ex.Message}"); + } + } +} diff --git a/KeyStats.Windows/KeyStats/Services/SyncCrypto.cs b/KeyStats.Windows/KeyStats/Services/SyncCrypto.cs new file mode 100644 index 0000000..276b0e7 --- /dev/null +++ b/KeyStats.Windows/KeyStats/Services/SyncCrypto.cs @@ -0,0 +1,481 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using KeyStats.Models; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Digests; +using Org.BouncyCastle.Crypto.Engines; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Modes; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Security; + +namespace KeyStats.Services; + +public sealed class SyncCrypto +{ + private const string CrockfordAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + private const string DataKeyLabel = "vault-encryption-v1"; + private const string IndexKeyLabel = "record-index-v1"; + private const string RecoveryAuthLabel = "recovery-auth-v1"; + private const string PairingWrapLabel = "pairing-wrap-v1"; + private static readonly byte[] RecoveryChecksumPrefix = Encoding.UTF8.GetBytes("keystats-recovery-checksum-v1"); + private static readonly byte[] PairingSafetyPrefix = Encoding.ASCII.GetBytes("pairing-safety-code-v1"); + private readonly SecureRandom _secureRandom = new(); + + public byte[] GenerateVaultSeed() + { + return GenerateRandomBytes(16); + } + + public byte[] GenerateRandomBytes(int length) + { + var bytes = new byte[length]; + _secureRandom.NextBytes(bytes); + return bytes; + } + + public string GenerateTokenSecret() + { + return Base64UrlEncode(GenerateRandomBytes(32)); + } + + public byte[] DeriveDataKey(byte[] vaultSeed) => DeriveKey(vaultSeed, DataKeyLabel); + public byte[] DeriveIndexKey(byte[] vaultSeed) => DeriveKey(vaultSeed, IndexKeyLabel); + public byte[] DeriveRecoveryAuth(byte[] vaultSeed) => DeriveKey(vaultSeed, RecoveryAuthLabel); + + public string CreateRecordId(byte[] indexKey, string deviceId, string localDay) + { + using var hmac = new HMACSHA256(indexKey); + var input = Encoding.UTF8.GetBytes(deviceId + "\n" + localDay); + return Base64UrlEncode(hmac.ComputeHash(input)); + } + + public EncryptedSyncRecord EncryptRecord( + byte[] dataKey, + string vaultId, + string deviceId, + string recordId, + long revision, + byte[] plaintext) + { + if (plaintext == null || plaintext.Length > SyncProtocol.MaximumSnapshotBytes) + { + throw new CryptographicException("Sync snapshot exceeds the encryption size limit."); + } + var nonce = GenerateRandomBytes(12); + var aad = CreateRecordAad(vaultId, deviceId, recordId, revision); + var encrypted = EncryptAesGcm(dataKey, nonce, plaintext, aad); + + return new EncryptedSyncRecord + { + SchemaVersion = SyncProtocol.SchemaVersion, + RecordId = recordId, + DeviceId = deviceId, + Revision = revision, + Nonce = Convert.ToBase64String(nonce), + Ciphertext = Convert.ToBase64String(encrypted.Ciphertext), + Tag = Convert.ToBase64String(encrypted.Tag), + CiphertextHash = Sha256Base64Url(nonce.Concat(encrypted.Ciphertext).Concat(encrypted.Tag).ToArray()) + }; + } + + public byte[] DecryptRecord(byte[] dataKey, string vaultId, EncryptedSyncRecord record) + { + if (record.SchemaVersion != SyncProtocol.SchemaVersion || record.Revision <= 0) + { + throw new CryptographicException("Unsupported encrypted record."); + } + + var nonce = DecodeBase64(record.Nonce, 12, "nonce"); + var ciphertext = DecodeBase64(record.Ciphertext, null, "ciphertext"); + if (ciphertext.Length > SyncProtocol.MaximumSnapshotBytes) + { + throw new CryptographicException("Encrypted snapshot exceeds the size limit."); + } + var tag = DecodeBase64(record.Tag, 16, "tag"); + var expectedHash = Sha256Base64Url(nonce.Concat(ciphertext).Concat(tag).ToArray()); + if (!FixedTimeEquals(expectedHash, record.CiphertextHash)) + { + throw new CryptographicException("Encrypted record hash mismatch."); + } + + var aad = CreateRecordAad(vaultId, record.DeviceId, record.RecordId, record.Revision); + return DecryptAesGcm(dataKey, nonce, ciphertext, tag, aad); + } + + public PairingSessionContext CreatePairingContext(string proposedDeviceId) + { + var privateKey = new X25519PrivateKeyParameters(_secureRandom); + var publicKey = privateKey.GeneratePublicKey(); + return new PairingSessionContext + { + PrivateKey = privateKey.GetEncoded(), + PublicKey = publicKey.GetEncoded(), + ProposedDeviceId = proposedDeviceId + }; + } + + public byte[] DerivePairingWrapKey(byte[] privateKeyBytes, byte[] peerPublicKeyBytes) + { + if (privateKeyBytes.Length != 32 || peerPublicKeyBytes.Length != 32) + { + throw new CryptographicException("Invalid X25519 key material."); + } + + var privateKey = new X25519PrivateKeyParameters(privateKeyBytes, 0); + var publicKey = new X25519PublicKeyParameters(peerPublicKeyBytes, 0); + var sharedSecret = new byte[32]; + privateKey.GenerateSecret(publicKey, sharedSecret, 0); + return DeriveKey(sharedSecret, PairingWrapLabel); + } + + public string CreatePairingSafetyCode( + byte[] localPublicKey, + byte[] peerPublicKey, + byte[] privateKey) + { + var localFirst = CompareBytes(localPublicKey, peerPublicKey) <= 0; + var first = localFirst ? localPublicKey : peerPublicKey; + var second = localFirst ? peerPublicKey : localPublicKey; + + var privateParameters = new X25519PrivateKeyParameters(privateKey, 0); + var peerParameters = new X25519PublicKeyParameters(peerPublicKey, 0); + var sharedSecret = new byte[32]; + privateParameters.GenerateSecret(peerParameters, sharedSecret, 0); + + var input = new byte[PairingSafetyPrefix.Length + first.Length + second.Length + sharedSecret.Length]; + var offset = 0; + Buffer.BlockCopy(PairingSafetyPrefix, 0, input, offset, PairingSafetyPrefix.Length); + offset += PairingSafetyPrefix.Length; + Buffer.BlockCopy(first, 0, input, offset, first.Length); + offset += first.Length; + Buffer.BlockCopy(second, 0, input, offset, second.Length); + offset += second.Length; + Buffer.BlockCopy(sharedSecret, 0, input, offset, sharedSecret.Length); + + using var sha = SHA256.Create(); + var digest = sha.ComputeHash(input); + var number = ((uint)digest[0] << 24) | + ((uint)digest[1] << 16) | + ((uint)digest[2] << 8) | + digest[3]; + return (number % 1_000_000).ToString("D6", CultureInfo.InvariantCulture); + } + + public PairingEncryptedPayload EncryptPairingPayload(byte[] wrapKey, string sessionId, byte[] plaintext) + { + var nonce = GenerateRandomBytes(12); + var aad = Encoding.UTF8.GetBytes(SyncProtocol.SchemaVersion + "\n" + sessionId); + var encrypted = EncryptAesGcm(wrapKey, nonce, plaintext, aad); + return new PairingEncryptedPayload + { + Nonce = Convert.ToBase64String(nonce), + Ciphertext = Convert.ToBase64String(encrypted.Ciphertext), + Tag = Convert.ToBase64String(encrypted.Tag) + }; + } + + public PairingEncryptedPayload EncryptDeviceProfile( + byte[] dataKey, + string vaultId, + string deviceId, + byte[] plaintext) + { + var nonce = GenerateRandomBytes(12); + var aad = Encoding.UTF8.GetBytes("profile-v1\n" + vaultId + "\n" + deviceId); + var encrypted = EncryptAesGcm(dataKey, nonce, plaintext, aad); + return new PairingEncryptedPayload + { + Nonce = Convert.ToBase64String(nonce), + Ciphertext = Convert.ToBase64String(encrypted.Ciphertext), + Tag = Convert.ToBase64String(encrypted.Tag) + }; + } + + public byte[] DecryptDeviceProfile( + byte[] dataKey, + string vaultId, + string deviceId, + PairingEncryptedPayload payload) + { + var nonce = DecodeBase64(payload.Nonce, 12, "nonce"); + var ciphertext = DecodeBase64(payload.Ciphertext, null, "ciphertext"); + var tag = DecodeBase64(payload.Tag, 16, "tag"); + var aad = Encoding.UTF8.GetBytes("profile-v1\n" + vaultId + "\n" + deviceId); + return DecryptAesGcm(dataKey, nonce, ciphertext, tag, aad); + } + + public byte[] DecryptPairingPayload(byte[] wrapKey, string sessionId, PairingEncryptedPayload payload) + { + var nonce = DecodeBase64(payload.Nonce, 12, "nonce"); + var ciphertext = DecodeBase64(payload.Ciphertext, null, "ciphertext"); + var tag = DecodeBase64(payload.Tag, 16, "tag"); + var aad = Encoding.UTF8.GetBytes(SyncProtocol.SchemaVersion + "\n" + sessionId); + return DecryptAesGcm(wrapKey, nonce, ciphertext, tag, aad); + } + + public string EncodeRecoveryCode(byte[] seed) + { + if (seed.Length != 16) + { + throw new ArgumentException("Recovery seed must be 16 bytes.", nameof(seed)); + } + + var seedPart = EncodeCrockford(seed, 26); + var checksumInput = new byte[RecoveryChecksumPrefix.Length + seed.Length]; + Buffer.BlockCopy(RecoveryChecksumPrefix, 0, checksumInput, 0, RecoveryChecksumPrefix.Length); + Buffer.BlockCopy(seed, 0, checksumInput, RecoveryChecksumPrefix.Length, seed.Length); + using var sha = SHA256.Create(); + var digest = sha.ComputeHash(checksumInput); + var checksumValue = ((digest[0] << 8) | digest[1]) >> 6; + var checksum = string.Concat( + CrockfordAlphabet[(checksumValue >> 5) & 31], + CrockfordAlphabet[checksumValue & 31]); + return GroupRecoveryCode(seedPart + checksum); + } + + public bool TryDecodeRecoveryCode(string? recoveryCode, out byte[] seed) + { + seed = Array.Empty(); + var normalized = NormalizeRecoveryCode(recoveryCode); + if (normalized.Length != 28) + { + return false; + } + + if (!TryDecodeCrockford(normalized.Substring(0, 26), 16, out var decodedSeed)) + { + return false; + } + + var expected = NormalizeRecoveryCode(EncodeRecoveryCode(decodedSeed)); + if (!FixedTimeEquals(expected, normalized)) + { + return false; + } + + seed = decodedSeed; + return true; + } + + public static string Sha256Base64(byte[] value) + { + using var sha = SHA256.Create(); + return Convert.ToBase64String(sha.ComputeHash(value)); + } + + public static string Sha256Base64Url(byte[] value) + { + using var sha = SHA256.Create(); + return Base64UrlEncode(sha.ComputeHash(value)); + } + + public static string Sha256Base64Url(string value) + { + using var sha = SHA256.Create(); + return Base64UrlEncode(sha.ComputeHash(Encoding.UTF8.GetBytes(value))); + } + + public static string Base64UrlEncode(byte[] value) + { + return Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + + private static byte[] CreateRecordAad( + string vaultId, + string deviceId, + string recordId, + long revision) + { + return Encoding.UTF8.GetBytes( + SyncProtocol.SchemaVersion + "\n" + + vaultId + "\n" + + deviceId + "\n" + + recordId + "\n" + + revision.ToString(CultureInfo.InvariantCulture)); + } + + private static byte[] DeriveKey(byte[] inputKeyMaterial, string info) + { + var generator = new HkdfBytesGenerator(new Sha256Digest()); + generator.Init(new HkdfParameters(inputKeyMaterial, Array.Empty(), Encoding.ASCII.GetBytes(info))); + var result = new byte[32]; + generator.GenerateBytes(result, 0, result.Length); + return result; + } + + private static (byte[] Ciphertext, byte[] Tag) EncryptAesGcm( + byte[] key, + byte[] nonce, + byte[] plaintext, + byte[] aad) + { + var cipher = new GcmBlockCipher(new AesEngine()); + cipher.Init(true, new AeadParameters(new KeyParameter(key), 128, nonce, aad)); + var output = new byte[cipher.GetOutputSize(plaintext.Length)]; + var length = cipher.ProcessBytes(plaintext, 0, plaintext.Length, output, 0); + length += cipher.DoFinal(output, length); + if (length < 16) + { + throw new CryptographicException("AES-GCM did not produce an authentication tag."); + } + + var ciphertextLength = length - 16; + var ciphertext = new byte[ciphertextLength]; + var tag = new byte[16]; + Buffer.BlockCopy(output, 0, ciphertext, 0, ciphertextLength); + Buffer.BlockCopy(output, ciphertextLength, tag, 0, tag.Length); + return (ciphertext, tag); + } + + private static byte[] DecryptAesGcm( + byte[] key, + byte[] nonce, + byte[] ciphertext, + byte[] tag, + byte[] aad) + { + var input = new byte[ciphertext.Length + tag.Length]; + Buffer.BlockCopy(ciphertext, 0, input, 0, ciphertext.Length); + Buffer.BlockCopy(tag, 0, input, ciphertext.Length, tag.Length); + + try + { + var cipher = new GcmBlockCipher(new AesEngine()); + cipher.Init(false, new AeadParameters(new KeyParameter(key), 128, nonce, aad)); + var output = new byte[cipher.GetOutputSize(input.Length)]; + var length = cipher.ProcessBytes(input, 0, input.Length, output, 0); + length += cipher.DoFinal(output, length); + if (length == output.Length) + { + return output; + } + + return output.Take(length).ToArray(); + } + catch (InvalidCipherTextException ex) + { + throw new CryptographicException("AES-GCM authentication failed.", ex); + } + } + + private static byte[] DecodeBase64(string value, int? expectedLength, string label) + { + try + { + var decoded = Convert.FromBase64String(value); + if (expectedLength.HasValue && decoded.Length != expectedLength.Value) + { + throw new CryptographicException($"Invalid {label} length."); + } + return decoded; + } + catch (FormatException ex) + { + throw new CryptographicException($"Invalid {label} encoding.", ex); + } + } + + private static int CompareBytes(byte[] left, byte[] right) + { + var length = Math.Min(left.Length, right.Length); + for (var i = 0; i < length; i++) + { + var comparison = left[i].CompareTo(right[i]); + if (comparison != 0) return comparison; + } + return left.Length.CompareTo(right.Length); + } + + private static bool FixedTimeEquals(string left, string right) + { + var leftBytes = Encoding.ASCII.GetBytes(left ?? string.Empty); + var rightBytes = Encoding.ASCII.GetBytes(right ?? string.Empty); + var difference = leftBytes.Length ^ rightBytes.Length; + var length = Math.Max(leftBytes.Length, rightBytes.Length); + for (var i = 0; i < length; i++) + { + var leftValue = i < leftBytes.Length ? leftBytes[i] : (byte)0; + var rightValue = i < rightBytes.Length ? rightBytes[i] : (byte)0; + difference |= leftValue ^ rightValue; + } + return difference == 0; + } + + private static string EncodeCrockford(byte[] bytes, int outputLength) + { + var builder = new StringBuilder(outputLength); + var accumulator = 0; + var bitCount = 0; + foreach (var value in bytes) + { + accumulator = (accumulator << 8) | value; + bitCount += 8; + while (bitCount >= 5) + { + bitCount -= 5; + builder.Append(CrockfordAlphabet[(accumulator >> bitCount) & 31]); + accumulator &= (1 << bitCount) - 1; + } + } + + if (bitCount > 0) + { + builder.Append(CrockfordAlphabet[(accumulator << (5 - bitCount)) & 31]); + } + + return builder.ToString().PadRight(outputLength, '0').Substring(0, outputLength); + } + + private static bool TryDecodeCrockford(string value, int byteLength, out byte[] bytes) + { + bytes = new byte[byteLength]; + var accumulator = 0; + var bitCount = 0; + var outputIndex = 0; + foreach (var character in value) + { + var index = CrockfordAlphabet.IndexOf(character); + if (index < 0) + { + bytes = Array.Empty(); + return false; + } + + accumulator = (accumulator << 5) | index; + bitCount += 5; + while (bitCount >= 8 && outputIndex < byteLength) + { + bitCount -= 8; + bytes[outputIndex++] = (byte)((accumulator >> bitCount) & 0xff); + accumulator &= (1 << bitCount) - 1; + } + } + + return outputIndex == byteLength && accumulator == 0; + } + + private static string NormalizeRecoveryCode(string? value) + { + var builder = new StringBuilder(); + foreach (var raw in (value ?? string.Empty).ToUpperInvariant()) + { + if (raw == '-' || char.IsWhiteSpace(raw)) continue; + builder.Append(raw switch + { + 'O' => '0', + 'I' or 'L' => '1', + _ => raw + }); + } + return builder.ToString(); + } + + private static string GroupRecoveryCode(string normalized) + { + return string.Join("-", Enumerable.Range(0, 7).Select(index => normalized.Substring(index * 4, 4))); + } +} diff --git a/KeyStats.Windows/KeyStats/Services/SyncKeyCanonicalizer.cs b/KeyStats.Windows/KeyStats/Services/SyncKeyCanonicalizer.cs new file mode 100644 index 0000000..41ab736 --- /dev/null +++ b/KeyStats.Windows/KeyStats/Services/SyncKeyCanonicalizer.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace KeyStats.Services; + +internal static class SyncKeyCanonicalizer +{ + private static readonly Regex FunctionKeyPattern = new("^F[0-9]{1,2}$", RegexOptions.CultureInvariant); + + public static string Canonicalize(string? rawName, string platform) + { + var trimmed = (rawName ?? string.Empty).Trim(); + if (trimmed.Length == 0) return string.Empty; + if (trimmed == "+") return "+"; + + var source = trimmed; + var hasLiteralPlus = source.EndsWith("++", StringComparison.Ordinal); + if (hasLiteralPlus) source = source.Substring(0, source.Length - 1); + + var normalized = new List(); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var rawPart in source.Split(new[] { '+' }, StringSplitOptions.RemoveEmptyEntries)) + { + var part = CanonicalPart(rawPart, platform); + if (part.Length > 0 && seen.Add(part)) normalized.Add(part); + } + if (hasLiteralPlus && seen.Add("+")) normalized.Add("+"); + return string.Join("+", normalized); + } + + private static string CanonicalPart(string raw, string platform) + { + var value = (raw ?? string.Empty).Trim(); + var upper = value.ToUpperInvariant(); + switch (upper) + { + case "ESC": case "ESCAPE": return "Esc"; + case "RETURN": case "ENTER": case "NUMPADENTER": return "Enter"; + case "BACKSPACE": case "BS": return "Backspace"; + case "DELETE": case "DEL": case "FORWARDDELETE": return "Delete"; + case "LEFT": case "ARROWLEFT": case "LEFTARROW": return "Left"; + case "RIGHT": case "ARROWRIGHT": case "RIGHTARROW": return "Right"; + case "UP": case "ARROWUP": case "UPARROW": return "Up"; + case "DOWN": case "ARROWDOWN": case "DOWNARROW": return "Down"; + case "COMMAND": case "CMD": return "Cmd"; + case "LEFTCOMMAND": case "LEFTCMD": return "LeftCmd"; + case "RIGHTCOMMAND": case "RIGHTCMD": return "RightCmd"; + case "WINDOWS": case "WIN": case "META": return "Win"; + case "LEFTWINDOWS": case "LEFTWIN": return "LeftWin"; + case "RIGHTWINDOWS": case "RIGHTWIN": return "RightWin"; + case "OPTION": return "Option"; + case "LEFTOPTION": return "LeftOption"; + case "RIGHTOPTION": return "RightOption"; + case "ALT": return "Alt"; + case "LEFTALT": return "LeftAlt"; + case "RIGHTALT": return "RightAlt"; + case "CONTROL": case "CTRL": return "Ctrl"; + case "LEFTCONTROL": case "LEFTCTRL": return "LeftCtrl"; + case "RIGHTCONTROL": case "RIGHTCTRL": return "RightCtrl"; + case "SHIFT": return "Shift"; + case "LEFTSHIFT": return "LeftShift"; + case "RIGHTSHIFT": return "RightShift"; + case "FN": case "FUNCTION": case "GLOBE": case "🌐": case "KEY63": case "KEY179": return "Fn"; + case "SPACE": case "SPACEBAR": return "Space"; + case "TAB": return "Tab"; + case "CAPS": case "CAPSLOCK": return "CapsLock"; + case "INSERT": case "INS": case "HELP": return "Insert"; + case "PAGEUP": return "PageUp"; + case "PAGEDOWN": return "PageDown"; + case "HOME": return "Home"; + case "END": return "End"; + case "PRINTSCREEN": case "PRTSC": case "PRTSCN": case "SNAPSHOT": return "PrintScreen"; + case "SCROLLLOCK": case "SCROLL": return "ScrollLock"; + case "PAUSE": case "BREAK": return "Pause"; + case "+": return "+"; + } + + if (StringInfo.ParseCombiningCharacters(value).Length == 1 || FunctionKeyPattern.IsMatch(upper)) return upper; + if ((value.StartsWith("mac:", StringComparison.Ordinal) && value.Length > 4) || + (value.StartsWith("macos:", StringComparison.Ordinal) && value.Length > 6) || + (value.StartsWith("windows:", StringComparison.Ordinal) && value.Length > 8)) + { + return value; + } + return platform + ":" + value; + } +} diff --git a/KeyStats.Windows/KeyStats/Services/SyncPendingSecretsStore.cs b/KeyStats.Windows/KeyStats/Services/SyncPendingSecretsStore.cs new file mode 100644 index 0000000..a55a3d3 --- /dev/null +++ b/KeyStats.Windows/KeyStats/Services/SyncPendingSecretsStore.cs @@ -0,0 +1,155 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using KeyStats.Models; + +namespace KeyStats.Services; + +/// +/// Persists an in-flight create, recovery, or pairing credential bundle with +/// Windows DPAPI so an accepted request can be replayed exactly after a lost +/// response without exposing seeds, tokens, or X25519 private keys. +/// +public sealed class SyncPendingSecretsStore +{ + private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("KeyStats.Sync.Pending.v1"); + private readonly string _path; + private readonly object _lock = new(); + private readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public bool Exists => File.Exists(_path) || File.Exists(_path + ".bak"); + + public SyncPendingSecretsStore(string dataFolder) + { + Directory.CreateDirectory(dataFolder); + _path = Path.Combine(dataFolder, "sync_pending_credentials.bin"); + } + + public void Save(PendingSyncSecrets value) + { + Validate(value); + lock (_lock) + { + var plaintext = JsonSerializer.SerializeToUtf8Bytes(value, _jsonOptions); + var protectedBytes = ProtectedData.Protect(plaintext, Entropy, DataProtectionScope.CurrentUser); + SyncStateStore.WriteDurable(_path, protectedBytes); + } + } + + public PendingSyncSecrets Load() + { + lock (_lock) + { + if (TryLoad(_path, out var value, out _)) return value; + var backupPath = _path + ".bak"; + if (TryLoad(backupPath, out value, out var protectedBytes)) + { + SyncStateStore.WriteDurable(_path, protectedBytes); + return value; + } + throw new CryptographicException("Pending sync credentials cannot be unlocked for this Windows user."); + } + } + + public void Delete() + { + lock (_lock) + { + TryDelete(_path); + TryDelete(_path + ".tmp"); + TryDelete(_path + ".bak"); + } + } + + private bool TryLoad(string path, out PendingSyncSecrets value, out byte[] protectedBytes) + { + value = new PendingSyncSecrets(); + protectedBytes = Array.Empty(); + if (!File.Exists(path)) return false; + try + { + protectedBytes = File.ReadAllBytes(path); + var plaintext = ProtectedData.Unprotect(protectedBytes, Entropy, DataProtectionScope.CurrentUser); + value = JsonSerializer.Deserialize(plaintext, _jsonOptions) + ?? throw new CryptographicException("Pending sync credentials are empty."); + Validate(value); + return true; + } + catch (Exception ex) when ( + ex is IOException || ex is UnauthorizedAccessException || ex is JsonException || + ex is CryptographicException || ex is NotSupportedException) + { + System.Diagnostics.Debug.WriteLine($"Could not load pending sync credentials {path}: {ex.Message}"); + value = new PendingSyncSecrets(); + protectedBytes = Array.Empty(); + return false; + } + } + + private static void Validate(PendingSyncSecrets value) + { + if (value.Version != 1 || string.IsNullOrWhiteSpace(value.Kind) || + string.IsNullOrWhiteSpace(value.DeviceId)) + { + throw new CryptographicException("Pending sync credential metadata is incomplete."); + } + if (value.Kind == "create" || value.Kind == "recover" || value.Kind == "pairing-final") + { + var prefix = value.DeviceId + "."; + if (value.VaultSeed == null || value.VaultSeed.Length != 16 || + string.IsNullOrWhiteSpace(value.DeviceToken) || + !value.DeviceToken.StartsWith(prefix, StringComparison.Ordinal) || + value.DeviceToken.Length <= prefix.Length) + { + throw new CryptographicException("Pending sync credentials are incomplete."); + } + if ((value.Kind == "create" || value.Kind == "pairing-final") && + string.IsNullOrWhiteSpace(value.VaultId)) + { + throw new CryptographicException("Pending sync vault binding is missing."); + } + if (!string.IsNullOrWhiteSpace(value.ReplaceDeviceId) && + !string.Equals(value.ReplaceDeviceId, value.DeviceId, StringComparison.Ordinal)) + { + throw new CryptographicException("Pending recovery replacement binding is inconsistent."); + } + if (value.Kind == "pairing-final" && + (string.IsNullOrWhiteSpace(value.PairingSessionId) || + string.IsNullOrWhiteSpace(value.PairingCompletionToken) || + value.PairingPrivateKey == null || value.PairingPrivateKey.Length != 32 || + value.PairingPublicKey == null || value.PairingPublicKey.Length != 32)) + { + throw new CryptographicException("Pending pairing completion credentials are incomplete."); + } + } + if (value.Kind == "pairing") + { + if (value.PairingPrivateKey == null || value.PairingPrivateKey.Length != 32 || + value.PairingPublicKey == null || value.PairingPublicKey.Length != 32 || + string.IsNullOrWhiteSpace(value.PairingSessionId) || + string.IsNullOrWhiteSpace(value.PairingCode) || + string.IsNullOrWhiteSpace(value.PairingCompletionToken) || + !value.PairingExpiresAt.HasValue) + { + throw new CryptographicException("Pending pairing credentials are incomplete."); + } + } + } + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) File.Delete(path); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Could not delete pending sync credential {path}: {ex.Message}"); + } + } +} diff --git a/KeyStats.Windows/KeyStats/Services/SyncServiceConfiguration.cs b/KeyStats.Windows/KeyStats/Services/SyncServiceConfiguration.cs new file mode 100644 index 0000000..887437d --- /dev/null +++ b/KeyStats.Windows/KeyStats/Services/SyncServiceConfiguration.cs @@ -0,0 +1,58 @@ +using System; +using System.Linq; +using System.Reflection; + +namespace KeyStats.Services; + +public static class SyncServiceConfiguration +{ + public static string ConfiguredBaseUrl + { + get + { + var value = typeof(SyncServiceConfiguration).Assembly + .GetCustomAttributes() + .FirstOrDefault(attribute => string.Equals( + attribute.Key, + "SyncServiceBaseUrl", + StringComparison.Ordinal)) + ?.Value; + + return Normalize(value); + } + } + + public static bool TryCreateBaseUri(out Uri? baseUri) + { + var candidate = ConfiguredBaseUrl; + + if (!Uri.TryCreate(candidate, UriKind.Absolute, out var parsed) || + !string.Equals(parsed.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || + string.IsNullOrWhiteSpace(parsed.Host) || + !parsed.Host.EndsWith(".workers.dev", StringComparison.OrdinalIgnoreCase) || + !string.IsNullOrEmpty(parsed.UserInfo) || + !string.IsNullOrEmpty(parsed.Query) || + !string.IsNullOrEmpty(parsed.Fragment) || + candidate.IndexOf('<') >= 0 || + candidate.IndexOf("example", StringComparison.OrdinalIgnoreCase) >= 0) + { + baseUri = null; + return false; + } + + baseUri = new Uri(parsed.AbsoluteUri.TrimEnd('/') + "/", UriKind.Absolute); + return true; + } + + private static string Normalize(string? value) + { + var normalized = (value ?? string.Empty).Trim(); + if (string.Equals(normalized, "$(SyncServiceBaseUrl)", StringComparison.Ordinal) || + normalized.IndexOf("REPLACE_ME", StringComparison.OrdinalIgnoreCase) >= 0) + { + return string.Empty; + } + + return normalized; + } +} diff --git a/KeyStats.Windows/KeyStats/Services/SyncStateStore.cs b/KeyStats.Windows/KeyStats/Services/SyncStateStore.cs new file mode 100644 index 0000000..aca8cf9 --- /dev/null +++ b/KeyStats.Windows/KeyStats/Services/SyncStateStore.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Json; +using KeyStats.Models; + +namespace KeyStats.Services; + +public sealed class SyncStateStore +{ + private readonly string _path; + private readonly object _lock = new(); + private readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + public bool NeedsRepair { get; private set; } + public bool RecoveredFromBackup { get; private set; } + + public SyncStateStore(string dataFolder) + { + Directory.CreateDirectory(dataFolder); + _path = Path.Combine(dataFolder, "sync_state.json"); + } + + public SyncState Load() + { + lock (_lock) + { + NeedsRepair = false; + RecoveredFromBackup = false; + var backupPath = _path + ".bak"; + if (TryLoad(_path, out var state, out _)) + { + return state; + } + + var primaryExists = File.Exists(_path); + if (TryLoad(backupPath, out state, out var backupBytes)) + { + try + { + // File.Replace keeps the unreadable primary as .bak, so recovery does + // not discard the evidence that the primary file was damaged. + WriteDurable(_path, backupBytes); + RecoveredFromBackup = true; + return state; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Could not restore sync state backup: {ex.Message}"); + } + } + + if (!primaryExists && !File.Exists(backupPath)) return NewState(); + + NeedsRepair = true; + var repairState = NewState(); + repairState.NeedsRepair = true; + return repairState; + } + } + + public void Save(SyncState state) + { + lock (_lock) + { + if (NeedsRepair) + { + throw new InvalidOperationException("Damaged sync state must be repaired explicitly before saving."); + } + Normalize(state); + var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(state, _jsonOptions)); + WriteDurable(_path, bytes); + } + } + + public void ReplaceAfterRepair(SyncState state) + { + lock (_lock) + { + Normalize(state); + if (!state.IsEnabled || state.NeedsRepair || string.IsNullOrWhiteSpace(state.VaultId) || + string.IsNullOrWhiteSpace(state.DeviceId)) + { + throw new InvalidOperationException("Replacement sync state is incomplete."); + } + var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(state, _jsonOptions)); + WriteDurable(_path, bytes); + NeedsRepair = false; + } + } + + public void Delete() + { + lock (_lock) + { + TryDelete(_path); + TryDelete(_path + ".tmp"); + TryDelete(_path + ".bak"); + NeedsRepair = false; + RecoveredFromBackup = false; + } + } + + internal static void WriteDurable(string path, byte[] bytes) + { + var tempPath = path + ".tmp"; + var backupPath = path + ".bak"; + using (var stream = new FileStream( + tempPath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + 4096, + FileOptions.WriteThrough)) + { + stream.Write(bytes, 0, bytes.Length); + stream.Flush(true); + } + + if (File.Exists(path)) + { + File.Replace(tempPath, path, backupPath); + } + else + { + File.Move(tempPath, path); + } + } + + private static SyncState NewState() + { + return new SyncState + { + LocalRecords = new Dictionary(StringComparer.Ordinal), + Devices = new List() + }; + } + + private bool TryLoad(string path, out SyncState state, out byte[] bytes) + { + state = NewState(); + bytes = Array.Empty(); + if (!File.Exists(path)) return false; + try + { + bytes = File.ReadAllBytes(path); + state = JsonSerializer.Deserialize(bytes, _jsonOptions) + ?? throw new JsonException("Sync state was empty."); + Normalize(state); + if (state.Version != 1 || + (state.IsEnabled && + (string.IsNullOrWhiteSpace(state.VaultId) || + string.IsNullOrWhiteSpace(state.DeviceId) || + state.ActiveDeviceCount < 1))) + { + throw new JsonException("Sync state metadata is incomplete."); + } + return true; + } + catch (Exception ex) when ( + ex is IOException || ex is UnauthorizedAccessException || ex is JsonException || + ex is NotSupportedException) + { + System.Diagnostics.Debug.WriteLine($"Could not load sync state {path}: {ex.Message}"); + state = NewState(); + bytes = Array.Empty(); + return false; + } + } + + private static void Normalize(SyncState state) + { + state.LocalRecords ??= new Dictionary(StringComparer.Ordinal); + state.Devices ??= new List(); + state.DeviceName ??= string.Empty; + state.DeviceId ??= string.Empty; + state.VaultId ??= string.Empty; + state.InstallationDeviceId ??= string.Empty; + state.ReplacementCandidateDeviceId = string.IsNullOrWhiteSpace(state.ReplacementCandidateDeviceId) + ? null + : state.ReplacementCandidateDeviceId; + state.PendingProvisioningKind = string.IsNullOrWhiteSpace(state.PendingProvisioningKind) + ? null + : state.PendingProvisioningKind; + state.PendingProvisioningDisplayName ??= string.Empty; + state.Platform = "windows"; + state.ActiveDeviceCount = Math.Max(0, state.ActiveDeviceCount); + state.HistoryCursor = Math.Max(0, state.HistoryCursor); + state.RemainingDailySyncs = Math.Max(0, state.RemainingDailySyncs); + state.AutomaticFailureCount = Math.Max(0, state.AutomaticFailureCount); + } + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) File.Delete(path); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Could not delete {path}: {ex.Message}"); + } + } +} diff --git a/KeyStats.Windows/KeyStats/Services/SyncTransport.cs b/KeyStats.Windows/KeyStats/Services/SyncTransport.cs new file mode 100644 index 0000000..29e1dc9 --- /dev/null +++ b/KeyStats.Windows/KeyStats/Services/SyncTransport.cs @@ -0,0 +1,411 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using KeyStats.Models; + +namespace KeyStats.Services; + +public interface ISyncTransport : IDisposable +{ + Task CreateVaultAsync(CreateVaultRequest request, CancellationToken cancellationToken); + Task RecoverVaultAsync(RecoverVaultRequest request, CancellationToken cancellationToken); + Task CreatePairingSessionAsync(CreatePairingSessionRequest request, CancellationToken cancellationToken); + Task JoinPairingSessionAsync(string code, JoinPairingSessionRequest request, string deviceToken, CancellationToken cancellationToken); + Task ApprovePairingSessionAsync(string sessionId, ApprovePairingSessionRequest request, string deviceToken, CancellationToken cancellationToken); + Task CompletePairingSessionAsync(string sessionId, CompletePairingSessionRequest request, CancellationToken cancellationToken); + Task SyncAsync(SyncRequest request, string deviceToken, string idempotencyKey, CancellationToken cancellationToken); + Task GetStateAsync(string deviceToken, CancellationToken cancellationToken); + Task GetHistoryAsync(long cursor, string deviceToken, CancellationToken cancellationToken); + Task RevokeDeviceAsync(string deviceId, string deviceToken, CancellationToken cancellationToken); + Task DeleteVaultAsync(string deviceToken, CancellationToken cancellationToken); +} + +public sealed class CloudflareSyncTransport : ISyncTransport +{ + // A history page can contain 100 maximum-size encrypted records plus the + // small device/current manifest returned by /sync. Keep a bounded response + // while allowing the protocol's documented worst-case page. + private const int MaximumResponseBytes = 16 * 1024 * 1024; + private const string VaultsPath = "v1/vaults"; + private const string VaultPath = "v1/vault"; + private const string RecoverPath = "v1/recover"; + private const string PairingSessionsPath = "v1/pairing-sessions"; + private const string SyncPath = "v1/sync"; + private const string StatePath = "v1/state"; + private const string HistoryPath = "v1/history"; + private const string DevicesPath = "v1/devices"; + private readonly HttpClient _client; + private readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + public CloudflareSyncTransport(Uri baseUri, HttpMessageHandler? handler = null) + { + if (!string.Equals(baseUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || + string.IsNullOrWhiteSpace(baseUri.Host)) + { + throw new ArgumentException("Sync service base URL must use HTTPS.", nameof(baseUri)); + } + _client = handler == null ? new HttpClient() : new HttpClient(handler, disposeHandler: true); + _client.BaseAddress = baseUri; + _client.Timeout = TimeSpan.FromSeconds(30); + _client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + _client.DefaultRequestHeaders.UserAgent.ParseAdd("KeyStats-Windows/1"); + } + + public Task CreateVaultAsync(CreateVaultRequest request, CancellationToken cancellationToken) + => SendAsync(HttpMethod.Post, VaultsPath, request, null, cancellationToken); + + public Task RecoverVaultAsync(RecoverVaultRequest request, CancellationToken cancellationToken) + => SendAsync(HttpMethod.Post, RecoverPath, request, null, cancellationToken); + + public Task CreatePairingSessionAsync( + CreatePairingSessionRequest request, + CancellationToken cancellationToken) + => SendAsync( + HttpMethod.Post, + PairingSessionsPath, + request, + null, + cancellationToken); + + public Task JoinPairingSessionAsync( + string code, + JoinPairingSessionRequest request, + string deviceToken, + CancellationToken cancellationToken) + => SendAsync( + HttpMethod.Post, + PairingSessionsPath + "/" + Uri.EscapeDataString(code) + "/join", + request, + deviceToken, + cancellationToken); + + public Task ApprovePairingSessionAsync( + string sessionId, + ApprovePairingSessionRequest request, + string deviceToken, + CancellationToken cancellationToken) + => SendWithoutResponseAsync( + HttpMethod.Post, + PairingSessionsPath + "/" + Uri.EscapeDataString(sessionId) + "/approve", + request, + deviceToken, + cancellationToken); + + public Task CompletePairingSessionAsync( + string sessionId, + CompletePairingSessionRequest request, + CancellationToken cancellationToken) + => SendAsync( + HttpMethod.Post, + PairingSessionsPath + "/" + Uri.EscapeDataString(sessionId) + "/complete", + request, + null, + cancellationToken); + + public Task SyncAsync( + SyncRequest request, + string deviceToken, + string idempotencyKey, + CancellationToken cancellationToken) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + if (request.Archives == null || request.Archives.Count > SyncProtocol.MaximumArchivesPerRequest) + { + throw new ArgumentException( + $"A sync request can contain at most {SyncProtocol.MaximumArchivesPerRequest} archives.", + nameof(request)); + } + if (!request.BootstrapComplete && !SyncBatchPlanner.IsBootstrapReason(request.Reason)) + { + throw new ArgumentException( + "Only bootstrap, recovery, and pairing requests can be marked incomplete.", + nameof(request)); + } + + return SendAsync( + HttpMethod.Post, + SyncPath, + request, + deviceToken, + cancellationToken, + new Dictionary { ["Idempotency-Key"] = idempotencyKey }); + } + + public Task GetHistoryAsync(long cursor, string deviceToken, CancellationToken cancellationToken) + { + var path = HistoryPath + "?cursor=" + Math.Max(0, cursor).ToString(CultureInfo.InvariantCulture); + return SendAsync(HttpMethod.Get, path, null, deviceToken, cancellationToken); + } + + public Task GetStateAsync(string deviceToken, CancellationToken cancellationToken) + => SendAsync(HttpMethod.Get, StatePath, null, deviceToken, cancellationToken); + + public Task RevokeDeviceAsync(string deviceId, string deviceToken, CancellationToken cancellationToken) + => SendWithoutResponseAsync( + HttpMethod.Delete, + DevicesPath + "/" + Uri.EscapeDataString(deviceId), + null, + deviceToken, + cancellationToken); + + public Task DeleteVaultAsync(string deviceToken, CancellationToken cancellationToken) + => SendWithoutResponseAsync(HttpMethod.Delete, VaultPath, null, deviceToken, cancellationToken); + + public void Dispose() => _client.Dispose(); + + private async Task SendAsync( + HttpMethod method, + string path, + TRequest requestBody, + string? bearerToken, + CancellationToken cancellationToken, + IReadOnlyDictionary? extraHeaders = null) + { + using var request = CreateRequest(method, path, requestBody, bearerToken, extraHeaders); + using var response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + var responseBytes = await ReadResponseBytesAsync(response, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw CreateException(response, responseBytes); + } + + if (responseBytes.Length == 0) + { + throw new SyncTransportException(response.StatusCode, "Sync service returned an empty response.", null); + } + + try + { + return JsonSerializer.Deserialize(responseBytes, _jsonOptions) + ?? throw new JsonException("Response was null."); + } + catch (JsonException ex) + { + throw new SyncTransportException(response.StatusCode, "Sync service returned invalid JSON.", null, ex); + } + } + + private async Task SendWithoutResponseAsync( + HttpMethod method, + string path, + TRequest requestBody, + string? bearerToken, + CancellationToken cancellationToken) + { + using var request = CreateRequest(method, path, requestBody, bearerToken); + using var response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + var responseBytes = await ReadResponseBytesAsync(response, cancellationToken).ConfigureAwait(false); + throw CreateException(response, responseBytes); + } + } + + private HttpRequestMessage CreateRequest( + HttpMethod method, + string path, + TRequest body, + string? bearerToken, + IReadOnlyDictionary? extraHeaders = null) + { + var request = new HttpRequestMessage(method, path); + if (!string.IsNullOrWhiteSpace(bearerToken)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + } + + if (extraHeaders != null) + { + foreach (var header in extraHeaders) + { + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + if (body != null) + { + var json = JsonSerializer.Serialize(body, _jsonOptions); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + } + return request; + } + + private static SyncTransportException CreateException(HttpResponseMessage response, byte[] responseBytes) + { + TimeSpan? retryAfter = null; + if (response.Headers.RetryAfter?.Delta is TimeSpan delta) + { + retryAfter = delta; + } + else if (response.Headers.RetryAfter?.Date is DateTimeOffset retryDate) + { + var delay = retryDate - DateTimeOffset.UtcNow; + retryAfter = delay > TimeSpan.Zero ? delay : TimeSpan.Zero; + } + else if (response.Headers.TryGetValues("Retry-After", out var values)) + { + foreach (var value in values) + { + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds)) + { + retryAfter = TimeSpan.FromSeconds(Math.Max(0, seconds)); + break; + } + } + } + + ReadSafeErrorDetails( + responseBytes, + out var code, + out var activeDeviceCount, + out var vaultId, + out var devices); + var message = string.IsNullOrWhiteSpace(code) + ? $"Sync service request failed ({(int)response.StatusCode})." + : $"Sync service request failed ({(int)response.StatusCode}, {code})."; + return new SyncTransportException( + response.StatusCode, + message, + retryAfter, + errorCode: code, + activeDeviceCount: activeDeviceCount, + vaultId: vaultId, + devices: devices); + } + + private static async Task ReadResponseBytesAsync( + HttpResponseMessage response, + CancellationToken cancellationToken) + { + if (response.Content.Headers.ContentLength is long contentLength && contentLength > MaximumResponseBytes) + { + throw new SyncTransportException(response.StatusCode, "Sync service response exceeded the size limit.", null); + } + + using var input = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + using var output = new MemoryStream(); + var buffer = new byte[8192]; + while (true) + { + var read = await input.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false); + if (read == 0) break; + if (output.Length + read > MaximumResponseBytes) + { + throw new SyncTransportException(response.StatusCode, "Sync service response exceeded the size limit.", null); + } + output.Write(buffer, 0, read); + } + return output.ToArray(); + } + + private static void ReadSafeErrorDetails( + byte[] responseBytes, + out string? code, + out int? activeDeviceCount, + out string? vaultId, + out IReadOnlyList devices) + { + code = null; + activeDeviceCount = null; + vaultId = null; + devices = Array.Empty(); + if (responseBytes.Length == 0 || responseBytes.Length > 64 * 1024) return; + try + { + using var document = JsonDocument.Parse(responseBytes); + if (!document.RootElement.TryGetProperty("code", out var codeElement) || + codeElement.ValueKind != JsonValueKind.String) + { + return; + } + + var candidateCode = codeElement.GetString(); + if (string.IsNullOrWhiteSpace(candidateCode) || candidateCode.Length > 64) return; + foreach (var character in candidateCode) + { + if (!(char.IsLetterOrDigit(character) || character == '_' || character == '-')) return; + } + code = candidateCode; + + if (document.RootElement.TryGetProperty("activeDeviceCount", out var countElement) && + countElement.ValueKind == JsonValueKind.Number && + countElement.TryGetInt32(out var count) && + count >= 1 && count <= SyncProtocol.MaximumDevices) + { + activeDeviceCount = count; + } + + if (document.RootElement.TryGetProperty("vaultId", out var vaultElement) && + vaultElement.ValueKind == JsonValueKind.String) + { + var candidateVaultId = vaultElement.GetString(); + if (Guid.TryParse(candidateVaultId, out _)) vaultId = candidateVaultId; + } + + if (document.RootElement.TryGetProperty("devices", out var devicesElement) && + devicesElement.ValueKind == JsonValueKind.Array && + devicesElement.GetArrayLength() <= SyncProtocol.MaximumDevices) + { + var parsedDevices = JsonSerializer.Deserialize>( + devicesElement.GetRawText(), + new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }); + if (parsedDevices != null && parsedDevices.All(device => + Guid.TryParse(device.DeviceId, out _))) + { + devices = parsedDevices; + } + } + } + catch (JsonException) + { + } + } +} + +public sealed class SyncTransportException : Exception +{ + public HttpStatusCode StatusCode { get; } + public TimeSpan? RetryAfter { get; } + public string? ErrorCode { get; } + public int? ActiveDeviceCount { get; } + public string? VaultId { get; } + public IReadOnlyList Devices { get; } + + public SyncTransportException( + HttpStatusCode statusCode, + string message, + TimeSpan? retryAfter, + Exception? innerException = null, + string? errorCode = null, + int? activeDeviceCount = null, + string? vaultId = null, + IReadOnlyList? devices = null) + : base(message, innerException) + { + StatusCode = statusCode; + RetryAfter = retryAfter; + ErrorCode = errorCode; + ActiveDeviceCount = activeDeviceCount; + VaultId = vaultId; + Devices = devices ?? Array.Empty(); + } +} diff --git a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml index abda356..b9e636e 100644 --- a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml +++ b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml @@ -57,7 +57,7 @@ Foreground="{DynamicResource TextSecondaryBrush}" Margin="0,4,0,0"/> -