mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-09-24 15:49:09 +00:00
904af93a1d
* 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>
160 lines
5.6 KiB
TypeScript
160 lines
5.6 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import type { Host, SSHKey } from "../domain/models";
|
|
import { STORAGE_KEY_DEFAULT_KEY_PASSPHRASES } from "../infrastructure/config/storageKeys";
|
|
import { buildVaultCsvCredentialOptions } from "./vaultCsvExportCredentials";
|
|
|
|
function installEmptyLocalStorage(t: test.TestContext): Map<string, string> {
|
|
const storage = new Map<string, string>();
|
|
Object.defineProperty(globalThis, "localStorage", {
|
|
configurable: true,
|
|
value: {
|
|
getItem: (key: string) => storage.get(key) ?? null,
|
|
setItem: (key: string, value: string) => storage.set(key, value),
|
|
},
|
|
});
|
|
Object.defineProperty(globalThis, "window", {
|
|
configurable: true,
|
|
value: { netcatty: undefined },
|
|
});
|
|
t.after(() => {
|
|
Reflect.deleteProperty(globalThis, "localStorage");
|
|
Reflect.deleteProperty(globalThis, "window");
|
|
});
|
|
return storage;
|
|
}
|
|
|
|
const host = (identityFileId?: string): Host => ({
|
|
id: "host-1",
|
|
label: "Host",
|
|
hostname: "host.example.com",
|
|
port: 22,
|
|
identityFileId,
|
|
identityFilePaths: identityFileId ? undefined : ["/Users/alice/.ssh/id_ed25519"],
|
|
authMethod: "key",
|
|
});
|
|
|
|
const referenceKey = (overrides: Partial<SSHKey> = {}): SSHKey => ({
|
|
id: "key-1",
|
|
label: "id_ed25519",
|
|
type: "ED25519",
|
|
category: "key",
|
|
source: "reference",
|
|
filePath: "/Users/alice/.ssh/id_ed25519",
|
|
privateKey: "",
|
|
created: 1,
|
|
...overrides,
|
|
});
|
|
|
|
test("CSV credentials prefer a readable reference-key passphrase without a false warning", async () => {
|
|
const result = await buildVaultCsvCredentialOptions(
|
|
[host("key-1")],
|
|
[referenceKey({ savePassphrase: true, passphrase: "key-secret" })],
|
|
async () => ({ values: [], unreadable: true, present: true }),
|
|
);
|
|
|
|
assert.equal(result.keyPassphrasesById.get("key-1"), "key-secret");
|
|
assert.equal(result.unreadablePassphraseCount, 0);
|
|
});
|
|
|
|
test("CSV credentials preserve legacy reference-key passphrases without a save flag", async () => {
|
|
const result = await buildVaultCsvCredentialOptions(
|
|
[host("key-1")],
|
|
[referenceKey({ passphrase: "legacy-secret" })],
|
|
async () => ({ values: [], unreadable: false, present: false }),
|
|
);
|
|
|
|
assert.equal(result.keyPassphrasesById.get("key-1"), "legacy-secret");
|
|
assert.equal(result.unreadablePassphraseCount, 0);
|
|
});
|
|
|
|
test("CSV credentials do not use stale path storage for an uninitialized legacy reference key", async () => {
|
|
const result = await buildVaultCsvCredentialOptions(
|
|
[host("key-1")],
|
|
[referenceKey()],
|
|
async () => ({ values: ["stale-secret"], unreadable: false, present: true }),
|
|
);
|
|
|
|
assert.equal(result.keyPassphrasesById.has("key-1"), false);
|
|
assert.equal(result.unreadablePassphraseCount, 0);
|
|
});
|
|
|
|
test("CSV credentials include a matching Keychain passphrase for a direct key path", async (t) => {
|
|
installEmptyLocalStorage(t);
|
|
const result = await buildVaultCsvCredentialOptions(
|
|
[host()],
|
|
[referenceKey({ savePassphrase: true, passphrase: "keychain-secret" })],
|
|
);
|
|
|
|
assert.equal(
|
|
result.keyPassphrases.get("/Users/alice/.ssh/id_ed25519"),
|
|
"keychain-secret",
|
|
);
|
|
assert.equal(result.unreadablePassphraseCount, 0);
|
|
});
|
|
|
|
test("CSV credentials exclude an explicitly unsaved Keychain passphrase for a direct key path", async (t) => {
|
|
const storage = installEmptyLocalStorage(t);
|
|
storage.set(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES, JSON.stringify({
|
|
"/Users/alice/.ssh/id_ed25519": "stale-side-secret",
|
|
}));
|
|
const result = await buildVaultCsvCredentialOptions(
|
|
[host()],
|
|
[referenceKey({ savePassphrase: false, passphrase: "stale-secret" })],
|
|
);
|
|
|
|
assert.equal(result.keyPassphrases.has("/Users/alice/.ssh/id_ed25519"), false);
|
|
assert.equal(result.unreadablePassphraseCount, 0);
|
|
});
|
|
|
|
test("CSV credentials warn when a saved reference-key passphrase cannot be read", async () => {
|
|
const result = await buildVaultCsvCredentialOptions(
|
|
[host("key-1")],
|
|
[referenceKey({ savePassphrase: true, passphrase: "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==" })],
|
|
async () => ({ values: [], unreadable: false, present: false }),
|
|
);
|
|
|
|
assert.equal(result.keyPassphrasesById.has("key-1"), false);
|
|
assert.equal(result.unreadablePassphraseCount, 1);
|
|
});
|
|
|
|
test("CSV credentials never fall back to stale path storage for an unsaved reference key", async () => {
|
|
const result = await buildVaultCsvCredentialOptions(
|
|
[host("key-1")],
|
|
[referenceKey({ savePassphrase: false })],
|
|
async () => ({ values: ["stale-secret"], unreadable: false, present: true }),
|
|
);
|
|
|
|
assert.equal(result.keyPassphrasesById.has("key-1"), false);
|
|
assert.equal(result.keyPassphrases.has("/Users/alice/.ssh/id_ed25519"), false);
|
|
assert.equal(result.unreadablePassphraseCount, 0);
|
|
});
|
|
|
|
test("CSV credentials use readable path storage only when a reference key is marked saved", async () => {
|
|
const result = await buildVaultCsvCredentialOptions(
|
|
[host("key-1")],
|
|
[referenceKey({ savePassphrase: true })],
|
|
async () => ({ values: ["side-store-secret"], unreadable: false, present: true }),
|
|
);
|
|
|
|
assert.equal(result.keyPassphrasesById.get("key-1"), "side-store-secret");
|
|
assert.equal(result.unreadablePassphraseCount, 0);
|
|
});
|
|
|
|
test("CSV credentials omit ambiguous path storage and warn", async () => {
|
|
for (const read of [
|
|
{ values: ["stale-secret"], unreadable: true, present: true },
|
|
{ values: ["old-secret", "new-secret"], unreadable: false, present: true },
|
|
]) {
|
|
const result = await buildVaultCsvCredentialOptions(
|
|
[host("key-1")],
|
|
[referenceKey({ savePassphrase: true })],
|
|
async () => read,
|
|
);
|
|
|
|
assert.equal(result.keyPassphrasesById.has("key-1"), false);
|
|
assert.equal(result.unreadablePassphraseCount, 1);
|
|
}
|
|
});
|