Files
Netcatty/application/state/useAutoSync.test.ts
T
904af93a1d fix(credentials): 防止解密失败后二次加密污染本地与云端 (#2702) (#2770)
* 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>
2026-08-06 13:17:24 +08:00

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",
);
});