mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
db69d5ac39
* fix: harden sync overwrite recovery * refactor: separate backup retention settings * refactor: align backup retention controls * refactor: simplify backup retention card * fix: address PR #720 deep-review findings - Close the cross-window restore race by holding a time-bounded barrier in localStorage during every destructive apply; useAutoSync skips pushes while it's set, preventing a pre-restore snapshot from clobbering just-restored cloud data. - Round-trip startup three-way merges so merged-in local additions actually reach the cloud instead of living only on the device that ran the merge until the next edit. - Upgrade sync signatures from a 64-char ciphertext prefix to full SHA-256 (v3), closing the tail-mutation replay weakness. - Harden the vault-backup IPC: payload size cap, enum-validated reason, sanitized version strings, strict maxCount, concurrent-call mutex, monotonic createdAt to avoid same-ms ordering ties. - Extract the anchor-change decision into a pure module with unit tests covering no-anchor, resource-id drift, and signature mismatch paths. - Capture the protective backup from the pre-apply closure snapshot so it reflects what's being replaced rather than what was imported. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR #720 follow-up review findings Make protective backup abort-on-failure (was best-effort console.error), preserve nested syncedAt in fingerprint, use UTF-8 byte length for size guard, throw on conflict-inspect failure so stale uploads can't leak through, treat unreadable remote as changed, canonical-JSON signature meta, and hold the version stamp on transient backup failures so the retry path still fires. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address second-pass review findings on PR #720 - Hold version-change stamp when payload is non-meaningful (covers the startup vault-rehydrate race where a transient empty snapshot would permanently skip the upgrade backup). - readBackupRecord stat-checks before readFile so an oversized file in the backup dir cannot OOM the renderer on enumeration. - Reject maxBackups input outside 1..100 instead of silently clamping (matches the i18n error copy and the main-process sanitizer bound). - Wrap USE_LOCAL conflict-resolution push in withRestoreBarrier so a concurrent auto-sync in another window cannot interleave. - sha256Hex throws SyncSignatureUnavailableError on missing WebCrypto subtle; createSyncedFileSignature returns null, forcing the unreadable-remote → three-way-merge path instead of a weak length-only pseudo-signature. - Document that array order in normalizePayloadForHash is an invariant enforced by producers, not the hash function. - Drop three-way-merge completion logs from console.log to console.info. - Comment the implicit restore → store-listener refresh chain so future refactors don't silently break the UI reload path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address third-pass review findings on PR #720 Resolves I-3 through I-8 and related cleanup items identified in the deep review. Highlights: - replace setTimeout(0) post-merge round-trip with a direct syncAllProviders call using the already-computed merged payload, removing the React-commit race - resolve the empty-vault confirmation promise on unmount so a mid-dialog window teardown doesn't leak the resolver - retry the version-change backup as hosts/keys hydrate, instead of latching on the first (possibly empty) snapshot - heartbeat-refresh the cross-window restore barrier so long applies cannot expose a post-60s window to concurrent auto-sync - add a diagnostic warning when connected providers hold divergent bases (multi-account configurations) - surface a user-visible "Sync paused" toast when startup inspect fails, replacing the previous silent gate-open - tie-break backup list sort by id when createdAt collides - extract applyProtectedSyncPayload so the main and settings windows cannot drift on restore-barrier / protective-backup handling Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address deep-review findings on PR #720 Deep re-review surfaced six Important issues that survived the prior four review rounds. All are hardened here: - I1: fsync the protective backup file AND its directory before the rename completes, so a system crash between backup creation and the restore it guards cannot leave a torn/zero-length safety net. - I3: persist an apply-in-progress sentinel across the non-atomic localStorage writes in applySyncPayload. A crash mid-apply now surfaces on the next startup (toast + refuse auto-push) instead of silently pushing the half-applied state over an intact cloud copy. - I2: only open the auto-sync gate (remoteCheckDoneRef) when the startup inspect validated cleanly. Add a bounded exponential-backoff retry so a transient inspect failure self-heals instead of wedging auto-sync until restart. - I5: save the sync base BEFORE advancing the per-provider anchor inside uploadToProvider. A renderer crash between the two writes now degrades to "stale anchor forces re-inspect on next run," which re-merges against the fresh base — eliminating the silent base-drift window where a 3rd-device race could misclassify entries. - I6: main process broadcasts a vaultBackups:changed IPC event on every mutation; useLocalVaultBackups subscribes so protective backups created from the main window show up in the Settings backup list without manual refresh. - I4: update PR description + code comment to match the actual (safer) design: auto-sync gate opens on vault init, with hasMeaningfulSyncData + restore barrier preventing empty-push; the version-change backup is best-effort and retries as data hydrates. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: serialize startup checkRemoteVersion and stabilize its deps Re-review flagged that checkRemoteVersion's useCallback depended on `config` — a fresh object literal from App.tsx on every render — so the retry effect restarted with attempt=0 on every vault edit and could spawn overlapping in-flight inspect+apply runs. Two concurrent commitRemoteInspection + onApplyPayload calls could race on the apply-in-progress sentinel around interleaved writes. Route `buildPayload`, `config.onApplyPayload`, and `config.startupReady` through refs so checkRemoteVersion's identity no longer churns with unrelated App state. Add an in-flight guard that returns early when a previous invocation is still awaiting the network, closing the same-window re-entry gap that withRestoreBarrier intentionally doesn't cover. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: release in-flight lock on no-connected-provider early return Third-pass review caught that `checkRemoteInFlightRef` was acquired before the `!connectedProvider` check, so that early return leaked the lock and every subsequent retry-timer tick silently no-op'd. Move the acquisition past the early return so the only path that takes the lock reaches the finally-release. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
213 lines
8.4 KiB
JavaScript
213 lines
8.4 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { createSyncedFileSignature } from './syncSignature.js';
|
|
|
|
function makeSyncedFile(overrides = {}) {
|
|
const meta = {
|
|
version: 1,
|
|
updatedAt: 1_700_000_000_000,
|
|
deviceId: 'device-a',
|
|
deviceName: 'Device A',
|
|
appVersion: '1.0.0',
|
|
iv: 'BASE64_IV',
|
|
salt: 'BASE64_SALT',
|
|
algorithm: 'AES-256-GCM',
|
|
kdf: 'PBKDF2',
|
|
kdfIterations: 600000,
|
|
...(overrides.meta || {}),
|
|
};
|
|
return {
|
|
meta,
|
|
payload: overrides.payload ?? 'CIPHERTEXTxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
|
};
|
|
}
|
|
|
|
test('null file produces null signature', async () => {
|
|
assert.equal(await createSyncedFileSignature(null), null);
|
|
});
|
|
|
|
test('two identical files produce identical signatures', async () => {
|
|
const a = makeSyncedFile();
|
|
const b = makeSyncedFile();
|
|
assert.equal(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
|
|
});
|
|
|
|
test('signature is stable across meta key-insertion order', async () => {
|
|
const canonical = makeSyncedFile();
|
|
const shuffled = {
|
|
meta: {
|
|
kdf: 'PBKDF2',
|
|
salt: 'BASE64_SALT',
|
|
iv: 'BASE64_IV',
|
|
appVersion: '1.0.0',
|
|
deviceName: 'Device A',
|
|
deviceId: 'device-a',
|
|
updatedAt: 1_700_000_000_000,
|
|
version: 1,
|
|
algorithm: 'AES-256-GCM',
|
|
kdfIterations: 600000,
|
|
},
|
|
payload: canonical.payload,
|
|
};
|
|
assert.equal(await createSyncedFileSignature(canonical), await createSyncedFileSignature(shuffled));
|
|
});
|
|
|
|
test('changing iv flips the signature', async () => {
|
|
const a = makeSyncedFile();
|
|
const b = makeSyncedFile({ meta: { iv: 'DIFFERENT_IV' } });
|
|
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
|
|
});
|
|
|
|
test('changing salt flips the signature', async () => {
|
|
const a = makeSyncedFile();
|
|
const b = makeSyncedFile({ meta: { salt: 'DIFFERENT_SALT' } });
|
|
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
|
|
});
|
|
|
|
test('changing updatedAt flips the signature', async () => {
|
|
const a = makeSyncedFile();
|
|
const b = makeSyncedFile({ meta: { updatedAt: 1_700_000_000_001 } });
|
|
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
|
|
});
|
|
|
|
test('changing algorithm flips the signature (v1 regression guard)', async () => {
|
|
// The old signature only hashed version/updatedAt/deviceId/iv/salt — an
|
|
// adapter could have changed algorithm/kdf while holding those constant.
|
|
// v2+ must reject that.
|
|
const a = makeSyncedFile({ meta: { algorithm: 'AES-256-GCM' } });
|
|
const b = makeSyncedFile({ meta: { algorithm: 'ChaCha20-Poly1305' } });
|
|
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
|
|
});
|
|
|
|
test('changing kdf flips the signature (v1 regression guard)', async () => {
|
|
const a = makeSyncedFile({ meta: { kdf: 'PBKDF2' } });
|
|
const b = makeSyncedFile({ meta: { kdf: 'Argon2id' } });
|
|
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
|
|
});
|
|
|
|
test('changing appVersion flips the signature (v1 regression guard)', async () => {
|
|
const a = makeSyncedFile({ meta: { appVersion: '1.0.0' } });
|
|
const b = makeSyncedFile({ meta: { appVersion: '2.0.0' } });
|
|
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
|
|
});
|
|
|
|
test('changing payload ciphertext flips the signature even when meta matches', async () => {
|
|
// Critical: a malicious or buggy adapter could replay meta while swapping
|
|
// the ciphertext. v2+ must treat the payload as load-bearing.
|
|
const a = makeSyncedFile({ payload: 'AAA' + 'x'.repeat(60) });
|
|
const b = makeSyncedFile({ payload: 'BBB' + 'x'.repeat(60) });
|
|
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
|
|
});
|
|
|
|
test('changing payload length flips the signature (truncation guard)', async () => {
|
|
// v3 hashes the full ciphertext — any length difference flips the signature.
|
|
const prefix = 'x'.repeat(64);
|
|
const a = makeSyncedFile({ payload: prefix });
|
|
const b = makeSyncedFile({ payload: `${prefix}extra` });
|
|
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
|
|
});
|
|
|
|
test('tail-mutation of a long ciphertext flips the signature (v2 prefix-replay guard)', async () => {
|
|
// v2 only hashed the first 64 chars of the ciphertext. An adversary with
|
|
// write access to the remote could preserve the prefix and mutate only the
|
|
// tail, producing a signature collision. v3 hashes the full ciphertext and
|
|
// must catch tail mutations even when prefix + length are preserved.
|
|
const prefix = 'x'.repeat(64);
|
|
const tailA = 'AAAAAAAAAAAAAAAA';
|
|
const tailB = 'BBBBBBBBBBBBBBBB';
|
|
const a = makeSyncedFile({ payload: `${prefix}${tailA}` });
|
|
const b = makeSyncedFile({ payload: `${prefix}${tailB}` });
|
|
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
|
|
});
|
|
|
|
test('deviceId alone is not sufficient to match (metadata weighted properly)', async () => {
|
|
// Both share deviceId but differ on iv — must not alias.
|
|
const a = makeSyncedFile({ meta: { deviceId: 'same', iv: 'IV_A' } });
|
|
const b = makeSyncedFile({ meta: { deviceId: 'same', iv: 'IV_B' } });
|
|
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
|
|
});
|
|
|
|
test('missing optional meta fields hash as null rather than throwing', async () => {
|
|
const partial = {
|
|
meta: {
|
|
version: 1,
|
|
updatedAt: 1_700_000_000_000,
|
|
deviceId: 'device',
|
|
appVersion: '1.0.0',
|
|
iv: 'IV',
|
|
salt: 'S',
|
|
algorithm: 'AES-256-GCM',
|
|
kdf: 'PBKDF2',
|
|
// deviceName and kdfIterations omitted intentionally
|
|
},
|
|
payload: 'short',
|
|
};
|
|
const sig = await createSyncedFileSignature(partial);
|
|
assert.equal(typeof sig, 'string');
|
|
assert.ok(sig.startsWith('v3:'));
|
|
});
|
|
|
|
test('file with non-string payload produces signature with len=0', async () => {
|
|
// Defensive: if an adapter somehow yields a non-string payload, we still
|
|
// generate a well-formed signature rather than crashing.
|
|
const weird = { meta: makeSyncedFile().meta, payload: null };
|
|
const sig = await createSyncedFileSignature(weird);
|
|
assert.ok(sig);
|
|
assert.ok(sig.includes('len=0'));
|
|
assert.ok(sig.includes('sha256='));
|
|
});
|
|
|
|
test('signature contains a 64-char hex SHA-256 segment', async () => {
|
|
// Lock in the hash algorithm choice so a future regression to prefix-hashing
|
|
// is caught by this unit test.
|
|
const file = makeSyncedFile();
|
|
const sig = await createSyncedFileSignature(file);
|
|
assert.ok(sig);
|
|
const match = sig.match(/sha256=([a-f0-9]+)/);
|
|
assert.ok(match, `expected sha256=<hex> in signature, got ${sig}`);
|
|
assert.equal(match[1].length, 64);
|
|
});
|
|
|
|
test('v2-format anchor string does not equal a v3 signature', async () => {
|
|
// Migration guard: if a user's localStorage carries a v2-prefixed anchor
|
|
// from a previous build, comparing against a fresh v3 signature must flip
|
|
// to "remote changed" so we re-observe rather than treating a stale anchor
|
|
// as authoritative.
|
|
const file = makeSyncedFile();
|
|
const v3 = await createSyncedFileSignature(file);
|
|
const v2Like = String(v3).replace(/^v3:/, 'v2:').replace(/sha256=[a-f0-9]+$/, 'head=xxxxxxxxxxxxxxxx');
|
|
assert.notEqual(v3, v2Like);
|
|
});
|
|
|
|
test('missing WebCrypto subtle → signature is null (fail-closed, no weak fallback)', async () => {
|
|
// Earlier revisions returned `nosha-<length>` when subtle.digest was
|
|
// unavailable. That fallback was length-only, so an adversary
|
|
// controlling the remote could trivially produce a payload whose
|
|
// weak pseudo-signature equals a legitimate v3 signature of the
|
|
// same length. Failing to `null` routes decideRemoteChanged into the
|
|
// "unreadable remote → treat as changed → three-way merge" path,
|
|
// which is strictly safer.
|
|
//
|
|
// `globalThis.crypto` is a read-only getter in Node, so we override
|
|
// the `subtle` property on the existing object rather than
|
|
// reassigning the whole binding.
|
|
const subtleDescriptor = Object.getOwnPropertyDescriptor(globalThis.crypto, 'subtle');
|
|
Object.defineProperty(globalThis.crypto, 'subtle', {
|
|
configurable: true,
|
|
get() {
|
|
return undefined;
|
|
},
|
|
});
|
|
try {
|
|
const sig = await createSyncedFileSignature(makeSyncedFile());
|
|
assert.equal(sig, null, 'missing subtle must not produce a weak fallback string');
|
|
} finally {
|
|
if (subtleDescriptor) {
|
|
Object.defineProperty(globalThis.crypto, 'subtle', subtleDescriptor);
|
|
} else {
|
|
delete globalThis.crypto.subtle;
|
|
}
|
|
}
|
|
});
|