mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
@@ -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 };
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
68
tests/cloud-prefs-migrations.test.mjs
Normal file
68
tests/cloud-prefs-migrations.test.mjs
Normal 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 });
|
||||
});
|
||||
});
|
||||
96
tests/crypto-key-rotation.test.mjs
Normal file
96
tests/crypto-key-rotation.test.mjs
Normal 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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user