feat: Cloud Preferences Sync polish + encryption key rotation (#3067)

* feat: cloud prefs sync polish + encryption key rotation (#2906)

1. Add sync status indicator in Preferences panel — shows current state
   (synced/pending/error/offline) with colored dot, last-sync timestamp,
   and manual "Sync now" button for conflict/error recovery.

2. Support AES key rotation in scripts/lib/crypto.cjs — encrypt() always
   uses the latest ENCRYPTION_KEY_V{n} env var, decrypt() reads the version
   prefix to select the matching key. Fully backwards-compatible with the
   existing NOTIFICATION_ENCRYPTION_KEY single-key setup.

3. Add tests exercising the applyMigrations() schema-version plumbing and
   the new multi-version key rotation (12 tests, all passing).

Closes #2906

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review feedback

- Raise key version ceiling from 9 to 99 to avoid silent footgun
- Extract SYNC_STATE_LABELS/COLORS to shared constants (no drift risk)
- Gate Cloud Sync UI on isCloudSyncEnabled() so it doesn't render
  when VITE_CLOUD_PREFS_ENABLED is off

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sebastien Melki
2026-04-13 18:44:25 +03:00
committed by GitHub
parent df2a476381
commit 50765023ea
6 changed files with 314 additions and 13 deletions

View File

@@ -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 };

View File

@@ -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<string, string> = {
synced: 'Synced', pending: 'Pending', syncing: 'Syncing\u2026',
conflict: 'Conflict', offline: 'Offline', 'signed-out': 'Signed out', error: 'Error',
};
const SYNC_STATE_COLORS: Record<string, string> = {
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<HTMLElement>('#usSyncDot');
const label = container.querySelector<HTMLElement>('#usSyncLabel');
const time = container.querySelector<HTMLElement>('#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 += `</div></details>`;
// ── 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 += `<details class="wm-pref-group">`;
html += `<summary>Cloud Sync</summary>`;
html += `<div class="wm-pref-group-content">`;
html += `<div class="wm-sync-status-row">
<div class="wm-sync-status-info">
<span class="wm-sync-status-dot" id="usSyncDot" style="background:${SYNC_STATE_COLORS[syncState] ?? SYNC_STATE_COLORS.error}"></span>
<span class="wm-sync-status-label" id="usSyncLabel">${SYNC_STATE_LABELS[syncState] ?? 'Unknown'}</span>
<span class="wm-sync-status-time" id="usSyncTime">Last synced: ${escapeHtml(lastSyncStr)}</span>
</div>
<button type="button" class="settings-btn settings-btn-secondary wm-sync-now-btn" id="usSyncNowBtn">Sync now</button>
</div>`;
html += `</div></details>`;
}
// ── Data & Community group ──
html += `<details class="wm-pref-group">`;
html += `<summary>${t('preferences.dataAndCommunity')}</summary>`;
@@ -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<HTMLButtonElement>('#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<HTMLButtonElement>('#usNotifUpgradeBtn');

View File

@@ -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 {

View File

@@ -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<void> {
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 {

View File

@@ -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 });
});
});

View File

@@ -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/);
});
});