mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8bc61562d | ||
|
|
7a677afd9c | ||
|
|
e66ece365d | ||
|
|
b9d24020c8 | ||
|
|
aa50aa8113 | ||
|
|
dab4fe6904 | ||
|
|
48b1afde85 | ||
|
|
f359e6c42b | ||
|
|
6de510c0c7 | ||
|
|
cee77d5dd8 | ||
|
|
62391fd98f | ||
|
|
f9e3999c66 |
No files matched your search
@@ -0,0 +1,65 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import React from 'react';
|
||||
import { createDomRenderer, flushEffects, installDomEnvironment } from '../../components/test-support/renderReactDom';
|
||||
import { EncryptionService } from '../../infrastructure/services/EncryptionService';
|
||||
import type { SyncManagerState } from '../../infrastructure/services/CloudSyncManager';
|
||||
import type { SyncPayload } from '../../domain/sync';
|
||||
|
||||
test('manual sync does not clear a newly shared password when an older unlock is superseded', async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: env.window.localStorage });
|
||||
t.after(() => {
|
||||
if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
});
|
||||
let sharedPassword: string | undefined;
|
||||
let clears = 0;
|
||||
Object.defineProperty(env.window, 'netcatty', { configurable: true, value: {
|
||||
cloudSyncGetSessionPassword: async () => sharedPassword,
|
||||
cloudSyncClearSessionPassword: async () => { clears += 1; sharedPassword = undefined; },
|
||||
} });
|
||||
const { getCloudSyncManager } = await import('../../infrastructure/services/CloudSyncManager');
|
||||
const manager = getCloudSyncManager();
|
||||
await manager.setupMasterKey('old-fixture-password');
|
||||
manager.lock();
|
||||
const newConfig = await EncryptionService.createMasterKeyConfig('new-fixture-password');
|
||||
const { useCloudSync } = await import('./useCloudSync');
|
||||
let current!: ReturnType<typeof useCloudSync>;
|
||||
const Probe = () => { current = useCloudSync(); return null; };
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
const originalUnlock = EncryptionService.unlockMasterKey;
|
||||
let release!: () => void;
|
||||
let started!: () => void;
|
||||
const gate = new Promise<void>((resolve) => { release = resolve; });
|
||||
const entered = new Promise<void>((resolve) => { started = resolve; });
|
||||
try {
|
||||
await renderer.render(<Probe />);
|
||||
await flushEffects();
|
||||
EncryptionService.unlockMasterKey = async (...args) => {
|
||||
started();
|
||||
await gate;
|
||||
return originalUnlock(...args);
|
||||
};
|
||||
sharedPassword = 'old-fixture-password';
|
||||
const result = current.syncNow({} as SyncPayload).then(() => null, (error: unknown) => error);
|
||||
await entered;
|
||||
const state = Reflect.get(manager, 'state') as SyncManagerState;
|
||||
state.masterKeyConfig = newConfig;
|
||||
Reflect.get(manager, 'bumpSyncSecurityGeneration').call(manager);
|
||||
sharedPassword = 'new-fixture-password';
|
||||
release();
|
||||
const error = await result;
|
||||
assert.equal(clears, 0, 'stale work must not erase the new shared password');
|
||||
assert.equal(sharedPassword, 'new-fixture-password');
|
||||
assert.ok(error instanceof Error);
|
||||
assert.match(error.message, /changed while unlocking/i);
|
||||
} finally {
|
||||
release();
|
||||
EncryptionService.unlockMasterKey = originalUnlock;
|
||||
await renderer.unmount();
|
||||
manager.destroy();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
* Uses useSyncExternalStore for real-time state synchronization across all components.
|
||||
*/
|
||||
|
||||
import { useCloudSyncAutoUnlock } from './useCloudSyncAutoUnlock';
|
||||
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
import {
|
||||
type CloudProvider,
|
||||
@@ -266,42 +267,14 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
getConvergentSyncLocalConfigSnapshot,
|
||||
);
|
||||
|
||||
// Auto-unlock: if a master key exists, retrieve the persisted password (Electron safeStorage)
|
||||
// and unlock silently so users don't have to manage a LOCKED state in the UI.
|
||||
// Track the master key config hash to detect when a new master key is set up in another window.
|
||||
const lastMasterKeyHashRef = useRef<string | null>(null);
|
||||
const attemptedAutoUnlockRef = useRef(false);
|
||||
useEffect(() => {
|
||||
// Compute a simple hash of the master key config to detect changes
|
||||
const currentHash = state.masterKeyConfig
|
||||
? JSON.stringify({ salt: state.masterKeyConfig.salt, kdf: state.masterKeyConfig.kdf })
|
||||
: null;
|
||||
|
||||
// If master key config changed (e.g., set up in settings window), reset the attempt flag
|
||||
if (currentHash !== lastMasterKeyHashRef.current) {
|
||||
lastMasterKeyHashRef.current = currentHash;
|
||||
attemptedAutoUnlockRef.current = false;
|
||||
}
|
||||
|
||||
if (attemptedAutoUnlockRef.current) return;
|
||||
if (state.securityState !== 'LOCKED') return;
|
||||
attemptedAutoUnlockRef.current = true;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
const password = await bridge?.cloudSyncGetSessionPassword?.();
|
||||
if (!password) return;
|
||||
|
||||
const ok = await manager.unlock(password);
|
||||
if (!ok) {
|
||||
void bridge?.cloudSyncClearSessionPassword?.();
|
||||
}
|
||||
} catch {
|
||||
// Ignore auto-unlock errors; manual actions will surface them.
|
||||
}
|
||||
})();
|
||||
}, [state.securityState, state.masterKeyConfig]);
|
||||
useCloudSyncAutoUnlock({
|
||||
securityState: state.securityState,
|
||||
masterKeyIdentity: state.masterKeyConfig
|
||||
? JSON.stringify(state.masterKeyConfig)
|
||||
: null,
|
||||
manager,
|
||||
bridge: netcattyBridge.get(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (state.securityState !== 'UNLOCKED') return;
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { createDomRenderer, flushEffects, installDomEnvironment, runWithAct } from '../../components/test-support/renderReactDom.tsx';
|
||||
import { useCloudSyncAutoUnlock } from './useCloudSyncAutoUnlock.ts';
|
||||
|
||||
for (const strict of [false, true]) {
|
||||
test(`pending password read is handed off when its owner unmounts (StrictMode=${strict})`, async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const pending: Array<(value: string | null) => void> = [];
|
||||
let unlocks = 0;
|
||||
const manager = { unlock: async () => { unlocks++; return true; } };
|
||||
const bridge = { cloudSyncGetSessionPassword: () => new Promise<string | null>(resolve => { pending.push(resolve); }) };
|
||||
function Consumer() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'handoff-key', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
function tree(owner: boolean) {
|
||||
return React.createElement(strict ? React.StrictMode : React.Fragment, null,
|
||||
owner ? React.createElement(Consumer, { key: 'owner' }) : null,
|
||||
React.createElement(Consumer, { key: 'peer' }));
|
||||
}
|
||||
try {
|
||||
await renderer.render(tree(true));
|
||||
await flushEffects();
|
||||
assert.ok(pending.length > 0);
|
||||
await renderer.render(tree(false));
|
||||
await flushEffects();
|
||||
await runWithAct(async () => { for (const resolve of pending) resolve('fixture-only'); });
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1, 'cancelled owners must not unlock when their old responses arrive');
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const strict of [false, true]) {
|
||||
test(`peer auto-unlocks when password arrives after the config (StrictMode=${strict})`, async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let password: string | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
const unlocked: string[] = [];
|
||||
const manager = { unlock: async (value: string) => { unlocked.push(value); return true; } };
|
||||
const bridge = {
|
||||
cloudSyncGetSessionPassword: async () => password,
|
||||
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; },
|
||||
};
|
||||
function Harness() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'first-key', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await renderer.render(strict ? React.createElement(React.StrictMode, null, React.createElement(Harness)) : React.createElement(Harness));
|
||||
await flushEffects();
|
||||
assert.deepEqual(unlocked, [], 'the initial read really happened before the password existed');
|
||||
await runWithAct(async () => { password = 'fixture-only'; for (const cb of listeners) cb(); });
|
||||
await flushEffects();
|
||||
assert.deepEqual(unlocked, ['fixture-only']);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
assert.equal(listeners.size, 0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const strict of [false, true]) {
|
||||
test(`a failed attempt during key rotation keeps the password for the arriving config (StrictMode=${strict})`, async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let password: string | null = null;
|
||||
let cleared = 0;
|
||||
let currentIdentity = 'old-key';
|
||||
const listeners = new Set<() => void>();
|
||||
const unlocked: Array<[string, string]> = [];
|
||||
const manager = {
|
||||
unlock: async (value: string) => {
|
||||
unlocked.push([currentIdentity, value]);
|
||||
return currentIdentity === 'new-key';
|
||||
},
|
||||
};
|
||||
const bridge = {
|
||||
cloudSyncGetSessionPassword: async () => password,
|
||||
cloudSyncClearSessionPassword: async () => { cleared++; return true; },
|
||||
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; },
|
||||
};
|
||||
function Harness() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: currentIdentity, manager, bridge });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await renderer.render(strict ? React.createElement(React.StrictMode, null, React.createElement(Harness)) : React.createElement(Harness));
|
||||
await flushEffects();
|
||||
// The password notification overtakes the config event: the peer is
|
||||
// still locked on the old config when the new password arrives.
|
||||
await runWithAct(async () => { password = 'rotation-password'; for (const cb of listeners) cb(); });
|
||||
await flushEffects();
|
||||
assert.deepEqual(unlocked, [['old-key', 'rotation-password']], 'the new password was tried against the old config');
|
||||
assert.equal(cleared, 0, 'the failed attempt must not clear the shared password');
|
||||
// The config event arrives afterwards; the retained password must
|
||||
// still be available to auto-unlock the rotated key.
|
||||
currentIdentity = 'new-key';
|
||||
await renderer.render(strict ? React.createElement(React.StrictMode, null, React.createElement(Harness)) : React.createElement(Harness));
|
||||
await flushEffects();
|
||||
assert.deepEqual(unlocked, [
|
||||
['old-key', 'rotation-password'],
|
||||
['new-key', 'rotation-password'],
|
||||
]);
|
||||
assert.equal(cleared, 0);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
assert.equal(listeners.size, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('completed auto-unlock does not undo a later deliberate lock', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let calls = 0;
|
||||
const manager = { unlock: async () => { calls++; return true; } };
|
||||
let onAvailable: (() => void) | undefined;
|
||||
const bridge = {
|
||||
cloudSyncGetSessionPassword: async () => 'fixture-only',
|
||||
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { onAvailable = cb; return () => { onAvailable = undefined; }; },
|
||||
};
|
||||
function Harness({ state, identity = 'key' }: { state: string; identity?: string }) {
|
||||
useCloudSyncAutoUnlock({ securityState: state, masterKeyIdentity: identity, manager, bridge });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await renderer.render(React.createElement(Harness, { state: 'LOCKED' }));
|
||||
await flushEffects();
|
||||
await renderer.render(React.createElement(Harness, { state: 'UNLOCKED' }));
|
||||
await runWithAct(async () => { onAvailable?.(); });
|
||||
await renderer.render(React.createElement(Harness, { state: 'LOCKED' }));
|
||||
await flushEffects();
|
||||
assert.equal(calls, 1);
|
||||
await runWithAct(async () => { onAvailable?.(); });
|
||||
await flushEffects();
|
||||
assert.equal(calls, 1, 'a peer password event after deliberate lock must not reopen this vault');
|
||||
await renderer.render(null);
|
||||
await renderer.render(React.createElement(Harness, { state: 'LOCKED' }));
|
||||
await runWithAct(async () => { onAvailable?.(); });
|
||||
await flushEffects();
|
||||
assert.equal(calls, 1, 'a remounted consumer must retain the manager deliberate lock');
|
||||
await renderer.render(React.createElement(Harness, { state: 'LOCKED', identity: 'replacement-key' }));
|
||||
await flushEffects();
|
||||
assert.equal(calls, 2, 'a genuinely replaced key can still initialize automatically');
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
|
||||
for (const strict of [false, true]) {
|
||||
test(`remount reads a password shared while no consumer was mounted (StrictMode=${strict})`, async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let password: string | null = null;
|
||||
let reads = 0;
|
||||
const unlocked: string[] = [];
|
||||
const manager = { unlock: async (value: string) => { unlocked.push(value); return true; } };
|
||||
const bridge = { cloudSyncGetSessionPassword: async () => { reads++; return password; } };
|
||||
function Consumer() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'remount-key', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
const tree = () => React.createElement(strict ? React.StrictMode : React.Fragment, null, React.createElement(Consumer));
|
||||
try {
|
||||
await renderer.render(tree());
|
||||
await flushEffects();
|
||||
assert.ok(reads > 0);
|
||||
assert.deepEqual(unlocked, []);
|
||||
await renderer.render(null);
|
||||
await flushEffects();
|
||||
password = 'fixture-shared-while-absent';
|
||||
await renderer.render(tree());
|
||||
await flushEffects();
|
||||
assert.deepEqual(unlocked, ['fixture-shared-while-absent']);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
}
|
||||
|
||||
test('remount during an active derivation does not start a duplicate unlock', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let finish!: (value: boolean) => void;
|
||||
let unlocks = 0;
|
||||
const manager = { unlock: () => { unlocks++; return new Promise<boolean>(resolve => { finish = resolve; }); } };
|
||||
const bridge = { cloudSyncGetSessionPassword: async () => 'fixture-only' };
|
||||
function Consumer() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'active-key', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await renderer.render(React.createElement(Consumer));
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1);
|
||||
await renderer.render(null);
|
||||
await renderer.render(React.createElement(Consumer));
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1);
|
||||
await runWithAct(async () => finish(true));
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
|
||||
test('two StrictMode consumers share one derivation after initial effect replay', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const pending: Array<(value: string | null) => void> = [];
|
||||
let unlocks = 0;
|
||||
const manager = { unlock: async () => { unlocks++; return true; } };
|
||||
const bridge = { cloudSyncGetSessionPassword: () => new Promise<string | null>(resolve => pending.push(resolve)) };
|
||||
function Consumer() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'strict-shared', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await renderer.render(React.createElement(React.StrictMode, null,
|
||||
React.createElement(Consumer, { key: 'one' }), React.createElement(Consumer, { key: 'two' })));
|
||||
await flushEffects();
|
||||
await runWithAct(async () => { for (const resolve of pending) resolve('fixture-only'); });
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
|
||||
test('password notification and a later peer mount share the same attempt', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let password: string | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
let unlocks = 0;
|
||||
const manager = { unlock: async () => { unlocks++; return false; } };
|
||||
const bridge = {
|
||||
cloudSyncGetSessionPassword: async () => password,
|
||||
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; },
|
||||
};
|
||||
function Consumer() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'notification-key', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
const tree = (late: boolean) => React.createElement(React.Fragment, null,
|
||||
React.createElement(Consumer, { key: 'one' }), React.createElement(Consumer, { key: 'two' }),
|
||||
late ? React.createElement(Consumer, { key: 'late' }) : null);
|
||||
try {
|
||||
await renderer.render(tree(false));
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 0);
|
||||
await runWithAct(async () => { password = 'fixture-only'; for (const cb of listeners) cb(); });
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1);
|
||||
await renderer.render(tree(true));
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1, 'new consumers share the notification generation');
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface AutoUnlockManager {
|
||||
unlock(password: string): Promise<boolean>;
|
||||
}
|
||||
interface AutoUnlockBridge {
|
||||
cloudSyncGetSessionPassword?(): Promise<string | null>;
|
||||
onCloudSyncSessionPasswordAvailable?(callback: () => void): () => void;
|
||||
}
|
||||
|
||||
// A renderer can mount several consumers of the same manager. Remember the
|
||||
// unlocked key across consumer unmounts so reopening settings cannot undo a lock.
|
||||
const unlockedIdentityByManager = new WeakMap<AutoUnlockManager, string>();
|
||||
// Dedupe attempts per manager (not per hook instance): every consumer of the
|
||||
// same manager observes the same events, and without sharing they would each
|
||||
// run the expensive PBKDF2 derivation inside manager.unlock.
|
||||
const attemptedAttemptByManager = new WeakMap<AutoUnlockManager, string | null>();
|
||||
const passwordRevisionByManager = new WeakMap<AutoUnlockManager, number>();
|
||||
const completedAttemptByManager = new WeakMap<AutoUnlockManager, string>();
|
||||
// Mounted consumers register a retry trigger here so an attempt can be handed
|
||||
// off when its owner unmounts (see the cleanup below).
|
||||
const retryListenersByManager = new WeakMap<AutoUnlockManager, Set<() => void>>();
|
||||
|
||||
/** Retry a locked peer window when the setting window finishes sharing its key. */
|
||||
export function useCloudSyncAutoUnlock(input: {
|
||||
securityState: string;
|
||||
masterKeyIdentity: string | null;
|
||||
manager: AutoUnlockManager;
|
||||
bridge: AutoUnlockBridge | null | undefined;
|
||||
}) {
|
||||
const { securityState, masterKeyIdentity, manager, bridge } = input;
|
||||
const [retryRevision, setRetryRevision] = useState(0);
|
||||
|
||||
useEffect(() => bridge?.onCloudSyncSessionPasswordAvailable?.(() => {
|
||||
passwordRevisionByManager.set(manager, (passwordRevisionByManager.get(manager) ?? 0) + 1);
|
||||
setRetryRevision(value => value + 1);
|
||||
}), [bridge, manager]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!masterKeyIdentity) return;
|
||||
const attempt = JSON.stringify([masterKeyIdentity, passwordRevisionByManager.get(manager) ?? 0]);
|
||||
if (securityState === 'UNLOCKED') {
|
||||
unlockedIdentityByManager.set(manager, masterKeyIdentity);
|
||||
attemptedAttemptByManager.set(manager, attempt);
|
||||
return;
|
||||
}
|
||||
if (securityState !== 'LOCKED') return;
|
||||
// Once this key has been unlocked, a later lock is intentional. A peer
|
||||
// sharing its password must not undo it, regardless of notification order.
|
||||
if (unlockedIdentityByManager.get(manager) === masterKeyIdentity) return;
|
||||
// Register before the dedup check: a consumer that only observes an
|
||||
// in-flight attempt must still be reachable for an ownership handoff.
|
||||
let listeners = retryListenersByManager.get(manager);
|
||||
if (!listeners) {
|
||||
listeners = new Set();
|
||||
retryListenersByManager.set(manager, listeners);
|
||||
}
|
||||
const notifyRetry = () => setRetryRevision(value => value + 1);
|
||||
listeners.add(notifyRetry);
|
||||
const removeListener = () => {
|
||||
listeners.delete(notifyRetry);
|
||||
if (listeners.size === 0 && completedAttemptByManager.get(manager) === attemptedAttemptByManager.get(manager)) {
|
||||
attemptedAttemptByManager.delete(manager);
|
||||
completedAttemptByManager.delete(manager);
|
||||
}
|
||||
};
|
||||
if (attemptedAttemptByManager.get(manager) === attempt) {
|
||||
return removeListener;
|
||||
}
|
||||
attemptedAttemptByManager.set(manager, attempt);
|
||||
completedAttemptByManager.delete(manager);
|
||||
let handedOff = false;
|
||||
let cancelled = false;
|
||||
let awaitingPassword = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const password = await bridge?.cloudSyncGetSessionPassword?.();
|
||||
awaitingPassword = false;
|
||||
if (cancelled || !password) return;
|
||||
// A failed attempt must not clear the shared password: the config and
|
||||
// the password travel over unordered channels (localStorage vs IPC),
|
||||
// so this attempt may have raced a master-key rotation and the
|
||||
// password may match the config that is still in flight. The attempt
|
||||
// is deduped per [identity, revision], so keeping the password lets
|
||||
// the arriving config trigger a retry instead of stranding the vault.
|
||||
await manager.unlock(password);
|
||||
} catch {
|
||||
// Explicit sync actions surface errors; keep auto-unlock silent.
|
||||
} finally {
|
||||
if (!handedOff && attemptedAttemptByManager.get(manager) === attempt) {
|
||||
completedAttemptByManager.set(manager, attempt);
|
||||
// Availability events can arrive while no consumer is mounted.
|
||||
// A future mount must read again after this attempt has settled.
|
||||
if (listeners.size === 0) {
|
||||
attemptedAttemptByManager.delete(manager);
|
||||
completedAttemptByManager.delete(manager);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
removeListener();
|
||||
// React StrictMode can immediately remount the same effect.
|
||||
if (awaitingPassword && attemptedAttemptByManager.get(manager) === attempt) {
|
||||
handedOff = true;
|
||||
attemptedAttemptByManager.set(manager, null);
|
||||
// Hand the attempt off: this consumer's in-flight password request is
|
||||
// discarded as cancelled, but other mounted consumers already returned
|
||||
// at the dedup check above, so they must be nudged to retry.
|
||||
for (const notify of listeners) notify();
|
||||
}
|
||||
};
|
||||
}, [securityState, masterKeyIdentity, manager, bridge, retryRevision]);
|
||||
}
|
||||
@@ -789,6 +789,13 @@ function createBridgeRegistrar(context) {
|
||||
cloudSyncSessionPassword = typeof password === "string" && password.length ? password : null;
|
||||
if (cloudSyncSessionPassword) {
|
||||
persistCloudSyncPassword(cloudSyncSessionPassword);
|
||||
// Peer renderers may have observed MASTER_KEY_CONFIG before the
|
||||
// settings renderer finished unlocking and shared this password.
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed() && !win.webContents.isDestroyed()) {
|
||||
win.webContents.send("netcatty:cloudSync:session:passwordAvailable");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
clearPersistedCloudSyncPassword();
|
||||
}
|
||||
|
||||
@@ -1176,6 +1176,11 @@ function createPreloadApi(ctx) {
|
||||
// Cloud sync session (in-memory only, shared across windows)
|
||||
cloudSyncSetSessionPassword: (password) =>
|
||||
ipcRenderer.invoke("netcatty:cloudSync:session:setPassword", password),
|
||||
onCloudSyncSessionPasswordAvailable: (callback) => {
|
||||
const handler = () => callback();
|
||||
ipcRenderer.on("netcatty:cloudSync:session:passwordAvailable", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:cloudSync:session:passwordAvailable", handler);
|
||||
},
|
||||
cloudSyncGetSessionPassword: () =>
|
||||
ipcRenderer.invoke("netcatty:cloudSync:session:getPassword"),
|
||||
cloudSyncClearSessionPassword: () =>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { createPreloadApi } = require('./api.cjs');
|
||||
|
||||
test('password-availability subscription exposes no IPC payload and unsubscribes', () => {
|
||||
const ipcRenderer = new EventEmitter();
|
||||
const api = createPreloadApi({ ipcRenderer, webUtils: {} });
|
||||
const calls = [];
|
||||
const unsubscribe = api.onCloudSyncSessionPasswordAvailable((...args) => calls.push(args));
|
||||
ipcRenderer.emit('netcatty:cloudSync:session:passwordAvailable', { sender: 'private' }, 'must-not-forward');
|
||||
assert.deepEqual(calls, [[]]);
|
||||
unsubscribe();
|
||||
ipcRenderer.emit('netcatty:cloudSync:session:passwordAvailable', {});
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(ipcRenderer.listenerCount('netcatty:cloudSync:session:passwordAvailable'), 0);
|
||||
});
|
||||
@@ -920,13 +920,34 @@ export async function unlockImpl(this: any,password: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The master key configuration can rotate while the derivation below is
|
||||
// in flight (e.g. a peer window re-encrypts with the same password and
|
||||
// the storage event lands mid-await). Capture the config and security
|
||||
// generation up front so a late-completing call cannot install a key
|
||||
// derived from a stale configuration over the active one.
|
||||
const configAtStart = this.state.masterKeyConfig;
|
||||
const generationAtStart = this.getSyncSecurityGeneration?.();
|
||||
|
||||
const unlockedKey = await EncryptionService.unlockMasterKey(
|
||||
password,
|
||||
this.state.masterKeyConfig
|
||||
configAtStart
|
||||
);
|
||||
|
||||
if (!unlockedKey) {
|
||||
return false;
|
||||
// Superseded derivations are not password failures. Callers discard the
|
||||
// shared session password only after false, so report stale work as an
|
||||
// error before interpreting either a valid or invalid old-config result.
|
||||
if (
|
||||
this.state.masterKeyConfig !== configAtStart
|
||||
|| (generationAtStart !== undefined
|
||||
&& this.getSyncSecurityGeneration?.() !== generationAtStart)
|
||||
|| (this.state.securityState !== 'LOCKED' && this.state.securityState !== 'UNLOCKED')
|
||||
) {
|
||||
throw new Error('Vault changed while unlocking. Try again.');
|
||||
}
|
||||
if (!unlockedKey) return false;
|
||||
if (this.state.securityState === 'UNLOCKED') {
|
||||
// Another unlock with the same config won the race; nothing to install.
|
||||
return true;
|
||||
}
|
||||
|
||||
this.state.unlockedKey = unlockedKey;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EncryptionService } from '../EncryptionService';
|
||||
import { unlockImpl } from './stateAndSecurityMethods';
|
||||
|
||||
for (const scenario of ['rotated-valid-password', 'rotated-invalid-old-password', 'locked-during-unlock'] as const) {
|
||||
test(`unlock rejects a superseded request instead of reporting a bad password: ${scenario}`, async () => {
|
||||
const oldConfig = await EncryptionService.createMasterKeyConfig('old-fixture-password');
|
||||
const newConfig = await EncryptionService.createMasterKeyConfig('new-fixture-password');
|
||||
let generation = 0;
|
||||
const manager = {
|
||||
state: { masterKeyConfig: oldConfig, securityState: 'LOCKED', unlockedKey: null },
|
||||
getSyncSecurityGeneration: () => generation,
|
||||
};
|
||||
const pending = unlockImpl.call(manager,
|
||||
scenario === 'rotated-invalid-old-password' ? 'new-fixture-password' : 'old-fixture-password');
|
||||
if (scenario !== 'locked-during-unlock') manager.state.masterKeyConfig = newConfig;
|
||||
generation += 1;
|
||||
await assert.rejects(pending, /changed while unlocking/i);
|
||||
assert.equal(manager.state.securityState, 'LOCKED');
|
||||
assert.equal(manager.state.unlockedKey, null);
|
||||
});
|
||||
}
|
||||
|
||||
test('a wrong password for the current configuration remains an ordinary verification failure', async () => {
|
||||
const config = await EncryptionService.createMasterKeyConfig('correct-fixture-password');
|
||||
const manager = { state: { masterKeyConfig: config, securityState: 'LOCKED', unlockedKey: null } };
|
||||
assert.equal(await unlockImpl.call(manager, 'wrong-fixture-password'), false);
|
||||
assert.equal(manager.state.unlockedKey, null);
|
||||
});
|
||||
+1
@@ -102,6 +102,7 @@ declare global {
|
||||
// Cloud sync master password (stored in-memory + persisted via Electron safeStorage)
|
||||
cloudSyncSetSessionPassword?(password: string): Promise<boolean>;
|
||||
cloudSyncGetSessionPassword?(): Promise<string | null>;
|
||||
onCloudSyncSessionPasswordAvailable?(callback: () => void): () => void;
|
||||
cloudSyncClearSessionPassword?(): Promise<boolean>;
|
||||
|
||||
// Cloud sync network operations (proxied via main process)
|
||||
|
||||
Reference in new issue
Block a user