mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
b1f199c4d7
Add yyyymmdd hhmm timestamps to cloud sync history and only show the green status dot when local changes are still pending upload. Co-authored-by: Cursor <cursoragent@cursor.com>
596 lines
21 KiB
TypeScript
596 lines
21 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import {
|
|
SYNC_CONSTANTS,
|
|
SYNC_STORAGE_KEYS,
|
|
cleanOneDriveErrorMessage,
|
|
generateDeviceId,
|
|
getDefaultDeviceName,
|
|
isOneDriveReauthRequiredMessage,
|
|
} from '../../../domain/sync';
|
|
import {
|
|
DEFAULT_CLOUD_SYNC_STRATEGY,
|
|
normalizeCloudSyncStrategy,
|
|
} from '../../../domain/syncStrategy';
|
|
import { EncryptionService } from '../EncryptionService';
|
|
import { createAdapter } from '../adapters';
|
|
import { localStorageAdapter } from '../../persistence/localStorageAdapter';
|
|
import {
|
|
decryptProviderSecrets,
|
|
encryptProviderSecrets,
|
|
} from '../../persistence/secureFieldAdapter';
|
|
import type { CloudAdapter } from '../adapters';
|
|
import type {
|
|
CloudProvider,
|
|
MasterKeyConfig,
|
|
ProviderConnection,
|
|
SecurityState,
|
|
SyncHistoryEntry,
|
|
} from '../../../domain/sync';
|
|
import type { SyncManagerState } from '../CloudSyncManager';
|
|
|
|
const SYNC_HISTORY_STORAGE_KEY = 'netcatty_sync_history_v1';
|
|
|
|
export function loadInitialStateImpl(this: any): SyncManagerState {
|
|
// Load persisted configuration
|
|
const masterKeyConfig = this.loadFromStorage<MasterKeyConfig>(
|
|
SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG
|
|
);
|
|
|
|
const deviceId = this.loadFromStorage<string>(SYNC_STORAGE_KEYS.DEVICE_ID)
|
|
|| generateDeviceId();
|
|
|
|
const deviceName = this.loadFromStorage<string>(SYNC_STORAGE_KEYS.DEVICE_NAME)
|
|
|| getDefaultDeviceName();
|
|
|
|
const syncConfig = this.loadFromStorage<{
|
|
autoSync: boolean;
|
|
interval: number;
|
|
localVersion: number;
|
|
localUpdatedAt: number;
|
|
remoteVersion: number;
|
|
remoteUpdatedAt: number;
|
|
syncStrategy?: unknown;
|
|
}>(SYNC_STORAGE_KEYS.SYNC_CONFIG);
|
|
|
|
// Load sync history
|
|
const syncHistory = this.loadFromStorage<SyncHistoryEntry[]>(SYNC_HISTORY_STORAGE_KEY) || [];
|
|
|
|
// Determine initial security state
|
|
const securityState: SecurityState = masterKeyConfig ? 'LOCKED' : 'NO_KEY';
|
|
|
|
// Load provider connections
|
|
const providers: Record<CloudProvider, ProviderConnection> = {
|
|
github: this.loadProviderConnection('github'),
|
|
google: this.loadProviderConnection('google'),
|
|
onedrive: this.loadProviderConnection('onedrive'),
|
|
webdav: this.loadProviderConnection('webdav'),
|
|
s3: this.loadProviderConnection('s3'),
|
|
};
|
|
|
|
// Save device ID if new
|
|
this.saveToStorage(SYNC_STORAGE_KEYS.DEVICE_ID, deviceId);
|
|
this.saveToStorage(SYNC_STORAGE_KEYS.DEVICE_NAME, deviceName);
|
|
|
|
return {
|
|
securityState,
|
|
syncState: 'IDLE',
|
|
masterKeyConfig,
|
|
unlockedKey: null,
|
|
providers,
|
|
deviceId,
|
|
deviceName,
|
|
localVersion: syncConfig?.localVersion || 0,
|
|
localUpdatedAt: syncConfig?.localUpdatedAt || 0,
|
|
remoteVersion: syncConfig?.remoteVersion || 0,
|
|
remoteUpdatedAt: syncConfig?.remoteUpdatedAt || 0,
|
|
currentConflict: null,
|
|
lastError: null,
|
|
autoSyncEnabled: syncConfig?.autoSync || false,
|
|
autoSyncInterval: syncConfig?.interval || SYNC_CONSTANTS.DEFAULT_AUTO_SYNC_INTERVAL,
|
|
syncStrategy: normalizeCloudSyncStrategy(syncConfig?.syncStrategy ?? DEFAULT_CLOUD_SYNC_STRATEGY),
|
|
syncHistory,
|
|
pendingLocalSync: false,
|
|
};
|
|
}
|
|
|
|
export function loadProviderConnectionImpl(this: any,provider: CloudProvider): ProviderConnection {
|
|
const key = SYNC_STORAGE_KEYS[`PROVIDER_${provider.toUpperCase()}` as keyof typeof SYNC_STORAGE_KEYS];
|
|
const stored = this.loadFromStorage<Partial<ProviderConnection>>(key);
|
|
|
|
// Determine the correct status: if tokens or config exist, should be 'connected'
|
|
// Never restore 'syncing' or 'error' status - those are transient
|
|
const status: ProviderConnection['status'] = (stored?.tokens || stored?.config)
|
|
? 'connected'
|
|
: 'disconnected';
|
|
|
|
return {
|
|
provider,
|
|
...stored,
|
|
status, // Must be last to override any stored 'syncing' or 'error' status
|
|
} as ProviderConnection;
|
|
}
|
|
|
|
export async function initProviderDecryptionImpl(this: any): Promise<void> {
|
|
const providers: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
|
|
for (const p of providers) {
|
|
try {
|
|
const conn = this.state.providers[p];
|
|
if (conn.tokens || conn.config) {
|
|
const seq = ++this.providerDecryptSeq[p];
|
|
const decrypted = await decryptProviderSecrets(conn);
|
|
// Only apply if no newer update has occurred during the async gap
|
|
if (seq === this.providerDecryptSeq[p]) {
|
|
this.state.providers[p] = decrypted;
|
|
this.providerDecrypted[p] = true;
|
|
}
|
|
} else {
|
|
// No secrets to decrypt — mark as done
|
|
this.providerDecrypted[p] = true;
|
|
}
|
|
} catch {
|
|
// Decryption failed — likely the Electron IPC handler is not yet
|
|
// registered. getConnectedAdapter() will retry for this provider.
|
|
}
|
|
}
|
|
this.notifyStateChange();
|
|
}
|
|
|
|
export async function saveProviderConnectionImpl(this: any,
|
|
provider: CloudProvider,
|
|
connection: ProviderConnection,
|
|
authAttemptId?: number
|
|
): Promise<void> {
|
|
const key = SYNC_STORAGE_KEYS[`PROVIDER_${provider.toUpperCase()}` as keyof typeof SYNC_STORAGE_KEYS];
|
|
// Use write-specific counter so status-only updates cannot discard
|
|
// an in-flight encrypted write that must be persisted.
|
|
const seq = ++this.providerWriteSeq[provider];
|
|
const encrypted = await encryptProviderSecrets(connection);
|
|
// Only persist if no newer save has started during the async gap
|
|
if (
|
|
seq === this.providerWriteSeq[provider] &&
|
|
(authAttemptId == null || this.isActiveAuthAttempt(provider, authAttemptId))
|
|
) {
|
|
this.saveToStorage(key, encrypted);
|
|
}
|
|
}
|
|
|
|
export function loadFromStorageImpl<T>(this: any,key: string): T | null {
|
|
return localStorageAdapter.read<T>(key);
|
|
}
|
|
|
|
export function saveToStorageImpl(this: any,key: string, value: unknown): void {
|
|
localStorageAdapter.write(key, value);
|
|
}
|
|
|
|
export function removeFromStorageImpl(this: any,key: string): void {
|
|
localStorageAdapter.remove(key);
|
|
}
|
|
|
|
export function setupCrossWindowSyncImpl(this: any): void {
|
|
if (this.hasStorageListener) return;
|
|
if (typeof window === 'undefined') return;
|
|
|
|
window.addEventListener('storage', this.handleStorageEvent);
|
|
this.hasStorageListener = true;
|
|
}
|
|
|
|
export function safeJsonParseImpl<T>(this: any,value: string | null): T | null {
|
|
if (!value) return null;
|
|
try {
|
|
return JSON.parse(value) as T;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function handleStorageEventImpl(this: any, event: StorageEvent): void {
|
|
if (event.storageArea !== window.localStorage) return;
|
|
const key = event.key;
|
|
if (!key) return;
|
|
|
|
// Handle master key config changes (e.g., when set up in settings window)
|
|
if (key === SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG) {
|
|
const nextConfig = this.safeJsonParse<MasterKeyConfig>(event.newValue);
|
|
|
|
if (nextConfig) {
|
|
const currentConfig = this.state.masterKeyConfig as MasterKeyConfig | null;
|
|
const configChanged = !currentConfig
|
|
|| currentConfig.verificationHash !== nextConfig.verificationHash
|
|
|| currentConfig.salt !== nextConfig.salt
|
|
|| currentConfig.kdf !== nextConfig.kdf
|
|
|| currentConfig.kdfIterations !== nextConfig.kdfIterations;
|
|
|
|
if (!configChanged) return;
|
|
|
|
// Master key was set up or changed in another window. Lock this
|
|
// window so it cannot keep syncing with the stale in-memory password.
|
|
this.bumpSyncSecurityGeneration?.();
|
|
this.state.masterKeyConfig = nextConfig;
|
|
this.state.securityState = 'LOCKED';
|
|
this.state.unlockedKey = null;
|
|
this.masterPassword = null;
|
|
this.stopAutoSync();
|
|
this.notifyStateChange();
|
|
} else if (this.state.masterKeyConfig) {
|
|
// Master key was removed in another window
|
|
this.bumpSyncSecurityGeneration?.();
|
|
this.state.masterKeyConfig = null;
|
|
this.state.securityState = 'NO_KEY';
|
|
this.state.unlockedKey = null;
|
|
this.masterPassword = null;
|
|
this.notifyStateChange();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Sync versions + auto-sync settings
|
|
if (key === SYNC_STORAGE_KEYS.SYNC_CONFIG) {
|
|
const next = this.safeJsonParse<{
|
|
autoSync?: boolean;
|
|
interval?: number;
|
|
localVersion?: number;
|
|
localUpdatedAt?: number;
|
|
remoteVersion?: number;
|
|
remoteUpdatedAt?: number;
|
|
syncStrategy?: unknown;
|
|
}>(event.newValue) || {
|
|
autoSync: false,
|
|
interval: SYNC_CONSTANTS.DEFAULT_AUTO_SYNC_INTERVAL,
|
|
localVersion: 0,
|
|
localUpdatedAt: 0,
|
|
remoteVersion: 0,
|
|
remoteUpdatedAt: 0,
|
|
syncStrategy: DEFAULT_CLOUD_SYNC_STRATEGY,
|
|
};
|
|
|
|
this.state.autoSyncEnabled = Boolean(next.autoSync);
|
|
this.state.autoSyncInterval = Math.max(
|
|
SYNC_CONSTANTS.MIN_SYNC_INTERVAL,
|
|
Math.min(
|
|
SYNC_CONSTANTS.MAX_SYNC_INTERVAL,
|
|
Number(next.interval ?? SYNC_CONSTANTS.DEFAULT_AUTO_SYNC_INTERVAL)
|
|
)
|
|
);
|
|
this.state.localVersion = Number(next.localVersion ?? 0);
|
|
this.state.localUpdatedAt = Number(next.localUpdatedAt ?? 0);
|
|
this.state.remoteVersion = Number(next.remoteVersion ?? 0);
|
|
this.state.remoteUpdatedAt = Number(next.remoteUpdatedAt ?? 0);
|
|
this.state.syncStrategy = normalizeCloudSyncStrategy(next.syncStrategy);
|
|
|
|
this.notifyStateChange();
|
|
return;
|
|
}
|
|
|
|
// Sync history list
|
|
if (key === SYNC_HISTORY_STORAGE_KEY) {
|
|
const nextHistory = this.safeJsonParse<SyncHistoryEntry[]>(event.newValue) || [];
|
|
this.state.syncHistory = Array.isArray(nextHistory) ? nextHistory : [];
|
|
this.notifyStateChange();
|
|
return;
|
|
}
|
|
|
|
// Sync provider connections (connect/disconnect, account, tokens, last sync)
|
|
const providerByKey: Partial<Record<string, CloudProvider>> = {
|
|
[SYNC_STORAGE_KEYS.PROVIDER_GITHUB]: 'github',
|
|
[SYNC_STORAGE_KEYS.PROVIDER_GOOGLE]: 'google',
|
|
[SYNC_STORAGE_KEYS.PROVIDER_ONEDRIVE]: 'onedrive',
|
|
[SYNC_STORAGE_KEYS.PROVIDER_WEBDAV]: 'webdav',
|
|
[SYNC_STORAGE_KEYS.PROVIDER_S3]: 's3',
|
|
};
|
|
const provider = providerByKey[key];
|
|
if (provider) {
|
|
const rawNext = this.loadProviderConnection(provider);
|
|
const seq = ++this.providerDecryptSeq[provider];
|
|
// Also bump write seq so any in-flight save from this window for the
|
|
// same provider is discarded — the cross-window data is newer.
|
|
++this.providerWriteSeq[provider];
|
|
|
|
// Decrypt secrets asynchronously, then update state.
|
|
// Use sequence counter to discard stale results when multiple events
|
|
// for the same provider arrive in quick succession.
|
|
decryptProviderSecrets(rawNext).then((next) => {
|
|
if (seq !== this.providerDecryptSeq[provider]) return; // stale — discard
|
|
|
|
const prev = this.state.providers[provider];
|
|
const preserveTransientStatus =
|
|
prev.status === 'connecting' || prev.status === 'syncing';
|
|
|
|
this.state.providers[provider] = {
|
|
...next,
|
|
status: preserveTransientStatus ? prev.status : next.status,
|
|
error: preserveTransientStatus ? prev.error : next.error,
|
|
};
|
|
|
|
const nextTokens = next.tokens;
|
|
const nextConfig = next.config;
|
|
const adapter = this.adapters.get(provider);
|
|
if (!nextTokens && !nextConfig) {
|
|
if (adapter) {
|
|
adapter.signOut();
|
|
this.adapters.delete(provider);
|
|
}
|
|
this.notifyStateChange();
|
|
return;
|
|
}
|
|
|
|
const tokenChanged =
|
|
(prev.tokens?.accessToken || null) !== (nextTokens?.accessToken || null) ||
|
|
(prev.tokens?.refreshToken || null) !== (nextTokens?.refreshToken || null) ||
|
|
(prev.tokens?.expiresAt || null) !== (nextTokens?.expiresAt || null) ||
|
|
(prev.tokens?.tokenType || null) !== (nextTokens?.tokenType || null) ||
|
|
(prev.tokens?.scope || null) !== (nextTokens?.scope || null);
|
|
|
|
const configChanged =
|
|
JSON.stringify(prev.config || null) !== JSON.stringify(nextConfig || null);
|
|
|
|
const resourceChanged = (adapter?.resourceId || null) !== (next.resourceId || null);
|
|
|
|
if (adapter && (tokenChanged || configChanged || resourceChanged)) {
|
|
adapter.signOut();
|
|
this.adapters.delete(provider);
|
|
}
|
|
|
|
this.notifyStateChange();
|
|
}).catch(() => {
|
|
// Decryption failure in cross-window handler is non-fatal
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function getConnectedAdapterImpl(this: any,provider: CloudProvider): Promise<CloudAdapter> {
|
|
// Ensure startup decryption has finished before reading tokens
|
|
await this.decryptionReady;
|
|
|
|
// If this provider's secrets were not successfully decrypted at
|
|
// startup (IPC handler not registered yet), retry now.
|
|
if (!this.providerDecrypted[provider]) {
|
|
const conn = this.state.providers[provider];
|
|
if (conn.tokens || conn.config) {
|
|
try {
|
|
const seq = ++this.providerDecryptSeq[provider];
|
|
const decrypted = await decryptProviderSecrets(conn);
|
|
if (seq === this.providerDecryptSeq[provider]) {
|
|
this.state.providers[provider] = decrypted;
|
|
this.providerDecrypted[provider] = true;
|
|
// Evict any adapter cached with the old (encrypted) tokens
|
|
// so a fresh one is built from the decrypted credentials below.
|
|
const stale = this.adapters.get(provider);
|
|
if (stale) {
|
|
stale.signOut();
|
|
this.adapters.delete(provider);
|
|
}
|
|
this.notifyStateChange();
|
|
}
|
|
} catch {
|
|
// Still failing — will surface when adapter tries to use tokens
|
|
}
|
|
}
|
|
}
|
|
|
|
const connection = this.state.providers[provider];
|
|
const tokens = connection?.tokens;
|
|
const config = connection?.config;
|
|
if (!tokens && !config) {
|
|
throw new Error('Provider not connected');
|
|
}
|
|
|
|
const existing = this.adapters.get(provider);
|
|
if (existing?.isAuthenticated) {
|
|
attachTokenRefreshPersistence.call(this, provider, existing);
|
|
return existing;
|
|
}
|
|
|
|
const adapter = await createAdapter(provider, tokens, connection.resourceId, config);
|
|
attachTokenRefreshPersistence.call(this, provider, adapter);
|
|
this.adapters.set(provider, adapter);
|
|
return adapter;
|
|
}
|
|
|
|
/**
|
|
* Wire an OAuth adapter's token-refresh callback so silently refreshed tokens
|
|
* are persisted. Without this, an adapter that refreshes its access token only
|
|
* updates memory and the next launch loads a stale token and is forced to
|
|
* reconnect — OneDrive's rotating refresh tokens go stale after the first
|
|
* in-session refresh (#1189), and Google's refreshed access token is likewise
|
|
* lost on restart. OneDrive and Google expose setOnTokensRefreshed; adapters
|
|
* without it (GitHub, WebDAV, S3) are no-ops.
|
|
*/
|
|
export function attachTokenRefreshPersistence(
|
|
this: any,
|
|
provider: CloudProvider,
|
|
adapter: CloudAdapter,
|
|
): void {
|
|
const setCallback = (adapter as {
|
|
setOnTokensRefreshed?: (cb: (tokens: import('../../../domain/sync').OAuthTokens) => void) => void;
|
|
}).setOnTokensRefreshed;
|
|
if (typeof setCallback !== 'function') return;
|
|
setCallback.call(adapter, (tokens) => {
|
|
persistRefreshedProviderTokensImpl.call(this, provider, tokens);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Persist tokens that an adapter refreshed mid-session into provider state and
|
|
* encrypted storage. Bumps the decrypt sequence so a concurrent stale decrypt
|
|
* (startup / cross-window) can't clobber the rotated tokens; preserves the live
|
|
* status/account/resource fields. saveProviderConnection manages its own write
|
|
* sequence to serialize the encrypted persist.
|
|
*/
|
|
export function persistRefreshedProviderTokensImpl(
|
|
this: any,
|
|
provider: CloudProvider,
|
|
tokens: import('../../../domain/sync').OAuthTokens,
|
|
): void {
|
|
const existing = this.state.providers[provider];
|
|
// Provider may have been disconnected during the async refresh — don't
|
|
// resurrect a connection that no longer has credentials.
|
|
if (!existing?.tokens) return;
|
|
|
|
// Invalidate any in-flight decrypt (startup / cross-window) so it cannot
|
|
// overwrite the rotated tokens we are about to commit.
|
|
++this.providerDecryptSeq[provider];
|
|
this.state.providers[provider] = {
|
|
...existing,
|
|
tokens,
|
|
};
|
|
void this.saveProviderConnection(provider, this.state.providers[provider]);
|
|
this.notifyStateChange();
|
|
}
|
|
|
|
/**
|
|
* Handle a sync error that means OneDrive's refresh token is dead. Clears the
|
|
* now-useless tokens and tears down the adapter so the provider drops to a real
|
|
* "reconnect" (disconnected) state instead of staying `error`-with-tokens —
|
|
* which `isProviderReadyForSync` keeps treating as ready, so auto-sync would
|
|
* otherwise retry the dead token forever and never surface a reconnect prompt.
|
|
*
|
|
* Returns true when it handled a reauth-required OneDrive error so the caller
|
|
* can preserve a clean status message. No-op (returns false) for any other
|
|
* provider or error.
|
|
*/
|
|
export function handleProviderReauthRequiredImpl(
|
|
this: any,
|
|
provider: CloudProvider,
|
|
error: unknown,
|
|
): boolean {
|
|
if (provider !== 'onedrive') return false;
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
if (!isOneDriveReauthRequiredMessage(message)) return false;
|
|
|
|
// Idempotent: this error can surface on multiple paths in one sync (preflight
|
|
// inspection + the operation's own catch). Once the credentials are already
|
|
// cleared there is nothing more to do, but still report handled so callers
|
|
// skip the generic error status that would re-add the raw marker message.
|
|
const current = this.state.providers[provider];
|
|
if (!current?.tokens && !current?.config) return true;
|
|
|
|
const adapter = this.adapters.get(provider);
|
|
if (adapter) {
|
|
adapter.signOut();
|
|
this.adapters.delete(provider);
|
|
}
|
|
|
|
// Bump decrypt seq so a stale in-flight decrypt cannot resurrect the tokens.
|
|
++this.providerDecryptSeq[provider];
|
|
this.state.providers[provider] = {
|
|
provider,
|
|
status: 'error',
|
|
account: current?.account,
|
|
error: cleanOneDriveErrorMessage(message),
|
|
};
|
|
void this.saveProviderConnection(provider, this.state.providers[provider]);
|
|
this.notifyStateChange();
|
|
return true;
|
|
}
|
|
|
|
export async function setupMasterKeyImpl(this: any,password: string): Promise<void> {
|
|
if (this.state.masterKeyConfig) {
|
|
throw new Error('Master key already exists. Use changeMasterKey instead.');
|
|
}
|
|
|
|
const config = await EncryptionService.createMasterKeyConfig(password);
|
|
|
|
this.bumpSyncSecurityGeneration?.();
|
|
this.state.masterKeyConfig = config;
|
|
this.state.securityState = 'LOCKED';
|
|
|
|
this.saveToStorage(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG, config);
|
|
this.emit({ type: 'SECURITY_STATE_CHANGED', state: 'LOCKED' });
|
|
|
|
// Auto-unlock after setup
|
|
await this.unlock(password);
|
|
}
|
|
|
|
export async function unlockImpl(this: any,password: string): Promise<boolean> {
|
|
if (!this.state.masterKeyConfig) {
|
|
throw new Error('No master key configured');
|
|
}
|
|
|
|
if (this.state.securityState === 'UNLOCKED') {
|
|
return true;
|
|
}
|
|
|
|
const unlockedKey = await EncryptionService.unlockMasterKey(
|
|
password,
|
|
this.state.masterKeyConfig
|
|
);
|
|
|
|
if (!unlockedKey) {
|
|
return false;
|
|
}
|
|
|
|
this.state.unlockedKey = unlockedKey;
|
|
this.state.securityState = 'UNLOCKED';
|
|
this.masterPassword = password;
|
|
|
|
this.emit({ type: 'SECURITY_STATE_CHANGED', state: 'UNLOCKED' });
|
|
|
|
// Start auto-sync if enabled
|
|
if (this.state.autoSyncEnabled) {
|
|
this.startAutoSync();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
export function lockImpl(this: any): void {
|
|
if (this.state.securityState !== 'UNLOCKED') {
|
|
return;
|
|
}
|
|
|
|
// Clear sensitive data from memory
|
|
this.bumpSyncSecurityGeneration?.();
|
|
this.state.unlockedKey = null;
|
|
this.masterPassword = null;
|
|
this.state.securityState = 'LOCKED';
|
|
|
|
// Stop auto-sync
|
|
this.stopAutoSync();
|
|
|
|
this.emit({ type: 'SECURITY_STATE_CHANGED', state: 'LOCKED' });
|
|
}
|
|
|
|
export async function changeMasterKeyImpl(this: any,oldPassword: string, newPassword: string): Promise<boolean> {
|
|
if (!this.state.masterKeyConfig) {
|
|
throw new Error('No master key configured');
|
|
}
|
|
|
|
const newConfig = await EncryptionService.changeMasterPassword(
|
|
oldPassword,
|
|
newPassword,
|
|
this.state.masterKeyConfig
|
|
);
|
|
|
|
if (!newConfig) {
|
|
return false;
|
|
}
|
|
|
|
this.bumpSyncSecurityGeneration?.();
|
|
this.state.masterKeyConfig = newConfig;
|
|
this.state.securityState = 'UNLOCKED';
|
|
this.masterPassword = newPassword;
|
|
|
|
// Re-derive key with new password
|
|
this.state.unlockedKey = await EncryptionService.unlockMasterKey(
|
|
newPassword,
|
|
newConfig
|
|
);
|
|
|
|
this.saveToStorage(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG, newConfig);
|
|
|
|
// Notify UI and restart auto-sync (actual re-upload requires a payload from app state)
|
|
this.emit({ type: 'SECURITY_STATE_CHANGED', state: 'UNLOCKED' });
|
|
if (this.state.autoSyncEnabled) {
|
|
this.startAutoSync();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
export async function verifyPasswordImpl(this: any,password: string): Promise<boolean> {
|
|
if (!this.state.masterKeyConfig) {
|
|
return false;
|
|
}
|
|
return EncryptionService.verifyPassword(password, this.state.masterKeyConfig);
|
|
}
|