mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-11 00:30:57 +00:00
* fix(credentials): stop double-encrypting undecryptable safeStorage blobs When local decrypt failed (e.g. OSCrypt key churn after reboot), encrypt used to wrap the leftover enc:v1 ciphertext again, permanently poisoning the vault. Startup sync could also push those placeholders to cloud and make download restore the same poison. - Keep real enc:v1 blobs unchanged on encrypt (header check, no wrap) - Strip device-bound placeholders when applying portable sync payloads - Guard startup local-wins / merge round-trips before upload - Skip vault init re-encrypt writes when secrets are still undecrypted Fixes #2702 Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(sync): strip enc:v1 secrets from smart-merge uploads Bugbot found that after local apply sanitized placeholders, legacy v1 smart-merge could still decrypt an unstripped remote, merge it, and re-upload the poison. Strip device-bound credentials on remote decrypt and again on the merged payload before upload. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(credentials): address Codex P2 review on #2702 - Always re-encrypt vault init batches so plaintext siblings are not left unprotected when one record is a stale enc:v1 placeholder - Sanitize device-bound secrets before convergent restore prepare so CRDT replica commit matches vault import - Require a complete safeStorage blob (header + min 31 bytes) before treating enc:v1 as ciphertext; encrypt header-only coincidences Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(credentials): address Codex P1 review on #2702 - Decode enc:v1 payloads with atob in the renderer-safe domain helper - Heal poisoned remote secrets from local/base before smart-merge so good credentials are not discarded as remote-only deletions - Keep post-merge strip so leftover enc:v1 never uploads Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(credentials): address Codex P1 CBC min and startup heal - Accept 19-byte v10/v11 CBC OSCrypt blobs (not only 31-byte GCM) - Heal poisoned remote secrets before startup smart-merge - Strip unresolved placeholders on merge round-trip upload Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(sync): sanitize payloads at every apply/commit boundary Codex P1: vault apply stripped enc:v1 while commitRemoteInspection and convergent materialization could keep the poison. Sanitize merged/remote payloads before apply+base commit, strip on CRDT materialization, and sanitize convergent apply inputs. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(sync): heal local and remote secrets before smart-merge Smart-merge could select a locally-changed entity whose only secret delta was enc:v1 poison, then strip and upload empty secrets. Heal both sides from the opposite payload/base before merge on all sync paths. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(sync): keep enc:v1 intact during convergent materialize Stripping device-bound secrets inside materializeSyncPayloadFromConvergentState broke envelope validation for poisoned-but-consistent v2 snapshots, so decrypt could not hydrate the clouds #2702 needs to recover. Portable stripping stays at apply/upload boundaries. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(credentials): detect Windows DPAPI by decoded header bytes Real DPAPI blobs start with 01 00 00 00 d0 8c... and base64-encode as AQAAANCM..., so the previous AQAAAA string prefix rejected them and allowed double-wrapping after key rotation. Match decoded headers in both the main-process bridge and renderer predicate. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * test(credentials): use complete enc:v1 fixtures for stricter detector Short placeholders like enc:v1:djEwAAAA no longer pass the platform header + minimum-size checks. Update auth/SFTP/proxy/sync fixtures to full v10-shaped blobs so npm test matches production validation. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(sync): treat preferred credential deletions as authoritative When healing enc:v1 before merge, an empty/missing secret on a present preferred entity is an intentional clear and must not be revived from base. Only fall back to base when preferred is absent or also poisoned. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(credentials): reject impossible v10/v11 ciphertext lengths Accept only CBC-aligned sizes (19+16n) or GCM-sized blobs (>=31) so coincidental enc:v1 plaintext of intermediate length is encrypted instead of treated as an undecryptable placeholder. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(credentials): require full DPAPI provider GUID signature Accept Windows safeStorage blobs only when they start with version 01 00 00 00 plus provider GUID df9d8cd0-1501-11d1-8c7a-00c04fc297eb, so coincidental 01 00 00 00 prefixes are not treated as ciphertext. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
239 lines
11 KiB
TypeScript
239 lines
11 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
|
|
test("auto-sync establishes the initial data baseline before debouncing edits", () => {
|
|
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
|
const baselineCommentIndex = source.indexOf("Establish the initial baseline immediately");
|
|
const baselineHelperIndex = source.indexOf("const establishInitialBaseline = () =>", baselineCommentIndex);
|
|
const initializedAssignmentIndex = source.indexOf("isInitializedRef.current = true;", baselineHelperIndex);
|
|
const hashReadIndex = source.indexOf("const currentHash = await getDataHash();", baselineHelperIndex);
|
|
const initializationGuardIndex = source.indexOf("if (!isInitializedRef.current)", baselineCommentIndex);
|
|
const baselineCallIndex = source.indexOf("establishInitialBaseline();", initializationGuardIndex);
|
|
const debounceCommentIndex = source.indexOf("Debounce first, then build the expensive full-data hash", initializationGuardIndex);
|
|
const debounceTimerIndex = source.indexOf("syncTimeoutRef.current = setTimeout", debounceCommentIndex);
|
|
|
|
assert.notEqual(baselineCommentIndex, -1);
|
|
assert.notEqual(baselineHelperIndex, -1);
|
|
assert.notEqual(initializationGuardIndex, -1);
|
|
assert.notEqual(initializedAssignmentIndex, -1);
|
|
assert.notEqual(hashReadIndex, -1);
|
|
assert.notEqual(baselineCallIndex, -1);
|
|
assert.notEqual(debounceCommentIndex, -1);
|
|
assert.notEqual(debounceTimerIndex, -1);
|
|
assert.ok(
|
|
initializedAssignmentIndex < hashReadIndex,
|
|
"initialization must be marked synchronously before reading the baseline hash",
|
|
);
|
|
assert.ok(
|
|
baselineCallIndex < debounceTimerIndex,
|
|
"the first baseline hash must be captured before scheduling the debounced auto-sync timer",
|
|
);
|
|
});
|
|
|
|
test("paused convergent sync captures a baseline before returning", () => {
|
|
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
|
const effectIndex = source.indexOf("// Debounced auto-sync when data changes");
|
|
const pausedGuardIndex = source.indexOf("if (convergentSyncPaused)", effectIndex);
|
|
const initializationGuardIndex = source.indexOf("if (!isInitializedRef.current)", pausedGuardIndex);
|
|
const baselineCallIndex = source.indexOf("establishInitialBaseline();", initializationGuardIndex);
|
|
const pausedReturnIndex = source.indexOf("return () =>", baselineCallIndex);
|
|
|
|
assert.notEqual(pausedGuardIndex, -1);
|
|
assert.notEqual(initializationGuardIndex, -1);
|
|
assert.notEqual(baselineCallIndex, -1);
|
|
assert.notEqual(pausedReturnIndex, -1);
|
|
assert.ok(
|
|
pausedGuardIndex < initializationGuardIndex
|
|
&& initializationGuardIndex < baselineCallIndex
|
|
&& baselineCallIndex < pausedReturnIndex,
|
|
"paused mode must preserve the pre-edit baseline before suppressing network sync",
|
|
);
|
|
});
|
|
|
|
test("an unchanged remote check cannot absorb an existing paused-edit baseline", () => {
|
|
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
|
const baselineSnapshotIndex = source.indexOf("const hadInitialBaseline = isInitializedRef.current");
|
|
const remoteChangeIndex = source.indexOf("inspectedRemoteChange = true", baselineSnapshotIndex);
|
|
const guardedUpdateIndex = source.indexOf(
|
|
"if (markCurrentDataSynced && (!hadInitialBaseline || inspectedRemoteChange))",
|
|
remoteChangeIndex,
|
|
);
|
|
|
|
assert.notEqual(baselineSnapshotIndex, -1);
|
|
assert.notEqual(remoteChangeIndex, -1);
|
|
assert.notEqual(guardedUpdateIndex, -1);
|
|
assert.ok(
|
|
baselineSnapshotIndex < remoteChangeIndex && remoteChangeIndex < guardedUpdateIndex,
|
|
"remote inspection must preserve an existing baseline unless it reconciled changed cloud data",
|
|
);
|
|
});
|
|
|
|
test("enabled convergent remote checks use the CRDT runtime before legacy inspection", () => {
|
|
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
|
const checkIndex = source.indexOf("const checkRemoteVersion = useCallback");
|
|
const convergentGuardIndex = source.indexOf(
|
|
"if (currentConvergentConfig.initialized && currentConvergentConfig.enabled)",
|
|
checkIndex,
|
|
);
|
|
const convergentSyncIndex = source.indexOf(
|
|
"syncNowRef.current({ notifyOnFailure, allowEmptyConvergentSync })",
|
|
convergentGuardIndex,
|
|
);
|
|
const legacyInspectionIndex = source.indexOf(
|
|
"manager.inspectProviderRemote(connectedProvider)",
|
|
convergentSyncIndex,
|
|
);
|
|
|
|
assert.notEqual(checkIndex, -1);
|
|
assert.notEqual(convergentGuardIndex, -1);
|
|
assert.notEqual(convergentSyncIndex, -1);
|
|
assert.notEqual(legacyInspectionIndex, -1);
|
|
assert.ok(
|
|
convergentGuardIndex < convergentSyncIndex && convergentSyncIndex < legacyInspectionIndex,
|
|
"v2 checks must return through the CRDT runtime before the legacy snapshot inspector",
|
|
);
|
|
});
|
|
|
|
test("enabled convergent startup checks preview empty-vault recovery before syncing", () => {
|
|
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
|
const convergentGuardIndex = source.indexOf(
|
|
"if (currentConvergentConfig.initialized && currentConvergentConfig.enabled)",
|
|
);
|
|
const previewIndex = source.indexOf("manager.previewConvergentRecovery()", convergentGuardIndex);
|
|
const recoveryPromptIndex = source.indexOf(
|
|
"requestEmptyVaultRecovery(recoveryPayload)",
|
|
previewIndex,
|
|
);
|
|
const cloudWinsIndex = source.indexOf(
|
|
"conflictActionOverride: 'download-remote'",
|
|
recoveryPromptIndex,
|
|
);
|
|
const normalSyncIndex = source.indexOf(
|
|
"syncNowRef.current({ notifyOnFailure, allowEmptyConvergentSync })",
|
|
cloudWinsIndex,
|
|
);
|
|
|
|
assert.notEqual(convergentGuardIndex, -1);
|
|
assert.notEqual(previewIndex, -1);
|
|
assert.notEqual(recoveryPromptIndex, -1);
|
|
assert.notEqual(cloudWinsIndex, -1);
|
|
assert.notEqual(normalSyncIndex, -1);
|
|
assert.ok(
|
|
convergentGuardIndex < previewIndex
|
|
&& previewIndex < recoveryPromptIndex
|
|
&& recoveryPromptIndex < cloudWinsIndex
|
|
&& cloudWinsIndex < normalSyncIndex,
|
|
"v2 startup must offer recovery and use cloud-wins before the normal CRDT sync path",
|
|
);
|
|
});
|
|
|
|
test("empty v2 startup checks allow a validated empty sync to open the gate", () => {
|
|
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
|
const convergentGuardIndex = source.indexOf(
|
|
"if (currentConvergentConfig.initialized && currentConvergentConfig.enabled)",
|
|
);
|
|
const replicaIndex = source.indexOf("manager.loadConvergentReplica()", convergentGuardIndex);
|
|
const emptyDecisionIndex = source.indexOf("allowEmptyConvergentSync = (", replicaIndex);
|
|
const syncIndex = source.indexOf(
|
|
"syncNowRef.current({ notifyOnFailure, allowEmptyConvergentSync })",
|
|
emptyDecisionIndex,
|
|
);
|
|
const guardIndex = source.indexOf(
|
|
"options?.allowEmptyConvergentSync !== true",
|
|
source.indexOf("const syncNow = useCallback"),
|
|
);
|
|
|
|
assert.notEqual(replicaIndex, -1);
|
|
assert.notEqual(emptyDecisionIndex, -1);
|
|
assert.notEqual(syncIndex, -1);
|
|
assert.notEqual(guardIndex, -1);
|
|
assert.ok(replicaIndex < emptyDecisionIndex && emptyDecisionIndex < syncIndex);
|
|
});
|
|
|
|
test("auto-sync skips only the exact remote-applied data hash", () => {
|
|
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
|
const helperIndex = source.indexOf("const getSyncPayloadDataHash = (payload: SyncPayload): string");
|
|
const skipRefIndex = source.indexOf("const skipNextSyncHashRef = useRef<string | null>(null)");
|
|
const assignmentIndex = source.indexOf("skipNextSyncHashRef.current = getSyncPayloadDataHash(remotePayload)");
|
|
const debounceTimerIndex = source.indexOf("syncTimeoutRef.current = setTimeout", assignmentIndex);
|
|
const skipHashIndex = source.indexOf("const skipHash = skipNextSyncHashRef.current", debounceTimerIndex);
|
|
const decisionIndex = source.indexOf("resolveAutoSyncHashDecision({", skipHashIndex);
|
|
const appliedSkipIndex = source.indexOf("appliedSkipHash: skipHash", decisionIndex);
|
|
const syncingGuardIndex = source.indexOf("if (sync.isSyncing || isSyncRunningRef.current)", decisionIndex);
|
|
const restoreGuardIndex = source.indexOf("if (isRestoreInProgress())", decisionIndex);
|
|
const interruptedGuardIndex = source.indexOf("if (readInterruptedVaultApply())", decisionIndex);
|
|
const syncNowIndex = source.indexOf("const didSync = await syncNow();", interruptedGuardIndex);
|
|
const didSyncGuardIndex = source.indexOf("if (didSync && skipHash !== null", syncNowIndex);
|
|
const clearAfterSyncIndex = source.indexOf("skipNextSyncHashRef.current = null;", didSyncGuardIndex);
|
|
const booleanSkipIndex = source.indexOf("skipNextSyncRef");
|
|
|
|
assert.notEqual(helperIndex, -1);
|
|
assert.notEqual(skipRefIndex, -1);
|
|
assert.notEqual(assignmentIndex, -1);
|
|
assert.notEqual(debounceTimerIndex, -1);
|
|
assert.notEqual(skipHashIndex, -1);
|
|
assert.notEqual(decisionIndex, -1);
|
|
assert.notEqual(appliedSkipIndex, -1);
|
|
assert.notEqual(syncingGuardIndex, -1);
|
|
assert.notEqual(restoreGuardIndex, -1);
|
|
assert.notEqual(interruptedGuardIndex, -1);
|
|
assert.notEqual(syncNowIndex, -1);
|
|
assert.notEqual(didSyncGuardIndex, -1);
|
|
assert.notEqual(clearAfterSyncIndex, -1);
|
|
assert.equal(booleanSkipIndex, -1);
|
|
assert.ok(
|
|
skipHashIndex < decisionIndex,
|
|
"remote-apply skip must pass through the hash decision helper before suppressing a sync",
|
|
);
|
|
assert.ok(
|
|
interruptedGuardIndex < syncNowIndex && syncNowIndex < clearAfterSyncIndex,
|
|
"remote-apply skip hash must survive temporary sync blockers and clear only after a successful sync",
|
|
);
|
|
});
|
|
|
|
test("startup local-wins and merge round-trips refuse device-bound credential placeholders", () => {
|
|
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
|
const uploadLocalIndex = source.indexOf("if (conflictAction === 'upload-local')");
|
|
const uploadGuardIndex = source.indexOf(
|
|
"findSyncPayloadEncryptedCredentialPaths(localPayload)",
|
|
uploadLocalIndex,
|
|
);
|
|
const uploadPushIndex = source.indexOf("manager.syncAllProviders(localPayload)", uploadGuardIndex);
|
|
const mergeRoundTripBlockIndex = source.indexOf(
|
|
"stripSyncPayloadEncryptedCredentials(mergeResult.payload)",
|
|
);
|
|
const mergeLocalHealIndex = source.indexOf(
|
|
"healPoisonedSecretsForMerge(localPayload, remoteRaw, base)",
|
|
);
|
|
const mergeRemoteHealIndex = source.indexOf(
|
|
"healPoisonedSecretsForMerge(remoteRaw, localPayload, base)",
|
|
);
|
|
const mergePushIndex = source.indexOf(
|
|
"manager.syncAllProviders(portableMerge)",
|
|
mergeRoundTripBlockIndex,
|
|
);
|
|
const stripRemoteIndex = source.indexOf(
|
|
"stripSyncPayloadEncryptedCredentials(remoteRaw)",
|
|
);
|
|
|
|
assert.notEqual(uploadLocalIndex, -1);
|
|
assert.notEqual(uploadGuardIndex, -1);
|
|
assert.notEqual(uploadPushIndex, -1);
|
|
assert.notEqual(mergeLocalHealIndex, -1);
|
|
assert.notEqual(mergeRemoteHealIndex, -1);
|
|
assert.notEqual(mergeRoundTripBlockIndex, -1);
|
|
assert.notEqual(mergePushIndex, -1);
|
|
assert.notEqual(stripRemoteIndex, -1);
|
|
assert.ok(
|
|
uploadGuardIndex < uploadPushIndex,
|
|
"startup local-wins must guard placeholders before syncAllProviders",
|
|
);
|
|
assert.ok(
|
|
mergeLocalHealIndex < mergeRoundTripBlockIndex
|
|
&& mergeRemoteHealIndex < mergeRoundTripBlockIndex
|
|
&& mergeRoundTripBlockIndex < mergePushIndex,
|
|
"startup merge must heal local+remote secrets, then strip, then upload",
|
|
);
|
|
});
|