mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 06:22:35 +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]);
|
||||
}
|
||||
@@ -1662,13 +1662,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
return false;
|
||||
}
|
||||
|
||||
if (e.type === "keypress" && broadcastForwardedKeys.has(kittyKeyIdentity(e))) {
|
||||
// Space and uppercase letters can emit xterm data from keypress, after
|
||||
// keydown's zero-delay cleanup. Rejoin that physical broadcast instead
|
||||
// of sending the same character a second time as unpaired raw text.
|
||||
markBroadcastLegacyDataPending(kittyKeyIdentity(e));
|
||||
}
|
||||
|
||||
if (e.type !== "keydown") {
|
||||
return true;
|
||||
}
|
||||
@@ -2430,10 +2423,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
});
|
||||
return;
|
||||
}
|
||||
// insertText can follow an already-delivered keypress. Its fallback marker
|
||||
// must not classify the next physical key as a second composition commit.
|
||||
// Actual composition/input handlers clear the physical-key pairing first.
|
||||
if (broadcastLegacyDataPending) kittyCompositionPending = false;
|
||||
if (kittyCompositionPending && !data.startsWith("\u001b")) {
|
||||
kittyCompositionPending = false;
|
||||
if (kittyCompositionClearTimer !== undefined) {
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import ts from "typescript";
|
||||
import { resolveKittyKeyboardBroadcastInput, type KittyKeyboardBroadcastInput } from "./kittyKeyboardBroadcast";
|
||||
import { createKittyKeyboardModeState, setKittyKeyboardModeFlags, shouldEncodeKittyCompositionText, shouldMarkKittyTextInputEvent, encodeKittyCompositionText } from "./kittyKeyboardProtocol";
|
||||
import { shouldBlockKeyPressForImeTextInput, shouldCommitDeferredImeTextInput } from "./terminalImeTextInput";
|
||||
import { sanitizeTerminalInput } from "./terminalInputSanitize";
|
||||
|
||||
// Execute the actual registered key/data callbacks, without constructing the
|
||||
// renderer and its WebGL/addon stack. In particular, retain their real timer
|
||||
// cleanup and raw-broadcast suppression rather than reproducing that logic here.
|
||||
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
function section(start: string, end: string): string {
|
||||
const from = source.indexOf(start);
|
||||
const to = source.indexOf(end, from);
|
||||
assert.ok(from >= 0 && to > from, `runtime section missing: ${start}`);
|
||||
return source.slice(from, to);
|
||||
}
|
||||
const declarations = section(" let suppressNextTerminalDataBroadcast =", " const broadcastKittyInput =");
|
||||
const keyboardCallback = section(" term.attachCustomKeyEventHandler((e: KeyboardEvent) => {", " const handleMiddleClick =");
|
||||
const dataCallback = section(" term.onData((data) => {", " const handleKittyKeyboardBroadcast =");
|
||||
const compositionMarker = section(" const markKittyCompositionPending =", " const finishKittyComposition =");
|
||||
const textInputMarker = section(" const markKittyTextInput =", ' textarea?.addEventListener("compositionstart"');
|
||||
const suppression = section(" const suppressTerminalBroadcast =", " // skipBroadcast");
|
||||
const callbackCode = ts.transpileModule(`
|
||||
${declarations}
|
||||
let win32InputModePendingEvent = null;
|
||||
let kittyCompositionPending = false;
|
||||
let kittyCompositionClearTimer;
|
||||
const handleTerminalInputData = (data) => {
|
||||
const inputSource = "terminal";
|
||||
${suppression}
|
||||
if (!suppressTerminalBroadcast) raw(data);
|
||||
};
|
||||
const textarea = null;
|
||||
${compositionMarker}
|
||||
${textInputMarker}
|
||||
${keyboardCallback}
|
||||
${dataCallback}
|
||||
globalThis.controls = { mark: markBroadcastLegacyDataPending, clear: clearBroadcastLegacyDataPending, input: markKittyTextInput };
|
||||
`, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText;
|
||||
|
||||
function setup(flags = 0) {
|
||||
const writes: string[] = [];
|
||||
const timers = new Map<number, () => void>();
|
||||
let timerId = 0;
|
||||
let receive!: (data: string) => void;
|
||||
let keyboard!: (event: Partial<KeyboardEvent>) => boolean;
|
||||
const forwarded = new Map<string, { targetSessionIds: string[] }>();
|
||||
const mode = createKittyKeyboardModeState();
|
||||
setKittyKeyboardModeFlags(mode, flags);
|
||||
const options = {
|
||||
kittyProtocolEnabled: flags !== 0, kittyMode: mode, applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(), legacySuppressedKeys: new Set<string>(),
|
||||
};
|
||||
const normalized = (input: KittyKeyboardBroadcastInput) => {
|
||||
const result = resolveKittyKeyboardBroadcastInput(input, options);
|
||||
if (result) writes.push(result.data);
|
||||
};
|
||||
const context = {
|
||||
controls: undefined as unknown as { mark: (identity: string) => void; clear: () => void; input: (event: { data: string; inputType: string }) => void },
|
||||
window: {
|
||||
setTimeout(fn: () => void) { const id = ++timerId; timers.set(id, fn); return id; },
|
||||
clearTimeout(id: number) { timers.delete(id); },
|
||||
},
|
||||
term: {
|
||||
modes: { win32InputMode: false },
|
||||
onData(fn: typeof receive) { receive = fn; },
|
||||
attachCustomKeyEventHandler(fn: typeof keyboard) { keyboard = fn; },
|
||||
},
|
||||
ctx: { terminalSettingsRef: { current: {} }, isBroadcastEnabledRef: { current: true }, onBroadcastInputRef: { current: () => undefined } },
|
||||
imeTextInputDeferredKey: null, imeTextInputDeferredKittyEvent: null,
|
||||
shouldBlockKeyPressForImeTextInput, shouldCommitDeferredImeTextInput, shouldMarkKittyTextInputEvent, shouldEncodeKittyCompositionText, encodeKittyCompositionText,
|
||||
kittyKeyboardMode: createKittyKeyboardModeState(), shouldSplitImeTextInputForWire: () => false,
|
||||
kittyKeyIdentity: (event: Partial<KeyboardEvent>) => event.code || event.key,
|
||||
broadcastForwardedKeys: forwarded,
|
||||
broadcastKittyInput: normalized,
|
||||
sanitizeTerminalInput,
|
||||
shouldSplitRawPasteInputForWire: () => false,
|
||||
raw: (data: string) => writes.push(data),
|
||||
};
|
||||
runInNewContext(callbackCode, context);
|
||||
return {
|
||||
writes, receive: (data: string) => receive(data), controls: context.controls,
|
||||
keypress: (key: string, code: string) => keyboard({ type: "keypress", key, code }),
|
||||
press(key: string, code: string) {
|
||||
normalized({ kind: "key", event: { type: "keydown", key, code }, fallbackToLegacy: true });
|
||||
forwarded.set(code, { targetSessionIds: ["target"] });
|
||||
context.controls.mark(code);
|
||||
},
|
||||
flushTimers() { for (const [id, fn] of timers) { timers.delete(id); fn(); } },
|
||||
release(key: string, code: string) {
|
||||
normalized({ kind: "key", event: { type: "keyup", key, code }, fallbackToLegacy: true });
|
||||
forwarded.delete(code);
|
||||
context.controls.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const flags of [0, 8]) {
|
||||
for (const [key, code] of [["A", "KeyA"], [" ", "Space"]]) {
|
||||
test(`broadcast pairs delayed ${code} keypress once with target flags ${flags}`, () => {
|
||||
const runtime = setup(flags);
|
||||
runtime.press(key, code);
|
||||
const firstWrite = runtime.writes.join("");
|
||||
assert.ok(firstWrite);
|
||||
runtime.flushTimers();
|
||||
assert.equal(runtime.keypress(key, code), true);
|
||||
runtime.receive(key);
|
||||
assert.equal(runtime.writes.join(""), firstWrite, "the source's later text must not duplicate its physical key broadcast");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("repeated physical presses each reach the broadcast target once", () => {
|
||||
const runtime = setup();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
runtime.press("A", "KeyA");
|
||||
runtime.flushTimers();
|
||||
runtime.keypress("A", "KeyA");
|
||||
runtime.receive("A");
|
||||
}
|
||||
assert.equal(runtime.writes.join(""), "AAA");
|
||||
});
|
||||
|
||||
test("keypress without an earlier broadcast and text after release are not swallowed", () => {
|
||||
const runtime = setup();
|
||||
runtime.keypress("B", "KeyB");
|
||||
runtime.receive("B");
|
||||
runtime.press("A", "KeyA");
|
||||
runtime.flushTimers();
|
||||
runtime.keypress("A", "KeyA");
|
||||
runtime.receive("A");
|
||||
runtime.release("A", "KeyA");
|
||||
runtime.receive("paste");
|
||||
assert.equal(runtime.writes.join(""), "BApaste");
|
||||
});
|
||||
|
||||
test("unmatched keypress cleanup still permits a later paste", () => {
|
||||
const runtime = setup();
|
||||
runtime.press("A", "KeyA");
|
||||
runtime.flushTimers();
|
||||
runtime.keypress("A", "KeyA");
|
||||
runtime.flushTimers();
|
||||
runtime.receive("paste");
|
||||
assert.equal(runtime.writes.join(""), "Apaste");
|
||||
});
|
||||
|
||||
test("trailing insertText for Space does not turn the next physical key into duplicate composition text", () => {
|
||||
const runtime = setup();
|
||||
for (const [key, code] of [[" ", "Space"], ["A", "KeyA"], ["B", "KeyB"], ["C", "KeyC"]]) {
|
||||
runtime.press(key, code);
|
||||
runtime.keypress(key, code);
|
||||
runtime.receive(key);
|
||||
runtime.controls.input({ data: key, inputType: "insertText" });
|
||||
runtime.release(key, code);
|
||||
// Real trace: the next key can arrive before insertText's zero-delay timer.
|
||||
}
|
||||
assert.equal(runtime.writes.join(""), " ABC");
|
||||
});
|
||||
|
||||
test("keyless insertText is still broadcast as actual text", () => {
|
||||
const runtime = setup();
|
||||
runtime.controls.input({ data: "中文", inputType: "insertText" });
|
||||
runtime.receive("中文");
|
||||
assert.equal(runtime.writes.join(""), "中文");
|
||||
});
|
||||
@@ -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