diff --git a/scripts/lib/crypto.cjs b/scripts/lib/crypto.cjs index 385a76d3c..f0dfc9a34 100644 --- a/scripts/lib/crypto.cjs +++ b/scripts/lib/crypto.cjs @@ -2,30 +2,44 @@ const { createCipheriv, createDecipheriv, randomBytes } = require('node:crypto'); -const KEY_ENV = 'NOTIFICATION_ENCRYPTION_KEY'; -const VERSION = 'v1'; +const LEGACY_KEY_ENV = 'NOTIFICATION_ENCRYPTION_KEY'; const IV_LEN = 12; const TAG_LEN = 16; -function getKey(version) { - if (version === 'v1') { - const raw = process.env[KEY_ENV]; - if (!raw) throw new Error(`${KEY_ENV} is not set`); - const key = Buffer.from(raw, 'base64'); - if (key.length !== 32) throw new Error(`${KEY_ENV} must be 32 bytes for AES-256 (got ${key.length})`); - return key; +// Versioned key env vars: ENCRYPTION_KEY_V1, ENCRYPTION_KEY_V2, ... +// Falls back to NOTIFICATION_ENCRYPTION_KEY for v1 (backwards-compatible). +const KEY_ENV_PREFIX = 'ENCRYPTION_KEY_V'; + +function getLatestVersion() { + for (let v = 99; v >= 1; v--) { + if (process.env[`${KEY_ENV_PREFIX}${v}`]) return `v${v}`; } - throw new Error(`Unknown key version: ${version}`); + if (process.env[LEGACY_KEY_ENV]) return 'v1'; + throw new Error('No encryption key configured (set ENCRYPTION_KEY_V1 or NOTIFICATION_ENCRYPTION_KEY)'); +} + +function getKey(version) { + const num = parseInt(version.slice(1), 10); + if (!num || num < 1) throw new Error(`Unknown key version: ${version}`); + + const raw = process.env[`${KEY_ENV_PREFIX}${num}`] + || (num === 1 ? process.env[LEGACY_KEY_ENV] : undefined); + + if (!raw) throw new Error(`No key for ${version} (set ${KEY_ENV_PREFIX}${num})`); + const key = Buffer.from(raw, 'base64'); + if (key.length !== 32) throw new Error(`${KEY_ENV_PREFIX}${num} must be 32 bytes for AES-256 (got ${key.length})`); + return key; } function encrypt(plaintext) { - const key = getKey(VERSION); + const version = getLatestVersion(); + const key = getKey(version); const iv = randomBytes(IV_LEN); const cipher = createCipheriv('aes-256-gcm', key, iv); const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); const payload = Buffer.concat([iv, tag, encrypted]); - return `${VERSION}:${payload.toString('base64')}`; + return `${version}:${payload.toString('base64')}`; } function decrypt(stored) { @@ -43,4 +57,4 @@ function decrypt(stored) { return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'); } -module.exports = { encrypt, decrypt }; +module.exports = { encrypt, decrypt, getLatestVersion }; diff --git a/src/services/preferences-content.ts b/src/services/preferences-content.ts index 7b4f386a6..f49169ff8 100644 --- a/src/services/preferences-content.ts +++ b/src/services/preferences-content.ts @@ -29,6 +29,16 @@ import { import { getCurrentClerkUser } from '@/services/clerk'; import { hasTier } from '@/services/entitlements'; import { SITE_VARIANT } from '@/config/variant'; +import { getSyncState, getLastSyncAt, syncNow, isCloudSyncEnabled } from '@/utils/cloud-prefs-sync'; + +const SYNC_STATE_LABELS: Record = { + synced: 'Synced', pending: 'Pending', syncing: 'Syncing\u2026', + conflict: 'Conflict', offline: 'Offline', 'signed-out': 'Signed out', error: 'Error', +}; +const SYNC_STATE_COLORS: Record = { + synced: 'var(--color-ok, #34d399)', pending: 'var(--color-warn, #fbbf24)', syncing: 'var(--color-warn, #fbbf24)', + conflict: 'var(--color-error, #f87171)', offline: 'var(--text-faint, #888)', 'signed-out': 'var(--text-faint, #888)', error: 'var(--color-error, #f87171)', +}; // When VITE_QUIET_HOURS_BATCH_ENABLED=0 the relay does not honour batch_on_wake. // Hide that option so users cannot select a mode that silently behaves as critical_only. const QUIET_HOURS_BATCH_ENABLED = import.meta.env.VITE_QUIET_HOURS_BATCH_ENABLED !== '0'; @@ -82,6 +92,20 @@ function renderMapThemeDropdown(container: HTMLElement, provider: MapProvider): .join(''); } +function updateSyncStatusUI(container: HTMLElement): void { + const dot = container.querySelector('#usSyncDot'); + const label = container.querySelector('#usSyncLabel'); + const time = container.querySelector('#usSyncTime'); + if (!dot || !label || !time) return; + + const state = getSyncState(); + const lastSync = getLastSyncAt(); + + dot.style.background = (SYNC_STATE_COLORS[state] ?? SYNC_STATE_COLORS.error) as string; + label.textContent = SYNC_STATE_LABELS[state] ?? 'Unknown'; + time.textContent = `Last synced: ${lastSync ? new Date(lastSync).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' }) : 'Never'}`; +} + function updateAiStatus(container: HTMLElement): void { const settings = getAiFlowSettings(); const dot = container.querySelector('#usStatusDot'); @@ -340,6 +364,28 @@ export function renderPreferences(host: PreferencesHost): PreferencesResult { html += toggleRowHtml('us-badge-anim', t('components.insights.badgeAnimLabel'), t('components.insights.badgeAnimDesc'), settings.badgeAnimation); html += ``; + // ── Cloud Sync group (web-only, signed-in, feature flag on) ── + if (!host.isDesktopApp && host.isSignedIn && isCloudSyncEnabled()) { + const syncState = getSyncState(); + const lastSync = getLastSyncAt(); + const lastSyncStr = lastSync + ? new Date(lastSync).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' }) + : 'Never'; + + html += `
`; + html += `Cloud Sync`; + html += `
`; + html += `
+
+ + ${SYNC_STATE_LABELS[syncState] ?? 'Unknown'} + Last synced: ${escapeHtml(lastSyncStr)} +
+ +
`; + html += `
`; + } + // ── Data & Community group ── html += `
`; html += `${t('preferences.dataAndCommunity')}`; @@ -627,6 +673,24 @@ export function renderPreferences(host: PreferencesHost): PreferencesResult { if (!host.isDesktopApp) updateAiStatus(container); + // ── Cloud Sync: wire "Sync now" button + live state updates ── + if (!host.isDesktopApp && host.isSignedIn && isCloudSyncEnabled()) { + const syncBtn = container.querySelector('#usSyncNowBtn'); + if (syncBtn) { + syncBtn.addEventListener('click', () => { + syncBtn.disabled = true; + syncBtn.textContent = 'Syncing\u2026'; + syncNow().finally(() => { + syncBtn.disabled = false; + syncBtn.textContent = 'Sync now'; + updateSyncStatusUI(container); + }); + }, { signal }); + } + const syncPollId = setInterval(() => updateSyncStatusUI(container), 2000); + signal.addEventListener('abort', () => clearInterval(syncPollId)); + } + // ── Notifications section: locked [PRO] upgrade button ── if (!host.isDesktopApp && !(host.isSignedIn && hasTier(1))) { const upgradeBtn = container.querySelector('#usNotifUpgradeBtn'); diff --git a/src/styles/main.css b/src/styles/main.css index 57c40e802..77cbc4746 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -16840,6 +16840,44 @@ a.prediction-link:hover { padding: 4px 14px 10px; } +/* ── Cloud Sync status ── */ + +.wm-sync-status-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 6px 0; +} + +.wm-sync-status-info { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-primary, #e8eaed); +} + +.wm-sync-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.wm-sync-status-label { + font-weight: 600; +} + +.wm-sync-status-time { + color: var(--text-faint, #888); + font-size: 11px; +} + +.wm-sync-now-btn { + flex-shrink: 0; +} + /* ============ AI Flow Toggle Styles (shared) ============ */ .ai-flow-section-label { diff --git a/src/utils/cloud-prefs-sync.ts b/src/utils/cloud-prefs-sync.ts index c11776b75..e6fe19361 100644 --- a/src/utils/cloud-prefs-sync.ts +++ b/src/utils/cloud-prefs-sync.ts @@ -43,6 +43,10 @@ function isEnabled(): boolean { return ENABLED && !isDesktopRuntime(); } +export function isCloudSyncEnabled(): boolean { + return isEnabled(); +} + // ── State helpers ───────────────────────────────────────────────────────────── function getSyncVersion(): number { @@ -309,6 +313,23 @@ export function onPrefChange(variant: string): void { schedulePrefUpload(variant); } +export async function syncNow(): Promise { + if (!isEnabled()) return; + if (_debounceTimer !== null) { + clearTimeout(_debounceTimer); + _debounceTimer = null; + } + await uploadNow(_currentVariant); +} + +export function getSyncState(): SyncState { + return (localStorage.getItem(KEY_SYNC_STATE) as SyncState) || 'signed-out'; +} + +export function getLastSyncAt(): number { + return parseInt(localStorage.getItem(KEY_LAST_SYNC_AT) ?? '0', 10) || 0; +} + // ── install ─────────────────────────────────────────────────────────────────── export function install(variant: string): void { diff --git a/tests/cloud-prefs-migrations.test.mjs b/tests/cloud-prefs-migrations.test.mjs new file mode 100644 index 000000000..8964505aa --- /dev/null +++ b/tests/cloud-prefs-migrations.test.mjs @@ -0,0 +1,68 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +/** + * Exercises the applyMigrations() plumbing from cloud-prefs-sync.ts. + * + * The function is not exported (internal), so we replicate the algorithm here + * with a real migration map to prove the loop + fallthrough logic works before + * it's needed in production. (Issue #2906 item 3) + */ + +function applyMigrations(data, fromVersion, currentVersion, migrations) { + let result = data; + for (let v = fromVersion + 1; v <= currentVersion; v++) { + result = migrations[v]?.(result) ?? result; + } + return result; +} + +describe('applyMigrations (cloud-prefs-sync plumbing)', () => { + const MIGRATIONS = { + 2: (data) => { + // Simulate renaming a preference key + const out = { ...data }; + if ('oldKey' in out) { + out.newKey = out.oldKey; + delete out.oldKey; + } + return out; + }, + 3: (data) => { + // Simulate adding a default for a new preference + const out = { ...data }; + if (!('addedInV3' in out)) out.addedInV3 = 'default-value'; + return out; + }, + }; + + it('no-op when already at current version', () => { + const data = { foo: 'bar' }; + const result = applyMigrations(data, 1, 1, MIGRATIONS); + assert.deepEqual(result, { foo: 'bar' }); + }); + + it('applies a single v1 -> v2 migration', () => { + const data = { oldKey: 'hello', keep: 42 }; + const result = applyMigrations(data, 1, 2, MIGRATIONS); + assert.deepEqual(result, { newKey: 'hello', keep: 42 }); + }); + + it('chains v1 -> v2 -> v3 migrations', () => { + const data = { oldKey: 'hello' }; + const result = applyMigrations(data, 1, 3, MIGRATIONS); + assert.deepEqual(result, { newKey: 'hello', addedInV3: 'default-value' }); + }); + + it('skips missing migration versions gracefully', () => { + const data = { oldKey: 'x' }; + const result = applyMigrations(data, 1, 4, MIGRATIONS); + assert.deepEqual(result, { newKey: 'x', addedInV3: 'default-value' }); + }); + + it('handles empty migrations map', () => { + const data = { a: 1 }; + const result = applyMigrations(data, 1, 5, {}); + assert.deepEqual(result, { a: 1 }); + }); +}); diff --git a/tests/crypto-key-rotation.test.mjs b/tests/crypto-key-rotation.test.mjs new file mode 100644 index 000000000..477cc05f9 --- /dev/null +++ b/tests/crypto-key-rotation.test.mjs @@ -0,0 +1,96 @@ +import { describe, it, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { randomBytes } from 'node:crypto'; + +const require = createRequire(import.meta.url); + +/** + * Tests the multi-version key rotation support in scripts/lib/crypto.cjs. + * (Issue #2906 item 2) + */ + +const KEY_V1 = randomBytes(32).toString('base64'); +const KEY_V2 = randomBytes(32).toString('base64'); + +const savedEnv = {}; + +function setEnv(overrides) { + for (const key of ['NOTIFICATION_ENCRYPTION_KEY', 'ENCRYPTION_KEY_V1', 'ENCRYPTION_KEY_V2']) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + Object.assign(process.env, overrides); +} + +function restoreEnv() { + for (const [key, val] of Object.entries(savedEnv)) { + if (val === undefined) delete process.env[key]; + else process.env[key] = val; + } +} + +function loadCrypto() { + const modPath = require.resolve('../scripts/lib/crypto.cjs'); + delete require.cache[modPath]; + return require(modPath); +} + +describe('crypto key rotation', () => { + afterEach(restoreEnv); + + it('legacy single-key: encrypt/decrypt round-trip with NOTIFICATION_ENCRYPTION_KEY', () => { + setEnv({ NOTIFICATION_ENCRYPTION_KEY: KEY_V1 }); + const { encrypt, decrypt } = loadCrypto(); + const ciphertext = encrypt('hello world'); + assert.ok(ciphertext.startsWith('v1:'), 'envelope should use v1 prefix'); + assert.equal(decrypt(ciphertext), 'hello world'); + }); + + it('versioned key: ENCRYPTION_KEY_V1 works the same as legacy', () => { + setEnv({ ENCRYPTION_KEY_V1: KEY_V1 }); + const { encrypt, decrypt } = loadCrypto(); + const ciphertext = encrypt('test'); + assert.ok(ciphertext.startsWith('v1:')); + assert.equal(decrypt(ciphertext), 'test'); + }); + + it('encrypt uses latest version (v2) when ENCRYPTION_KEY_V2 is set', () => { + setEnv({ ENCRYPTION_KEY_V1: KEY_V1, ENCRYPTION_KEY_V2: KEY_V2 }); + const { encrypt } = loadCrypto(); + const ciphertext = encrypt('secret'); + assert.ok(ciphertext.startsWith('v2:'), 'should encrypt with v2 when available'); + }); + + it('decrypt can read v1 data even after v2 is the latest', () => { + setEnv({ ENCRYPTION_KEY_V1: KEY_V1 }); + const { encrypt: encryptV1 } = loadCrypto(); + const v1Cipher = encryptV1('legacy data'); + + setEnv({ ENCRYPTION_KEY_V1: KEY_V1, ENCRYPTION_KEY_V2: KEY_V2 }); + const { decrypt } = loadCrypto(); + assert.equal(decrypt(v1Cipher), 'legacy data'); + }); + + it('v2 encrypted data is not decryptable with only v1 key', () => { + setEnv({ ENCRYPTION_KEY_V1: KEY_V1, ENCRYPTION_KEY_V2: KEY_V2 }); + const { encrypt } = loadCrypto(); + const v2Cipher = encrypt('new data'); + + setEnv({ ENCRYPTION_KEY_V1: KEY_V1 }); + const { decrypt } = loadCrypto(); + assert.throws(() => decrypt(v2Cipher), /No key for v2/); + }); + + it('getLatestVersion returns highest available version', () => { + setEnv({ ENCRYPTION_KEY_V1: KEY_V1, ENCRYPTION_KEY_V2: KEY_V2 }); + const { getLatestVersion } = loadCrypto(); + assert.equal(getLatestVersion(), 'v2'); + }); + + it('throws when no key is configured at all', () => { + setEnv({}); + const { encrypt } = loadCrypto(); + assert.throws(() => encrypt('x'), /No encryption key configured/); + }); +});